diff --git a/.doc/dev.md b/.doc/dev.md index b766706..a026683 100644 --- a/.doc/dev.md +++ b/.doc/dev.md @@ -33,15 +33,18 @@ To run the tests against a real Rex too: ```sh REXDIFF_REX=$(which rex) go test ./test/ +REXDIFF_REX=$(which rex) test/e2e.sh ``` +`test/e2e.sh` also needs jq. + ## Release ```sh -gh release create v0.3.0 +gh release create vX.Y.Z ``` -GitHub Actions builds the binaries and updates the Homebrew cask. +GitHub Actions runs the tests again, builds the binaries, and updates the Homebrew cask. ## Rules diff --git a/.doc/test.md b/.doc/test.md index a934a15..90fb8b3 100644 --- a/.doc/test.md +++ b/.doc/test.md @@ -9,6 +9,8 @@ rexdiff has library tests and black-box tests. - Each library is tested through its public API only, in `package x_test`. - A test that needs git runs real git in a temporary repository. `internal/testgit` hides your git config, so hooks and signing do not change the result. +- A test uses the real limits of a library. A hook in `export_test.go` lowers a limit only + if the real size makes the test slow. - A test gives the library its own environment, with fake tools on its own `PATH`. No test changes the environment of the process, so all of them run in parallel. @@ -17,12 +19,18 @@ rexdiff has library tests and black-box tests. - `tui` has no unit tests. The tests in `test/` build the binary and use it as you do. - Each test types keys and clicks through a terminal emulator, and reads the screen. - A test waits for the screen or for a file, never for a fixed time. -- One test waits for the log: `TestLastTurn` waits for the line `msg="turn start"` in - the debug log. +- Some tests wait for a line in the debug log, for example `msg="turn start"`. - The scripts in `test/testdata/script` test the commands, for example `install`, with testscript. - Shell scripts stand in for rex, gh, the clipboard, and the editors. The fake rex prints output recorded from a real Rex. -- `TestRealRex` runs rexdiff in a real Rex, to find a fake that no longer matches Rex. +- `test/e2e.sh` runs rexdiff in a real Rex, one step at a time. It walks through the user + guide, hostile file names, tiny and huge blocks, boxes open while the block resizes, and + a long review. Each step waits until the screen shows a whole frame. The script also + checks that rex still prints JSON in the shapes that the fake rex records. Run it with + `REXDIFF_REX=$(which rex) test/e2e.sh`. - The black-box tests run one at a time, because each one is a real program in a terminal. +- The perf tests in `test/perf_test.go` time the program on large repositories. They run + only if `REXDIFF_PERF` names a directory for the repositories. The libraries have Go + benchmarks: `go test -bench . ./diff ./vcs ./internal/tree`. diff --git a/cmd/rexdiff/install.go b/cmd/rexdiff/install.go index d9eb447..8795251 100644 --- a/cmd/rexdiff/install.go +++ b/cmd/rexdiff/install.go @@ -14,48 +14,40 @@ import ( "strings" "time" + "github.com/rednafi/rexdiff/internal/xctx" "github.com/rednafi/rexdiff/rex" ) -// The install command adds a Rex action, "Toggle rexdiff", to the command -// palette and binds it to a key. The action opens rexdiff beside the agent -// in focus. If rexdiff runs in the session, the action closes each block -// that runs rexdiff instead. The action finds these blocks by the program -// status that rexdiff reports. A Rex session is the same as a herdr -// workspace. -// -// The action is in two files beside Rex's init.lua, and init.lua loads them -// with one line. rexdiff.lua has the settings of the user. Install writes -// it only if it is not there, and rexdiff does not change it after that. -// rexdiff_toggle.lua has the toggle action. Install and upgrades write -// it whole. - +// The require line in init.lua loads rexdiff.lua, which holds the user's +// settings and loads rexdiff_toggle.lua, which holds the action. const ( - // requireLine is the line of init.lua that loads rexdiff.lua. + // requireLine is the line in init.lua that loads rexdiff.lua. requireLine = `require("rexdiff")` - luaFile = "rexdiff.lua" // beside init.lua - toggleFile = "rexdiff_toggle.lua" // beside init.lua + luaFile = "rexdiff.lua" // in the same directory as init.lua + toggleFile = "rexdiff_toggle.lua" // in the same directory as init.lua ) -// settingsLua is the content of a new rexdiff.lua: the settings, then the -// loader of rexdiff_toggle.lua. If rexdiff_toggle.lua fails, the loader keeps -// the rest of Rex's config, and the key shows why. +// settingsLua is the content of a new rexdiff.lua: the settings, followed +// by code that loads rexdiff_toggle.lua. If rexdiff_toggle.lua fails to +// load, the rest of the Rex config still works, and pressing the key shows +// the error. // //go:embed rexdiff.lua var settingsLua string -// loaderMark is the first line of the loader in settingsLua. +// loaderMark is the first line of the loading code in settingsLua. const loaderMark = "-- rexdiff_toggle.lua has the toggle action" -// oldMark is the line of a rexdiff.lua from rexdiff 0.2.7 above its code. -// The text above the line is the settings of the user. -const oldMark = "-- rexdiff code below. Install and upgrades replace it. Do not edit this line." +// v027Mark is the line that separates the user's settings, above it, from +// the action code, below it, in a rexdiff.lua written by rexdiff 0.2.7. +const v027Mark = "-- rexdiff code below. Install and upgrades replace it. Do not edit this line." -// fromOld returns the new rexdiff.lua for old, a rexdiff.lua from rexdiff -// 0.2.7. It keeps the settings of old, and puts the loader in place of the -// code. ok is false if old is not from 0.2.7. -func fromOld(old string) (s string, ok bool) { - above, _, ok := strings.Cut(old, oldMark) +// migrateV027 converts oldLua, a rexdiff.lua written by rexdiff 0.2.7, to +// the current format. It keeps the settings and replaces the action code +// with the code that loads rexdiff_toggle.lua. ok is false if oldLua was +// not written by 0.2.7. +func migrateV027(oldLua string) (s string, ok bool) { + above, _, ok := strings.Cut(oldLua, v027Mark) if !ok { return "", false } @@ -63,39 +55,38 @@ func fromOld(old string) (s string, ok bool) { return strings.TrimRight(above, "\n") + "\n\n" + loaderMark + loader, true } -// toggleCode is the code of the toggle action. It needs BIN, the path of -// rexdiff. +// toggleCode is the Lua code of the toggle action. It uses the variable +// BIN for the path of rexdiff. // //go:embed rexdiff_toggle.lua var toggleCode string -// toggleLua returns the content of rexdiff_toggle.lua for bin, the path of -// rexdiff. +// toggleLua returns the content of rexdiff_toggle.lua for the rexdiff +// binary at bin. func toggleLua(bin string) string { return "-- rexdiff writes this file. Do not edit it. Your settings are in rexdiff.lua.\n" + binLine(bin) + "\n" + toggleCode } -// binLine returns the line of rexdiff_toggle.lua that names bin. upgrade uses -// it to find which rexdiff the file runs. +// binLine returns the line of rexdiff_toggle.lua that sets BIN to bin. func binLine(bin string) string { return "local BIN = " + luaQuote(bin) } -// luaEscapes escapes the characters that a Lua string cannot have as they -// are. A Replacer is safe for concurrent use. +// luaEscapes escapes the characters that cannot appear unescaped in a Lua +// string literal. var luaEscapes = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`, "\r", `\r`, "\x00", `\0`) -// luaQuote returns s as a Lua string. Go's %q writes escapes, such as -// \u200b, that Lua 5.1 does not know. +// luaQuote returns s as a Lua string literal. It does not use %q, because +// Lua 5.1 does not support escapes such as \u200b. func luaQuote(s string) string { return `"` + luaEscapes.Replace(s) + `"` } -// luaPath returns the path of the file name in the environment env, -// beside the init.lua of Rex. +// luaPath returns the path of the file called name in the directory of +// the Rex init.lua for env. func luaPath(env []string, name string) string { return filepath.Join(filepath.Dir(rex.ConfigPath(env)), name) } -// readFile returns the content of the file at path, or "" if there is no -// file. +// readFile returns the content of the file at path, or "" if the file does +// not exist. func readFile(path string) (string, error) { b, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { @@ -104,32 +95,35 @@ func readFile(path string) (string, error) { return string(b), err } -// executable returns the path of the rexdiff binary, for the Rex action. It -// keeps a symlink, such as the one from Homebrew, because the target of the -// link changes at each upgrade. -func executable() (string, error) { +// executablePath returns the path of the rexdiff binary for the Rex action +// to run. It does not resolve symbolic links, such as the one Homebrew +// creates, because the link target changes with every upgrade. +func executablePath() (string, error) { bin, err := os.Executable() if err != nil { return "", err } - // go run deletes its binary at exit, so the action cannot run it. + // go run deletes its binary when it exits, so the action could not run + // it later. if strings.Contains(bin, string(filepath.Separator)+"go-build") { return "", errors.New("install the binary first, for example with go install, then run rexdiff install") } return bin, nil } -// install writes rexdiff_toggle.lua with the action that runs bin, the path -// of rexdiff. It writes rexdiff.lua if it is not there, and moves the code -// out of a rexdiff.lua from rexdiff 0.2.7. It adds the require line to -// init.lua. env is the environment of rexdiff. +// install writes rexdiff_toggle.lua with the Rex action "Toggle rexdiff", +// bound to a key, which opens bin next to the focused agent or, if +// rexdiff is already running in the session, closes every rexdiff block. +// It writes rexdiff.lua if it does not exist, migrates a rexdiff.lua from +// 0.2.7, adds the require line to init.lua and reloads the Rex config. +// env is the environment of rexdiff. func install(ctx context.Context, env []string, bin string, args []string, stdout, stderr io.Writer) error { if err := parseSub("install", args, env, stderr); err != nil { return err } settingsPath, togglePath := luaPath(env, luaFile), luaPath(env, toggleFile) - old, err := readFile(settingsPath) + oldLua, err := readFile(settingsPath) if err != nil { return err } @@ -140,9 +134,9 @@ func install(ctx context.Context, env []string, bin string, args []string, stdou return err } fmt.Fprintf(stdout, "wrote %s\n", togglePath) - next, fromV027 := fromOld(old) + next, fromV027 := migrateV027(oldLua) switch { - case old == "": + case oldLua == "": next = settingsLua fmt.Fprintf(stdout, "wrote %s with your settings\n", settingsPath) case fromV027: @@ -163,25 +157,25 @@ func install(ctx context.Context, env []string, bin string, args []string, stdou if added { fmt.Fprintf(stdout, "added %s to %s\n", requireLine, initPath) } - reload(ctx, env, stdout, stderr) + reloadRex(ctx, env, stdout, stderr) fmt.Fprintln(stdout, "press the key of the settings (shift+cmd+r by default), or run \"Toggle rexdiff\" from the command palette, beside an agent") return nil } -// upgradeReloadTimeout is the longest time of the reload of the Rex -// config after an upgrade. -const upgradeReloadTimeout = 10 * time.Second +// migrateReloadTimeout limits how long migrateLua waits for Rex to reload +// its config. +const migrateReloadTimeout = 10 * time.Second -// upgrade writes rexdiff_toggle.lua with the code for bin, if the file is -// there and differs, and reloads Rex's config. A rexdiff.lua from rexdiff -// 0.2.7 gets its code moved to rexdiff_toggle.lua, and keeps its settings. -// It is not the update of rexdiff itself (see the update package). It -// upgrades only the Lua code of the action. It does nothing if the files -// run another rexdiff, for example a build for development. It returns -// what the user must know, or "". env is the environment of rexdiff. -func upgrade(ctx context.Context, env []string, bin string) string { +// migrateLua rewrites rexdiff_toggle.lua with the current code for bin if +// the file exists and is out of date, migrates a rexdiff.lua from 0.2.7, +// and then reloads the Rex config. It does nothing if the files run a +// different rexdiff binary, for example a development build. It returns a +// message for the user, or "". env is the environment of rexdiff. +func migrateLua(ctx context.Context, env []string, bin string) string { settingsPath, togglePath := luaPath(env, luaFile), luaPath(env, toggleFile) - old, err := readFile(settingsPath) + // The migration is best effort: if a file cannot be read, rexdiff + // starts as usual and leaves the files as they are. + oldLua, err := readFile(settingsPath) if err != nil { return "" } @@ -189,10 +183,11 @@ func upgrade(ctx context.Context, env []string, bin string) string { if err != nil { return "" } - // Each file names the rexdiff that it runs on this line. + // Each file contains this line, which names the rexdiff binary that it + // runs. mine := "\n" + binLine(bin) + "\n" - next, fromV027 := fromOld(old) - fromV027 = fromV027 && strings.Contains(old, mine) + next, fromV027 := migrateV027(oldLua) + fromV027 = fromV027 && strings.Contains(oldLua, mine) want := toggleLua(bin) if !fromV027 && (code == want || !strings.Contains(code, mine)) { return "" @@ -205,29 +200,26 @@ func upgrade(ctx context.Context, env []string, bin string) string { return luaFile + " upgrade failed: " + err.Error() } } - // The upgrade runs before the first frame, and the user does not see - // it. Thus the reload has a deadline. - ctx, cancel := context.WithCancelCause(ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, upgradeReloadTimeout, - fmt.Errorf("rex config reload: %v timeout: %w", upgradeReloadTimeout, context.DeadlineExceeded)) - defer cancelTimeout() - defer cancel(fmt.Errorf("rex config reload: done: %w", context.Canceled)) + // migrateLua runs before rexdiff draws its first frame, so the user + // cannot see or cancel it. Give the reload a deadline. + ctx, cancel := xctx.WithTimeout(ctx, migrateReloadTimeout, "rex config reload") + defer cancel() if err := rex.ReloadConfig(ctx, env); err != nil { return "upgraded " + toggleFile + ". Reload Rex's config yourself: rex config reload" } return "" } -// addRequire adds the require line to the end of the init.lua at path. It -// creates the file if it is not there. If the file has the line, addRequire -// does not change it. It reports whether it adds the line. +// addRequire appends the require line to the init.lua at path, creating +// the file if needed. It does nothing if the line is already there, and +// reports whether it added the line. func addRequire(path string) (bool, error) { s, err := readFile(path) if err != nil { return false, err } for line := range strings.Lines(s) { - if isRequire(line) { + if isRequireLine(line) { return false, nil } } @@ -237,35 +229,36 @@ func addRequire(path string) (bool, error) { return true, os.WriteFile(path, []byte(s+requireLine+"\n"), 0o644) } -// isRequire reports whether line is the require line of rexdiff, maybe -// with a comment after it. -func isRequire(line string) bool { +// isRequireLine reports whether line is rexdiff's require line, possibly +// followed by a Lua comment. +func isRequireLine(line string) bool { t := strings.TrimSpace(line) return t == requireLine || strings.HasPrefix(t, requireLine+" --") } -// parseSub parses the arguments of the subcommand name, which has no -// flags. It returns the errors of parseFlags. env is the environment of +// parseSub parses the arguments of the subcommand name, which takes no +// flags or arguments. For -h it returns [flag.ErrHelp]. For an unknown +// flag or any argument it returns errUsage. env is the environment of // rexdiff. func parseSub(name string, args, env []string, stderr io.Writer) error { - fs := flag.NewFlagSet("rexdiff "+name, flag.ContinueOnError) - fs.SetOutput(stderr) - if err := fs.Parse(args); err != nil { + flags := flag.NewFlagSet("rexdiff "+name, flag.ContinueOnError) + flags.SetOutput(stderr) + if err := flags.Parse(args); err != nil { return usageError(err) } - if fs.NArg() > 0 { - // Show the usage, as the flag package does for a bad flag. + if flags.NArg() > 0 { + // Print the usage, as the flag package does for an unknown flag. fmt.Fprintf(stderr, "unknown argument %q (set the key and the placement in the settings in %s)\n", - fs.Arg(0), luaPath(env, luaFile)) - fs.Usage() + flags.Arg(0), luaPath(env, luaFile)) + flags.Usage() return errUsage } return nil } -// uninstall removes the require line from init.lua, then rexdiff.lua with -// the settings, and rexdiff_toggle.lua. In this order, a failure does not -// leave init.lua with a require of a file that is not there. env is the +// uninstall removes the require line from init.lua, then deletes +// rexdiff.lua and rexdiff_toggle.lua. Because of this order, a failure +// never leaves init.lua requiring a file that no longer exists. env is the // environment of rexdiff. func uninstall(ctx context.Context, env, args []string, stdout, stderr io.Writer) error { if err := parseSub("uninstall", args, env, stderr); err != nil { @@ -281,27 +274,27 @@ func uninstall(ctx context.Context, env, args []string, stdout, stderr io.Writer } } fmt.Fprintln(stdout, "removed the rexdiff action, its key, and its settings from Rex") - reload(ctx, env, stdout, stderr) + reloadRex(ctx, env, stdout, stderr) return nil } -// removeRequire removes the require line of rexdiff from the init.lua at -// path. If the file is not there, it does nothing. +// removeRequire removes rexdiff's require line from the init.lua at path. +// If the file does not exist, it does nothing. func removeRequire(path string) error { s, err := readFile(path) if err != nil || s == "" { return err } - // Split, not Lines, because Join needs the lines without their ends. - lines := slices.DeleteFunc(strings.Split(s, "\n"), isRequire) + // Use Split, not Lines, because Join needs lines without their + // newlines. + lines := slices.DeleteFunc(strings.Split(s, "\n"), isRequireLine) return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644) } -// reload tells Rex to read its configuration file again. rex runs with -// the environment env. If Rex fails, reload tells the user on stderr how -// to reload. The reload has no deadline, because the user sees it in a -// terminal and can press Ctrl-C. -func reload(ctx context.Context, env []string, stdout, stderr io.Writer) { +// reloadRex runs rex with env to make Rex read its configuration file +// again. If that fails, reloadRex tells the user on stderr how to reload +// by hand. The reload has no deadline, because the user can press Ctrl-C. +func reloadRex(ctx context.Context, env []string, stdout, stderr io.Writer) { if err := rex.ReloadConfig(ctx, env); err != nil { fmt.Fprintf(stderr, "reload Rex's config yourself: rex config reload (%v)\n", err) return diff --git a/cmd/rexdiff/main.go b/cmd/rexdiff/main.go index 8aae8ff..cd7fc5d 100644 --- a/cmd/rexdiff/main.go +++ b/cmd/rexdiff/main.go @@ -1,9 +1,8 @@ -// Rexdiff shows the changes that a coding agent makes, beside the agent in -// Rex. You can comment on the changed lines, then paste the comments into the -// agent. "rexdiff install" adds a "Toggle rexdiff" action and a key to the Rex -// configuration, in two files beside Rex's init.lua. rexdiff.lua has the -// settings of rexdiff, and rexdiff_toggle.lua has the code of the action. The -// action gives the settings to rexdiff in the --config flag. +// Command rexdiff shows the changes that a coding agent makes, in a Rex +// pane next to the agent. You can comment on changed lines and paste the +// comments into the agent. "rexdiff install" adds a "Toggle rexdiff" action and a +// key binding to the Rex configuration. The action passes the settings +// from rexdiff.lua to rexdiff with the --config flag. // // rexdiff [flags] [repo] // rexdiff install @@ -32,34 +31,39 @@ import ( ) func main() { - // A closed pane sends SIGHUP. The stop of ctx lets rexdiff remove its - // files before it exits. + // Closing the pane sends SIGHUP. Cancel ctx on these signals so rexdiff + // can remove its temporary files before it exits. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) - // rexdiff reads the environment once, here. The packages get it as a + // Read the environment only here, and pass it to the packages as a // value. err := run(ctx, os.Environ(), os.Args[1:], os.Stdout, os.Stderr) if err != nil && ctx.Err() != nil { - // The error of a stopped program tells only "context canceled". - // The cause names the signal. + // The error says only "context canceled", but the cause names the + // signal. err = context.Cause(ctx) } stop() switch { case errors.Is(err, flag.ErrHelp): + // -h printed the usage, which is a success. case errors.Is(err, errUsage): - os.Exit(2) // stderr already has the error + os.Exit(exitUsage) // the error is already printed on stderr case err != nil: fmt.Fprintln(os.Stderr, "rexdiff:", err) os.Exit(1) } } -// errUsage is the error of a bad flag or argument. stderr already has the -// error and the usage. +// exitUsage is the exit status for an invalid flag or argument, as with +// the flag package. +const exitUsage = 2 + +// errUsage is the error for an invalid flag or argument. By the time it is +// returned, the error message and the usage are already printed on +// stderr. var errUsage = errors.New("bad usage") -// usageError returns err of flag parsing for -h, and errUsage for the -// other errors. +// usageError returns err for -h, and errUsage for other flag errors. func usageError(err error) error { if errors.Is(err, flag.ErrHelp) { return err @@ -67,7 +71,8 @@ func usageError(err error) error { return errUsage } -// usage is the text above the flags in the help of the diff viewer. +// usage is the help text printed before the list of the diff viewer's +// flags. const usage = `usage: rexdiff [flags] [repo] rexdiff install rexdiff uninstall @@ -75,13 +80,13 @@ const usage = `usage: rexdiff [flags] [repo] flags: ` -// run runs the subcommand in args, or else the diff viewer. env is the -// environment of rexdiff, as "key=value" pairs. +// run runs the subcommand named in args, or the diff viewer if there is +// none. env is the environment of rexdiff, as "key=value" pairs. func run(ctx context.Context, env, args []string, stdout, stderr io.Writer) error { if len(args) > 0 { switch args[0] { case "install": - bin, err := executable() + bin, err := executablePath() if err != nil { return err } @@ -98,15 +103,15 @@ func run(ctx context.Context, env, args []string, stdout, stderr io.Writer) erro fmt.Fprintln(stdout, cmp.Or(update.Version(version), "(devel)")) return nil } - if f.check { - return tui.CheckConfig(f.config) - } - if err := tui.CheckFlags(tui.Layout(f.layout), tui.Scope(f.scope)); err != nil { + if err := tui.ValidateFlags(tui.Layout(f.layout), tui.Scope(f.scope)); err != nil { return err } + if f.check { + return tui.ValidateConfig(f.config) + } - // The programs that rexdiff starts, such as an editor, do not get the - // variable of the resume file. + // Remove the resume variable, so programs that rexdiff starts, such as + // an editor, do not inherit it. resumePath, env := takeResume(env) dir := cmp.Or(f.repo, ".") repo, err := vcs.Open(ctx, env, dir) @@ -120,68 +125,70 @@ func run(ctx context.Context, env, args []string, stdout, stderr io.Writer) erro } defer closeLog() - // In Rex, upgrade writes rexdiff_toggle.lua if it differs from the code - // of this rexdiff. - client := rex.New(env) + // When running in Rex, update rexdiff_toggle.lua if it differs from + // the code that this rexdiff would write. + client, inRex := rex.New(env) var notice string - bin, binErr := executable() - if binErr == nil && client != nil { - notice = upgrade(ctx, env, bin) + bin, binErr := executablePath() + if binErr == nil && inRex { + notice = migrateLua(ctx, env, bin) } + installer, _ := update.NewInstaller(env, bin, version) // nil if rexdiff cannot update itself resume, err := tui.Run(ctx, tui.Options{ - Repo: repo, - Rex: client, - Notice: notice, - Updater: update.Detect(env, bin, version), - Resume: resumePath, - Env: env, - Config: f.config, - Layout: tui.Layout(f.layout), - Scope: tui.Scope(f.scope), - Agent: f.agent, - Base: f.base, - Wrap: f.wrap, - Log: log, + Repo: repo, + Rex: client, + Notice: notice, + Installer: installer, + Resume: resumePath, + Env: env, + Config: f.config, + Layout: tui.Layout(f.layout), + Scope: tui.Scope(f.scope), + Agent: f.agent, + Base: f.base, + Wrap: f.wrap, + Log: log, }) if err != nil || resume == "" { return err } - // After an update, the new rexdiff runs in this process. + // After an update, the new rexdiff replaces this process. closeLog() - _ = repo.Close() // the new process does not run the deferred calls - return restart(bin, args, env, resume, stdout) + _ = repo.Close() // the deferred calls never run once the process is replaced + return restart(env, bin, args, resume, stdout) } -// restart runs the new rexdiff at bin in this process after an update, -// with the same args and env. resumeEnv gives it the saved state in the -// file resume. restart returns only if it fails. -func restart(bin string, args, env []string, resume string, stdout io.Writer) error { +// restart replaces this process with the new rexdiff at bin after an +// update. The new rexdiff gets the same env and args, plus the path of the +// file resume, which holds the saved state. restart returns only if it +// fails. +func restart(env []string, bin string, args []string, resume string, stdout io.Writer) error { env = slices.Concat(env, []string{resumeEnv + "=" + resume}) - err := update.Restart(bin, args, env) // returns only if it fails - // Run keeps the alternate screen for the restart. Leave that screen and - // show the cursor. + err := update.Restart(env, bin, args) // returns only if it fails + // tui.Run leaves the terminal on the alternate screen for the new + // process. Since the restart failed, switch back and show the cursor. fmt.Fprint(stdout, "\x1b[?1049l\x1b[?25h") return fmt.Errorf("start the new rexdiff (your comments are saved in %s): %w", resume, err) } -// resumeEnv is the variable that gives a restarted rexdiff the path of the -// file with the state from before the update. rexdiff uses a variable, not a -// flag, so that the arguments of the user do not change. +// resumeEnv is the environment variable that tells a restarted rexdiff +// where to find the state saved before the update. It is an environment +// variable, not a flag, so the user's arguments stay the same. const resumeEnv = "REXDIFF_RESUME" -// takeResume returns the file in the variable resumeEnv of env, or "". It -// also returns a copy of env without the variable. +// takeResume returns the value of resumeEnv in env, or "" if it is not +// set, and a copy of env without that variable. func takeResume(env []string) (path string, rest []string) { path = proc.Getenv(env, resumeEnv) isResume := func(kv string) bool { return strings.HasPrefix(kv, resumeEnv+"=") } return path, slices.DeleteFunc(slices.Clone(env), isResume) } -// version is the version of rexdiff that GoReleaser sets, for example -// "0.2.0". It is "" in other builds. +// version is the rexdiff version that GoReleaser sets at build time, for +// example "0.2.0". It is "" in other builds. var version string -// flags are the command-line arguments of the diff viewer. +// flags holds the command-line arguments of the diff viewer. type flags struct { layout string scope string @@ -190,23 +197,24 @@ type flags struct { agent string logPath string config string // the settings as JSON, or "" - check bool // check the settings and exit + check bool // validate the settings and exit version bool // print the version and exit - repo string // the repo argument, or "" + repo string // the repository argument, or "" } -// parseFlags parses the arguments of the diff viewer. The defaults of -// -agent and -log come from env. It writes the usage and the errors of the -// flags to stderr. For -h, it returns [flag.ErrHelp], and for a bad flag -// or argument errUsage. +// parseFlags parses the arguments of the diff viewer. The defaults for +// -agent and -log come from env. It prints the usage and flag errors on +// stderr. For -h it returns [flag.ErrHelp]. For an invalid flag or an +// extra argument it returns errUsage. func parseFlags(args, env []string, stderr io.Writer) (flags, error) { var f flags fs := flag.NewFlagSet("rexdiff", flag.ContinueOnError) fs.SetOutput(stderr) - // tui.Run checks the layout and the scope, as in the settings. + // The caller validates the layout and scope values. fs.StringVar(&f.layout, "layout", "", "diff layout: unified (top to bottom) or split (side by side)") fs.StringVar(&f.scope, "scope", "", "start scope: uncommitted, branch, or last-turn") - // As in herdr-reviewr, -wrap takes on or off, so "-wrap off" works. + // -wrap is a string flag that takes on or off, so that "-wrap off" + // works. A boolean flag would need "-wrap=false". wrap := fs.String("wrap", "on", "wrap of long lines: on or off (the key w toggles it)") fs.StringVar(&f.base, "base", "", "base for the branch scope (default: the picked base, else the repo's default branch)") fs.StringVar(&f.agent, "agent", proc.Getenv(env, "REXDIFF_AGENT"), "Rex block (ID or label) of the agent beside this pane") @@ -222,7 +230,7 @@ func parseFlags(args, env []string, stderr io.Writer) (flags, error) { return flags{}, usageError(err) } if fs.NArg() > 1 { - // Show the usage, as the flag package does for a bad flag. + // Print the usage, as the flag package does for an unknown flag. fmt.Fprintf(stderr, "unknown argument %q (flags go before the repo)\n", fs.Arg(1)) fs.Usage() return flags{}, errUsage @@ -235,15 +243,25 @@ func parseFlags(args, env []string, stderr io.Writer) (flags, error) { } // openLog returns a logger that appends to the file at path, and a function -// that closes the file. If path is empty, the logger discards everything. -// The log is only for debugging, so the close ignores errors. +// that closes the file, ignoring any error. If path is empty, the logger +// discards all output. A new file is readable only by the user, because +// the log names files and agents. The path may also be a terminal or a +// FIFO, but opening a FIFO with no reader fails at once instead of +// waiting for one. func openLog(path string) (*slog.Logger, func(), error) { if path == "" { return slog.New(slog.DiscardHandler), func() {}, nil } - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + flags := os.O_APPEND | os.O_CREATE | os.O_WRONLY | syscall.O_NONBLOCK + f, err := os.OpenFile(path, flags, 0o600) + if pe, ok := errors.AsType[*os.PathError](err); ok { + err = pe.Err // the path is in the message already + } + if errors.Is(err, syscall.ENXIO) { + err = errors.New("the FIFO has no reader, start one first") + } if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("open log %s: %w", path, err) } h := slog.NewTextHandler(f, &slog.HandlerOptions{Level: slog.LevelDebug}) return slog.New(h), func() { _ = f.Close() }, nil diff --git a/diff/bench_test.go b/diff/bench_test.go new file mode 100644 index 0000000..275f310 --- /dev/null +++ b/diff/bench_test.go @@ -0,0 +1,156 @@ +package diff_test + +import ( + "fmt" + "math/rand/v2" + "strings" + "testing" + + "github.com/rednafi/rexdiff/diff" +) + +// benchFile returns a Go file of n lines, and a copy in which about one in +// every edit lines is changed. With edit 1, every line changes. +func benchFile(n, edit int) (oldText, newText string) { + var o, c strings.Builder + r := rand.New(rand.NewPCG(1, uint64(n))) //nolint:gosec // a fixed seed produces the same files on every run + for i := range n { + line := fmt.Sprintf("\tx += compute(%d, \"s%d\") // L%d\n", i, i*7, i) + if i%20 == 0 { + line = fmt.Sprintf("func f%d(x int) int { // L%d\n", i, i) + } + o.WriteString(line) + if edit == 1 || r.IntN(edit) == 0 { + line = strings.Replace(line, "// L", "// M", 1) + } + c.WriteString(line) + } + return o.String(), c.String() +} + +// benchSizes lists the benchmark files, with their line counts and edit +// rates for benchFile. +var benchSizes = []struct { + name string + n, edit int +}{ + {"7k-changed", 7_000, 3}, + {"20k-rewrite", 20_000, 1}, + {"50k-scattered", 50_000, 250}, + {"200k-scattered", 200_000, 1000}, +} + +// screen reads one screen of lines from d, starting at line top, as a +// view does. +func screen(d *diff.Diff, top int) { + for i := top; i < min(top+50, len(d.Rows)); i++ { + d.Line(i) + } +} + +// BenchmarkRender measures rendering a diff and reading its first screen. +// The cold cases use a new Cache each time. The scroll cases render again +// after a horizontal scroll, which reuses the cached diff and rows. +func BenchmarkRender(b *testing.B) { + for _, s := range benchSizes { + oldText, newText := benchFile(s.n, s.edit) + for _, layout := range []string{"unified", "split", "wrap"} { + o := diff.Options{Width: 130, Split: layout == "split", Wrap: layout == "wrap"} + b.Run(s.name+"/"+layout+"/cold", func(b *testing.B) { + for b.Loop() { + o.Cache = &diff.Cache{} + screen(diff.Render("x.go", oldText, newText, o), 0) + } + }) + b.Run(s.name+"/"+layout+"/scroll", func(b *testing.B) { + o.Cache = &diff.Cache{} + diff.Render("x.go", oldText, newText, o) + for b.Loop() { + if !o.Wrap { + o.ScrollX = 8 - o.ScrollX + } + screen(diff.Render("x.go", oldText, newText, o), 0) + } + }) + } + b.Run(s.name+"/eager", func(b *testing.B) { + if s.n > 20_000 { + b.Skip("an eager render of a large file is slow") + } + for b.Loop() { + diff.Render("x.go", oldText, newText, diff.Options{Width: 130, Cache: &diff.Cache{}}) + } + }) + } +} + +// BenchmarkLines measures reading the next screen of lines from a lazy +// diff, as when paging down. +func BenchmarkLines(b *testing.B) { + oldText, newText := benchFile(50_000, 3) + o := diff.Options{Width: 130, Cache: &diff.Cache{}} + d := diff.Render("x.go", oldText, newText, o) + top := 0 + for b.Loop() { + if top+50 >= len(d.Rows) { + b.StopTimer() + d, top = diff.Render("x.go", oldText, newText, o), 0 + b.StartTimer() + } + screen(d, top) + top += 50 + } +} + +// BenchmarkRenderFile measures what the Files tab does: rendering a file +// and reading its first screen. Each render uses a different file from the +// previous one, so the Cache stops the previous background highlighting. +func BenchmarkRenderFile(b *testing.B) { + for _, s := range benchSizes { + srcs := [2]string{} + srcs[0], srcs[1] = benchFile(s.n, s.edit) + for _, wrap := range []bool{false, true} { + b.Run(fmt.Sprintf("%s/wrap=%v", s.name, wrap), func(b *testing.B) { + o := diff.Options{Width: 130, Wrap: wrap, Cache: &diff.Cache{}} + i := 0 + for b.Loop() { + screen(diff.RenderFile("x.go", srcs[i%2], fileOptions(o)), 0) + i++ + } + diff.RenderFile("x.go", "", fileOptions(o)) // stops the last background highlighting + }) + } + } +} + +// BenchmarkCountLines measures counting added and deleted lines, as the +// file list does. +func BenchmarkCountLines(b *testing.B) { + for _, s := range benchSizes { + oldText, newText := benchFile(s.n, s.edit) + b.Run(s.name, func(b *testing.B) { + for b.Loop() { + diff.CountLines(oldText, newText) + } + }) + } + oldText, newText := fewValues(25_000) + b.Run("50k-few-values", func(b *testing.B) { + for b.Loop() { + diff.CountLines(oldText, newText) + } + }) +} + +// fewValues returns two texts of n lines each, made of random lines from a +// few distinct values. Such texts are the slowest input for a diff. +func fewValues(n int) (oldText, newText string) { + r := rand.New(rand.NewPCG(1, 2)) //nolint:gosec // a fixed seed produces the same texts on every run + values := []string{"a\n", "b\n", "}\n", "\n", "return\n"} + var o, c strings.Builder + for range n { + o.WriteString(values[r.IntN(len(values))]) + c.WriteString(values[r.IntN(len(values))]) + } + return o.String(), c.String() +} diff --git a/diff/caps_test.go b/diff/caps_test.go new file mode 100644 index 0000000..c8cb949 --- /dev/null +++ b/diff/caps_test.go @@ -0,0 +1,57 @@ +package diff_test + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/rednafi/rexdiff/diff" +) + +// longLine returns a line of n bytes of Java keywords and words, which +// the Java lexer is slow on. +func longLine(n int) string { + return strings.Repeat("public static lorem ipsum ", n/26+1)[:n] +} + +// keywordColour is the escape sequence of a Java keyword. +const keywordColour = "\x1b[35m" + +// TestLongLinePlain checks that Render and RenderFile draw a line too long +// to highlight without colours, and the short lines around it with them, +// with and without wrapping. Highlighting the line would take about 30 s. +func TestLongLinePlain(t *testing.T) { + t.Parallel() + long := longLine(100_000) + src := "class A {}\n" + long + "\nclass B {}\n" + for _, wrap := range []bool{false, true} { + o := diff.Options{Width: 80, Wrap: wrap} + renders := map[string]*diff.Diff{ + "Render": diff.Render("A.java", "", src, o), + "RenderFile": diff.RenderFile("A.java", src, fileOptions(o)), + } + for name, d := range renders { + var shown []string + for i := range len(d.Rows) { + if d.Rows[i].New == 2 { + shown = append(shown, d.Line(i)) + } + } + if len(shown) == 0 { + t.Fatalf("%s, wrap %v: no rows for the long line", name, wrap) + } + if wrap && !strings.Contains(ansi.Strip(strings.Join(shown, "")), long[len(long)-40:]) { + t.Errorf("%s, wrap %v: the rows do not show the end of the long line", name, wrap) + } + for _, l := range shown { + if strings.Contains(l, keywordColour) { + t.Fatalf("%s, wrap %v: long line row %.60q… has colours", name, wrap, l) + } + } + if last := d.Line(len(d.Rows) - 1); !strings.Contains(last, keywordColour) { + t.Errorf("%s, wrap %v: last row %q, want it highlighted", name, wrap, last) + } + } + } +} diff --git a/diff/cell.go b/diff/cell.go index 710f9e1..1fa37a6 100644 --- a/diff/cell.go +++ b/diff/cell.go @@ -9,56 +9,99 @@ import ( "github.com/rednafi/rexdiff/internal/diffview" ) -// Cell tells which source character the cell at column x of line row -// shows. Column 0 is the first column of the line. It returns the side, -// the line number from 1, and the index of the rune in [Lines] of that -// side. The index can be past the end of the line, for a cell after the -// code. ok is false if the cell is not on code, for example on a line -// number, a mark, or a hunk header. -func (d *Diff) Cell(row, x int) (s Side, line, index int, ok bool) { +// Cell reports which source character is shown in the cell at column x of +// screen line row, where column 0 is the first column. It returns the +// side, the 1-based line number, and the rune index within that line as +// split by [SplitLines]. For a cell after the end of the code, the index is the +// line's length in runes. ok is false if the cell is not on code, for +// example on a line number, a sign or a hunk header. +func (d *Diff) Cell(row, x int) (side Side, line, index int, ok bool) { if row < 0 || row >= len(d.Rows) || d.Rows[row].Kind == KindHunk { return 0, 0, 0, false } r := d.Rows[row] - s, onNumber := d.SideAt(x) + var onGutter bool + side, onGutter = d.SideAt(x) if !d.split { - s = SideNew + side = SideNew if r.New == 0 { - s = SideOld + side = SideOld } } - line = r.Line(s) - if onNumber || line == 0 || x < d.codeAt[s] { + line = r.Line(side) + if onGutter || line == 0 || x < d.codeAt[side] { return 0, 0, 0, false } - text, ok := lineAt(d.src[s], line) - if !ok { + text, found := lineAt(d.texts[side], line) + if !found { return 0, 0, 0, false } - col := x - d.codeAt[s] + d.offset + col := x - d.codeAt[side] + d.scrollX if d.wrap { - start, width := d.segmentStart(row, s, text) - if col >= width { // the blank cells after a wrapped part - return s, line, utf8.RuneCountInString(text), true + start, width := d.segmentStart(row, side, text) + if col >= width { // a blank cell after the end of a wrapped part + return side, line, utf8.RuneCountInString(text), true } col += start } - return s, line, runeAt(text, col), true + return side, line, runeAt(text, col), true } -// segmentStart returns the column in the source line text where the -// screen line row starts on side s, and the width of its code. A wrapped -// line can drop the spaces where it breaks. Thus it finds each screen line -// in the text, in order. -func (d *Diff) segmentStart(row int, s Side, text string) (start, width int) { +// CodeWidth returns the number of columns that show code on side, or 0 if +// the lines have no width limit. +func (d *Diff) CodeWidth(side Side) int { return max(d.codeWidth[side], 0) } + +// Locate finds where the byte at offset i of a source line is shown. row +// is the first screen line of the source line, and side the side that +// shows it. Locate returns the screen line that shows the byte, which is +// row unless the line wraps, and the column of the byte counted from the +// start of the code on that screen line, as if not scrolled. +func (d *Diff) Locate(row int, side Side, i int) (line, col int) { + if row < 0 || row >= len(d.Rows) { + return row, 0 + } + text, found := lineAt(d.texts[side], d.Rows[row].Line(side)) + if !found { + return row, 0 + } + col = ansi.StringWidth(expand(text[:min(max(i, 0), len(text))])) + if !d.wrap { + return row, col + } + // Go through the screen lines of the wrapped line in order, as + // segmentStart does, keeping the width of the code before each one. + shown := expand(text) + at, start := 0, 0 // the byte offset in shown and the column after the previous screen line + for r := row; r < len(d.Rows) && (r == row || d.Rows[r].Continued); r++ { + seg := d.segment(r, side) + k := strings.Index(shown[at:], seg) + if k < 0 { + break + } + start += ansi.StringWidth(shown[at : at+k]) + width := ansi.StringWidth(seg) + last := r+1 >= len(d.Rows) || !d.Rows[r+1].Continued + if col < start+width || last { + return r, col - start + } + at, start = at+k+len(seg), start+width + } + return row, col +} + +// segmentStart returns the column in the source line text where screen +// line row starts on the given side, and the width of the code on that +// screen line. It searches text for each screen line of the wrapped line +// in order, because wrapping can drop spaces at the breaks. +func (d *Diff) segmentStart(row int, side Side, text string) (start, width int) { first := row for first > 0 && d.Rows[first].Continued { first-- } shown := expand(text) - at := 0 // the byte in shown after the last screen line + at := 0 // byte offset in shown just after the previous screen line for i := first; i <= row; i++ { - seg := d.segment(i, s) + seg := d.segment(i, side) k := strings.Index(shown[at:], seg) if k < 0 { break @@ -71,26 +114,26 @@ func (d *Diff) segmentStart(row int, s Side, text string) (start, width int) { return ansi.StringWidth(shown[:at]), 0 } -// segment returns the code of side s on screen line i, without the -// spaces at the end. -func (d *Diff) segment(i int, s Side) string { - plain := ansi.Strip(d.Lines[i]) +// segment returns the code of the given side on screen line i, without +// trailing spaces. +func (d *Diff) segment(i int, side Side) string { + plain := ansi.Strip(d.Line(i)) end := ansi.StringWidth(plain) - if d.split && s == SideOld { + if d.split && side == SideOld { end = d.newNumAt } - return strings.TrimRight(ansi.Cut(plain, d.codeAt[s], end), " ") + return strings.TrimRight(ansi.Cut(plain, d.codeAt[side], end), " ") } -// expand returns line as the screen shows it, with the tabs as spaces and -// the control characters as symbols. +// expand returns line as it appears on screen, with tabs replaced by +// spaces and control characters replaced by symbols. func expand(line string) string { - return diffview.Escape(strings.ReplaceAll(line, "\t", strings.Repeat(" ", diffview.TabWidth))) + return diffview.Escape(diffview.ExpandTabs(line)) } -// runeAt returns the index of the first rune of the grapheme cluster of -// line that covers column col. It returns the number of runes if col is -// past the end. +// runeAt returns the index of the first rune of the grapheme cluster in +// line that covers column col. If col is past the end, it returns the +// number of runes in line. func runeAt(line string, col int) int { i, c := 0, 0 for line != "" { @@ -109,7 +152,8 @@ func runeAt(line string, col int) int { return i } -// lineAt returns line n of src, from 1, as [Lines] splits it. +// lineAt returns the 1-based line n of src, as split by [SplitLines]. It +// returns false if src has fewer than n lines. func lineAt(src string, n int) (string, bool) { for l := range strings.Lines(src) { if n--; n == 0 { diff --git a/diff/cell_test.go b/diff/cell_test.go index a041ed4..5493e55 100644 --- a/diff/cell_test.go +++ b/diff/cell_test.go @@ -9,7 +9,8 @@ import ( "github.com/rednafi/rexdiff/diff" ) -// screenRune returns the rune of the plain line that covers column x. +// screenRune returns the rune of plain that covers column x. It returns +// false if x is past the end of the line. func screenRune(plain string, x int) (rune, bool) { c := 0 for _, r := range plain { @@ -22,18 +23,18 @@ func screenRune(plain string, x int) (rune, bool) { return 0, false } -// plainLine returns line i of d without the escapes. It stops the test if d -// has no line i. +// plainLine returns line i of d without escape sequences. It fails the +// test if d has no line i. func plainLine(t *testing.T, d *diff.Diff, i int) string { t.Helper() - if i < 0 || i >= len(d.Lines) { - t.Fatalf("line %d of a diff with %d lines", i, len(d.Lines)) + if i < 0 || i >= len(screenLines(d)) { + t.Fatalf("line %d of a diff with %d lines", i, len(screenLines(d))) } - return ansi.Strip(d.Lines[i]) + return ansi.Strip(screenLines(d)[i]) } -// column returns the column of the first cell of needle in line i of d. -// With last, it finds the last needle. +// column returns the screen column where needle starts in line i of d. If +// last is true, it uses the last occurrence of needle. func column(t *testing.T, d *diff.Diff, i int, needle string, last bool) int { t.Helper() plain := plainLine(t, d, i) @@ -47,18 +48,24 @@ func column(t *testing.T, d *diff.Diff, i int, needle string, last bool) int { return ansi.StringWidth(plain[:k]) } -// TestCell checks the source character of a cell, with tabs, an offset, -// the split layout, and a file. +// TestCell checks the source character that [Diff.Cell] returns for +// various cells, with tabs, a horizontal scroll, split view and a file +// view. func TestCell(t *testing.T) { t.Parallel() - const before, after = "a\n\told_name := 1\n", "a\n\tnew_name := 1\n" - u := diff.Render("x.go", before, after, diff.Options{Width: 80}) - off := diff.Render("x.go", before, after, diff.Options{Width: 80, Offset: 6}) - s := diff.Render("x.go", before, after, diff.Options{Width: 80, Split: true}) - f := diff.RenderFile("x.go", after, diff.Options{Width: 80}) + const oldSrc, newSrc = "a\n\told_name := 1\n", "a\n\tnew_name := 1\n" + // Each subtest renders its own Diff, because a Diff is not safe for + // concurrent use. + render := func(o diff.Options) func() *diff.Diff { + return func() *diff.Diff { return diff.Render("x.go", oldSrc, newSrc, o) } + } + u := render(diff.Options{Width: 80}) + off := render(diff.Options{Width: 80, ScrollX: 6}) + s := render(diff.Options{Width: 80, Split: true}) + f := func() *diff.Diff { return diff.RenderFile("x.go", newSrc, diff.FileOptions{Width: 80}) } tests := []struct { name string - d *diff.Diff + d func() *diff.Diff row int needle string last bool @@ -77,8 +84,9 @@ func TestCell(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - x := column(t, tt.d, tt.row, tt.needle, tt.last) - side, line, char, ok := tt.d.Cell(tt.row, x) + d := tt.d() + x := column(t, d, tt.row, tt.needle, tt.last) + side, line, char, ok := d.Cell(tt.row, x) if !ok || side != tt.side || line != tt.line || char != tt.char { t.Errorf("Cell(%d, %d) = %v, %d, %d, %v, want %v, %d, %d, true", tt.row, x, side, line, char, ok, @@ -86,21 +94,23 @@ func TestCell(t *testing.T) { } }) } - // A cell of the tab gives the tab. A cell after the code gives the end. - x := column(t, u, 3, "new", false) - if _, _, char, _ := u.Cell(3, x-1); char != 0 { + ud := u() + // A cell inside a tab returns the tab. A cell after the code returns + // the line's length. + x := column(t, ud, 3, "new", false) + if _, _, char, _ := ud.Cell(3, x-1); char != 0 { t.Errorf("the tab: char %d, want 0", char) } - if _, _, char, ok := u.Cell(3, 79); !ok || char != 14 { + if _, _, char, ok := ud.Cell(3, 79); !ok || char != 14 { t.Errorf("after the code: char %d, %v, want 14, true", char, ok) } - // The numbers, the mark, and the hunk header are not code. + // Line numbers, signs and hunk headers are not code. for _, c := range []struct{ row, x int }{{3, 0}, {3, 4}, {3, x - 5}, {0, 20}, {9, 20}} { - if _, _, _, ok := u.Cell(c.row, c.x); ok { + if _, _, _, ok := ud.Cell(c.row, c.x); ok { t.Errorf("Cell(%d, %d) is code, want not code", c.row, c.x) } } - // The other side of a split insert has no line. + // In split view, the old side of an added line is empty. ins := diff.Render("x.go", "a\n", "a\nb\n", diff.Options{Width: 80, Split: true}) if _, _, _, ok := ins.Cell(2, 10); ok { t.Error("the empty old side of an insert is code, want not code") @@ -108,7 +118,8 @@ func TestCell(t *testing.T) { } // TestCellMatchesScreen checks that each code cell shows the character -// that Cell gives, in each layout, with wrap, and with an offset. +// that [Diff.Cell] returns, for each renderer, with cut, scrolled and +// wrapped lines. func TestCellMatchesScreen(t *testing.T) { t.Parallel() var words []string @@ -116,14 +127,14 @@ func TestCellMatchesScreen(t *testing.T) { words = append(words, "w"+string(rune('a'+i%26))+strings.Repeat("x", i%4)) } long := "\t" + strings.Join(words, " ") + " 日本 end" - before, after := "a\nb\n", "a\n"+long+"\nb\n" + oldSrc, newSrc := "a\nb\n", "a\n"+long+"\nb\n" opts := map[string]diff.Options{ "cut": {Width: 60}, - "offset": {Width: 60, Offset: 13}, + "offset": {Width: 60, ScrollX: 13}, "wrap": {Width: 60, Wrap: true}, } - src := map[diff.Side][]string{diff.SideOld: diff.Lines(before), diff.SideNew: diff.Lines(after)} - for _, tt := range renderers(before, after) { + src := map[diff.Side][]string{diff.SideOld: diff.SplitLines(oldSrc), diff.SideNew: diff.SplitLines(newSrc)} + for _, tt := range renderers(oldSrc, newSrc) { t.Run(tt.name, func(t *testing.T) { t.Parallel() for on, o := range opts { @@ -134,11 +145,11 @@ func TestCellMatchesScreen(t *testing.T) { } // checkCells checks that each code cell of d shows the character that -// Cell gives. The lines of src are the source of each side. +// [Diff.Cell] returns. src holds the source lines of each side. func checkCells(t *testing.T, on string, d *diff.Diff, src map[diff.Side][]string) { t.Helper() checked := 0 - for i, l := range d.Lines { + for i, l := range screenLines(d) { plain := ansi.Strip(l) for x := range ansi.StringWidth(plain) { side, line, char, ok := d.Cell(i, x) @@ -170,17 +181,17 @@ func checkCells(t *testing.T, on string, d *diff.Diff, src map[diff.Side][]strin } } -// TestCellGraphemes checks Cell after grapheme clusters of more than one -// code point, in a cut line and in a wrapped line. +// TestCellGraphemes checks [Diff.Cell] after grapheme clusters made of +// several code points, in a cut line and in a wrapped line. func TestCellGraphemes(t *testing.T) { t.Parallel() for _, g := range []string{"❤️", "👨‍👩‍👧", "🇺🇸"} { line := strings.Repeat(g+"x ", 20) src := []rune(line) for _, o := range []diff.Options{{Width: 200}, {Width: 40, Wrap: true}} { - d := diff.RenderFile("x.txt", line+"\n", o) + d := diff.RenderFile("x.txt", line+"\n", fileOptions(o)) found := 0 - for i, l := range d.Lines { + for i, l := range screenLines(d) { plain := ansi.Strip(l) for k := range len(plain) { if plain[k] != 'x' { @@ -202,17 +213,16 @@ func TestCellGraphemes(t *testing.T) { } } -// TestCellOffsetInWideChar checks Cell when the offset cuts a wide -// character. The content moves by the offset, so Cell agrees with the -// screen. +// TestCellOffsetInWideChar checks that [Diff.Cell] agrees with the screen +// when the horizontal scroll cuts through a wide character. func TestCellOffsetInWideChar(t *testing.T) { t.Parallel() const line = "日本x" for _, off := range []int{1, 3} { - o := diff.Options{Width: 40, Offset: off} + o := diff.Options{Width: 40, ScrollX: off} for name, d := range map[string]*diff.Diff{ "diff": diff.Render("x.txt", "", line+"\n", o), - "file": diff.RenderFile("x.txt", line+"\n", o), + "file": diff.RenderFile("x.txt", line+"\n", fileOptions(o)), } { row := len(d.Rows) - 1 for char, needle := range []rune(line) { @@ -228,3 +238,53 @@ func TestCellOffsetInWideChar(t *testing.T) { } } } + +// TestLocate checks that Locate finds the screen line and column of a +// match in a long line, with and without wrapping, in a diff and in a +// file view. +func TestLocate(t *testing.T) { + t.Parallel() + line := "\tstart " + strings.Repeat("word ", 40) + "HIT end" + at := strings.Index(line, "HIT") + src := "a\n" + line + "\n" + for _, tt := range []struct { + name string + o diff.Options + file bool // render a file view instead of a diff + row int // the first screen line of line + }{ + {"diff", diff.Options{Width: 60}, false, 2}, + {"diff wrapped", diff.Options{Width: 60, Wrap: true}, false, 2}, + {"split wrapped", diff.Options{Width: 90, Split: true, Wrap: true}, false, 2}, + {"file", diff.Options{Width: 60}, true, 1}, + {"file wrapped", diff.Options{Width: 60, Wrap: true}, true, 1}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + d := diff.Render("x.txt", "a\n", src, tt.o) + if tt.file { + d = diff.RenderFile("x.txt", src, fileOptions(tt.o)) + } + if len(d.Rows) == 0 { + t.Fatal("no lines") + } + got, col := d.Locate(tt.row, diff.SideNew, at) + // The column of the code start, found from the "start" word on + // the first screen line. + codeAt := column(t, d, tt.row, "start", false) - len(" ") + width := d.CodeWidth(diff.SideNew) + if !strings.HasSuffix(tt.name, "wrapped") { + if got != tt.row || col <= width { + t.Errorf("Locate = %d, %d, want line %d and a column past the width %d", got, col, tt.row, width) + } + return + } + if got == tt.row || col >= width { + t.Fatalf("Locate = %d, %d, want a later screen line and a column within the width %d", got, col, width) + } + if r, ok := screenRune(plainLine(t, d, got), codeAt+col); !ok || r != 'H' { + t.Errorf("screen line %d shows %q at column %d, want 'H'", got, r, codeAt+col) + } + }) + } +} diff --git a/diff/comment.go b/diff/comment.go index 3b4e2cc..bfe2472 100644 --- a/diff/comment.go +++ b/diff/comment.go @@ -12,49 +12,45 @@ import ( ) // Comment is a review note on a range of lines on one side of a file. Use -// [NewComment] to make one. A comment keeps the text of its lines and its -// diff rows, for [Comments.Review]. As in herdr-reviewr, a comment does not -// move when its file changes. It stays on its line numbers. +// [NewComment] to create one. A comment stores the text of its lines and +// its diff rows for [Comments.Review]. When the file changes, the comment +// keeps its line numbers and does not follow its code; [Comment.Moved] +// reports whether its lines still hold that code. type Comment struct { ID int // [Comments.Add] sets it Path string Side Side - // Start and End are the first and the last line of the range. They - // start at 1. + // Start and End are the 1-based first and last lines of the range. Start, End int Text string - // Anchor tells the view that the comment shows in. + // Anchor identifies the view that shows the comment. Anchor Anchor code []string // the text of the lines Start to End - snippet []string // the rows as unified diff lines, or empty to use code - // noAnchor is true for a comment from a file with no anchors. + snippet []string // the rows as unified diff lines, or empty to use code instead + // noAnchor is true for a comment loaded from a file without anchors. noAnchor bool } -// Anchor is the view that a comment is made in. A comment shows only in a -// view with the same anchor. +// Anchor identifies the view in which a comment was written. A comment is +// shown only in a view with the same anchor. type Anchor struct { - // InDiff is true for a comment on a diff, and false for a comment on - // a file view. + // InDiff is true for a comment on a diff, and false for a comment in + // the file view. InDiff bool - // Rev names the commits of the diff, or is "" for the working tree. + // Rev names the commits being compared, or is "" for the working tree. Rev string } -// NewComment returns a comment with no text on side s of the file at path. -// Rows are rows of the [Diff] of before and after. Start and End are the -// first and the last line of the rows on side s. They are 0 if no row has -// a line on s. -// -// In the diff rows of the comment, equal, deleted, and inserted lines start -// with " ", "-", and "+". The text comes from before and after, so tabs -// stay. In each block of changes, deleted lines come before inserted -// lines, also in the split layout. Continued rows add nothing. -func NewComment(path string, s Side, rows []Row, before, after string) Comment { - c := Comment{Path: path, Side: s, snippet: snippet(rows, before, after)} +// NewComment returns an empty comment on side of the file at path, for +// rows of the [Diff] between oldText and newText. Start and End are the +// first and last lines of the rows on that side, or 0 if there are none. +// The comment keeps the rows as unified diff lines taken from oldText and +// newText, with deleted lines before added ones. +func NewComment(path string, side Side, rows []Row, oldText, newText string) Comment { + c := Comment{Path: path, Side: side, snippet: snippet(rows, oldText, newText)} for _, r := range rows { - n := r.Line(s) + n := r.Line(side) if n == 0 { continue } @@ -66,14 +62,14 @@ func NewComment(path string, s Side, rows []Row, before, after string) Comment { if c.Start == 0 { return c } - lines := Lines(version(s, before, after)) + lines := SplitLines(versionText(side, oldText, newText)) c.code = slices.Clone(lines[min(c.Start-1, len(lines)):min(c.End, len(lines))]) return c } -// Location returns the path and the lines of c, for example "a.go:3-4". -// A comment on the old side has " (removed)" at the end. [termtext.Quote] -// quotes the path. +// Location returns the path and line range of c, for example "a.go:3-4". +// For a comment on the old side, it appends " (removed)". The path is +// quoted with [termtext.Quote]. func (c *Comment) Location() string { loc := fmt.Sprintf("%s:%d", termtext.Quote(c.Path), c.Start) if c.End != c.Start { @@ -85,8 +81,29 @@ func (c *Comment) Location() string { return loc } -// Comments is the set of comments of a review. The zero value is an empty -// set that is ready to use. +// Moved reports whether text, the version of the file on the side of c, +// no longer holds the code of c on lines Start to End: the lines hold +// other code, or text has fewer lines. It reports false for a comment +// with no code to compare, such as one read from an older file. +func (c *Comment) Moved(text string) bool { + if c.Start == 0 || len(c.code) != c.End-c.Start+1 { + return false + } + n := 0 + for l := range strings.Lines(text) { + n++ + if n > c.End { + break + } + if n >= c.Start && trimEOL(l) != c.code[n-c.Start] { + return true + } + } + return n < c.End +} + +// Comments is the set of comments in a review. The zero value is an empty +// set ready to use. type Comments struct { last int items []*Comment @@ -95,15 +112,15 @@ type Comments struct { // Len returns the number of comments. func (cs *Comments) Len() int { return len(cs.items) } -// Add adds c with a new ID. +// Add adds c and gives it a new ID. func (cs *Comments) Add(c Comment) { cs.last++ c.ID = cs.last cs.items = append(cs.items, &c) } -// Remove removes the comment with the given ID, if there is one. -func (cs *Comments) Remove(id int) { +// Delete removes the comment with the given ID, if there is one. +func (cs *Comments) Delete(id int) { cs.items = slices.DeleteFunc(cs.items, func(c *Comment) bool { return c.ID == id }) } @@ -114,10 +131,9 @@ func (cs *Comments) All() []*Comment { }) } -// SetOldRev sets the Rev of each comment that [Comments.UnmarshalJSON] -// reads from the file of an older rexdiff. Such a file has no anchors, and -// its comments are on the diff of the scope that the file saves. -func (cs *Comments) SetOldRev(rev string) { +// SetRev sets the Rev of each comment that [Comments.UnmarshalJSON] read +// from a file without anchors. +func (cs *Comments) SetRev(rev string) { for _, c := range cs.items { if c.noAnchor { c.Anchor.Rev, c.noAnchor = rev, false @@ -133,23 +149,24 @@ func (cs *Comments) Find(id int) *Comment { return nil } -// At returns the first comment with anchor a whose range includes line, or -// nil. -func (cs *Comments) At(a Anchor, path string, s Side, line int) *Comment { - for _, c := range cs.items { - if c.Anchor == a && c.Path == path && c.Side == s && - line >= c.Start && line <= c.End { - return c - } +// At returns the first comment with anchor a whose range includes line, +// or nil if there is none. +func (cs *Comments) At(a Anchor, path string, side Side, line int) *Comment { + i := slices.IndexFunc(cs.items, func(c *Comment) bool { + return c.Anchor == a && c.Path == path && c.Side == side && + line >= c.Start && line <= c.End + }) + if i < 0 { + return nil } - return nil + return cs.items[i] } // EndingAt returns the comments with anchor a whose range ends on line. -func (cs *Comments) EndingAt(a Anchor, path string, s Side, line int) []*Comment { +func (cs *Comments) EndingAt(a Anchor, path string, side Side, line int) []*Comment { var ending []*Comment for _, c := range cs.items { - if c.Anchor == a && c.Path == path && c.Side == s && c.End == line { + if c.Anchor == a && c.Path == path && c.Side == side && c.End == line { ending = append(ending, c) } } @@ -167,16 +184,16 @@ func (cs *Comments) Count(a Anchor, path string) int { return n } -// version returns before for the [SideOld] side, and after for the -// [SideNew] side. -func version(s Side, before, after string) string { - if s == SideOld { - return before +// versionText returns oldText for [SideOld] and newText for [SideNew]. +func versionText(side Side, oldText, newText string) string { + if side == SideOld { + return oldText } - return after + return newText } -// savedComment is a [Comment] with its private fields, for MarshalJSON. +// savedComment is the JSON form of a [Comment], including its unexported +// fields. type savedComment struct { ID int `json:"id"` Path string `json:"path"` @@ -184,24 +201,24 @@ type savedComment struct { Start int `json:"start"` End int `json:"end"` Text string `json:"text"` - // InDiff is nil in a file of an older rexdiff. Most comments of - // such a file are on a diff. + // InDiff is nil in files from older versions, and such a comment is + // read as a comment on a diff. InDiff *bool `json:"in_diff"` Rev string `json:"rev"` Code []string `json:"code"` Snippet []string `json:"snippet"` } -// savedComments is a [Comments] with its private fields. +// savedComments is the JSON form of [Comments], including its unexported +// fields. type savedComments struct { Last int `json:"last"` Items []savedComment `json:"items"` } -// MarshalJSON encodes the comments with their code and their diff rows, -// so that UnmarshalJSON restores the same comments, for example after a -// restart. Bytes that are not UTF-8, for example in a Latin-1 file, -// become U+FFFD. +// MarshalJSON encodes the comments with their code and diff rows, so that +// [Comments.UnmarshalJSON] restores the same comments. Invalid UTF-8 bytes +// are replaced with U+FFFD. func (cs *Comments) MarshalJSON() ([]byte, error) { saved := savedComments{Last: cs.last} for _, c := range cs.items { @@ -214,7 +231,7 @@ func (cs *Comments) MarshalJSON() ([]byte, error) { return json.Marshal(saved, jsontext.AllowInvalidUTF8(true)) } -// UnmarshalJSON reads comments that MarshalJSON wrote. +// UnmarshalJSON decodes comments encoded by [Comments.MarshalJSON]. func (cs *Comments) UnmarshalJSON(b []byte) error { var saved savedComments if err := json.Unmarshal(b, &saved); err != nil { diff --git a/diff/comment_test.go b/diff/comment_test.go index 73d3752..c38c313 100644 --- a/diff/comment_test.go +++ b/diff/comment_test.go @@ -14,32 +14,33 @@ import ( "github.com/rednafi/rexdiff/internal/testgit" ) -// comment returns a comment with text on all rows of the diff of before -// and after. -func comment(path string, s diff.Side, before, after, text string) diff.Comment { - rows := diff.Render(path, before, after, diff.Options{}).Rows - c := diff.NewComment(path, s, rows, before, after) +// comment returns a comment with the given text on all rows of the diff +// between oldText and newText. +func comment(path string, side diff.Side, oldText, newText, text string) diff.Comment { + rows := diff.Render(path, oldText, newText, diff.Options{}).Rows + c := diff.NewComment(path, side, rows, oldText, newText) c.Text = text return c } -// commentOn returns a comment on lines start to end of src, the file at -// path. The rows come from [diff.RenderFile]. -func commentOn(path string, s diff.Side, src string, start, end int) diff.Comment { - rows := diff.RenderFile(path, src, diff.Options{}).Rows - return diff.NewComment(path, s, rows[start-1:end], src, src) +// commentOn returns a comment on lines start to end of src, the content of +// the file at path, using rows from [diff.RenderFile]. +func commentOn(path string, side diff.Side, src string, start, end int) diff.Comment { + rows := diff.RenderFile(path, src, diff.FileOptions{}).Rows + return diff.NewComment(path, side, rows[start-1:end], src, src) } -// review returns the text of [diff.Comments.Review] for c only. +// review returns the output of [diff.Comments.Review] for a set that holds +// only c. func review(c diff.Comment) string { var cs diff.Comments cs.Add(c) - return cs.Review("") + return cs.Review("", nil) } -// TestReviewMatchesHerdrReviewr checks that Review writes the text format -// of herdr-reviewr. -func TestReviewMatchesHerdrReviewr(t *testing.T) { +// TestReview checks the text that Review writes: the prompt, then each +// comment's location, diff lines and text, sorted by path. +func TestReview(t *testing.T) { t.Parallel() var cs diff.Comments cs.Add(comment("c.go", diff.SideNew, @@ -54,7 +55,7 @@ func TestReviewMatchesHerdrReviewr(t *testing.T) { "merge these\n\n \nplease ", )) - got := cs.Review("Fix each comment.\r\n") + got := cs.Review("Fix each comment.\r\n", nil) want := "Fix each comment.\n\n" + "a.go:1-2\n+x := 1\n+y := 2\nmerge these\nplease\n\n" + "b.go:1 (removed)\n-gone()\nwhy?\n\n" + @@ -63,56 +64,58 @@ func TestReviewMatchesHerdrReviewr(t *testing.T) { if got != want { t.Errorf("Review = \n%s\nwant:\n%s", got, want) } - if got := new(diff.Comments).Review(""); got != "" { + if got := new(diff.Comments).Review("", nil); got != "" { t.Errorf("Review with no comments = %q, want \"\"", got) } } -// TestReviewContinuedRows checks a comment on only continued rows. +// TestReviewContinuedRows checks Review for a comment that covers only +// continued rows. func TestReviewContinuedRows(t *testing.T) { t.Parallel() - before, after := "", strings.Repeat("word ", 20)+"\n" - d := diff.Render("a.go", before, after, diff.Options{Width: 30, Wrap: true}) + oldSrc, newSrc := "", strings.Repeat("word ", 20)+"\n" + d := diff.Render("a.go", oldSrc, newSrc, diff.Options{Width: 30, Wrap: true}) var cont []diff.Row for _, r := range d.Rows { if r.Continued { cont = append(cont, r) } } - c := diff.NewComment("a.go", diff.SideNew, cont, before, after) + c := diff.NewComment("a.go", diff.SideNew, cont, oldSrc, newSrc) c.Text = "long" - // The comment has no diff lines, so Review writes the lines of its range. - want := "a.go:1\n+" + strings.TrimSuffix(after, "\n") + "\nlong" + // The comment has no diff lines, so Review writes the lines in its range. + want := "a.go:1\n+" + strings.TrimSuffix(newSrc, "\n") + "\nlong" if got := review(c); len(cont) == 0 || got != want { t.Errorf("%d continued rows, Review = \n%s\nwant:\n%s", len(cont), got, want) } } -// TestComments checks Add, Find, At, EndingAt, Count, and Remove. +// TestComments checks Add, Find, At, EndingAt, Count and Delete. func TestComments(t *testing.T) { t.Parallel() var cs diff.Comments - d := diff.Anchor{InDiff: true} - cs.Add(diff.Comment{Path: "a.go", Side: diff.SideNew, Start: 2, End: 4, Anchor: d}) - cs.Add(diff.Comment{Path: "a.go", Side: diff.SideOld, Start: 4, End: 4, Anchor: d}) - cs.Add(diff.Comment{Path: "b.go", Side: diff.SideNew, Start: 1, End: 1, Anchor: d}) + anchor := diff.Anchor{InDiff: true} + cs.Add(diff.Comment{Path: "a.go", Side: diff.SideNew, Start: 2, End: 4, Anchor: anchor}) + cs.Add(diff.Comment{Path: "a.go", Side: diff.SideOld, Start: 4, End: 4, Anchor: anchor}) + cs.Add(diff.Comment{Path: "b.go", Side: diff.SideNew, Start: 1, End: 1, Anchor: anchor}) a, b := cs.All()[0], cs.All()[1] - if cs.Len() != 3 || cs.Count(d, "a.go") != 2 || a.ID == b.ID { + if cs.Len() != 3 || cs.Count(anchor, "a.go") != 2 || a.ID == b.ID { t.Fatalf("Len = %d, Count(a.go) = %d, IDs %d %d; "+ "want 3, 2, two different IDs", - cs.Len(), cs.Count(d, "a.go"), a.ID, b.ID) + cs.Len(), cs.Count(anchor, "a.go"), a.ID, b.ID) } - if c := cs.At(d, "a.go", diff.SideNew, 3); c != a { + if c := cs.At(anchor, "a.go", diff.SideNew, 3); c != a { t.Errorf("At(a.go, New, 3) = %v, want %v", c, a) } - if c := cs.At(d, "a.go", diff.SideNew, 5); c != nil { + if c := cs.At(anchor, "a.go", diff.SideNew, 5); c != nil { t.Errorf("At(a.go, New, 5) = %v, want nil", c) } - if got := cs.EndingAt(d, "a.go", diff.SideOld, 4); len(got) != 1 || got[0] != b { + if got := cs.EndingAt(anchor, "a.go", diff.SideOld, 4); len(got) != 1 || got[0] != b { t.Errorf("EndingAt(a.go, Old, 4) = %v, want [%v]", got, b) } - // A comment shows only in its kind of view, and at its revision. + // A comment shows only in the same kind of view and at the same + // revision. for _, other := range []diff.Anchor{{}, {InDiff: true, Rev: "a..b"}} { if c := cs.At(other, "a.go", diff.SideNew, 3); c != nil { t.Errorf("At(%+v, a.go, New, 3) = %v, want nil", other, c) @@ -127,7 +130,7 @@ func TestComments(t *testing.T) { if cs.Find(b.ID) != b { t.Errorf("Find(%d) = %v, want %v", b.ID, cs.Find(b.ID), b) } - cs.Remove(b.ID) + cs.Delete(b.ID) if cs.Find(b.ID) != nil || cs.Len() != 2 { t.Errorf("after Remove(%d): Find = %v, Len = %d, want nil, 2", b.ID, cs.Find(b.ID), cs.Len()) @@ -139,73 +142,73 @@ func TestComments(t *testing.T) { } } -// TestNewComment checks comments from the rows of a change, in the two -// layouts, on each side. +// TestNewComment checks the line range and the Review text of comments +// created from the rows of a change, in both layouts and on both sides. func TestNewComment(t *testing.T) { t.Parallel() - before := "a\nb\nc\nd\n" - after := "a\nB\nC\nd\n" + oldSrc := "a\nb\nc\nd\n" + newSrc := "a\nB\nC\nd\n" for _, split := range []bool{false, true} { - rows := diff.Render("x.txt", before, after, diff.Options{Split: split}).Rows - // The rows from "a" to "d". + rows := diff.Render("x.txt", oldSrc, newSrc, diff.Options{Split: split}).Rows + // The rows from "a" to "d", after the hunk header. lo := slices.IndexFunc(rows, isEqual) - for _, s := range []diff.Side{diff.SideNew, diff.SideOld} { - c := diff.NewComment("x.txt", s, rows[lo:], before, after) - if c.Path != "x.txt" || c.Side != s || c.Start != 1 || c.End != 4 { - t.Errorf("split %v, side %d: %+v, want lines 1-4", split, s, c) + for _, side := range []diff.Side{diff.SideNew, diff.SideOld} { + c := diff.NewComment("x.txt", side, rows[lo:], oldSrc, newSrc) + if c.Path != "x.txt" || c.Side != side || c.Start != 1 || c.End != 4 { + t.Errorf("split %v, side %d: %+v, want lines 1-4", split, side, c) } want := "x.txt:1-4" - if s == diff.SideOld { + if side == diff.SideOld { want += " (removed)" } want += "\n a\n-b\n-c\n+B\n+C\n d\n" if got := review(c); got != want { t.Errorf("split %v, side %d: Review = \n%s\nwant:\n%s", - split, s, got, want) + split, side, got, want) } } - // A comment on only the inserted lines has its range on the new side. + // A comment on only the added lines gets its range from the new side. var ins []diff.Row for _, r := range rows { - if r.Kind == diff.KindInsert || r.Kind == diff.KindChange { + if r.Kind == diff.KindAdded || r.Kind == diff.KindChanged { ins = append(ins, r) } } - got := diff.NewComment("x.txt", diff.SideNew, ins, before, after) + got := diff.NewComment("x.txt", diff.SideNew, ins, oldSrc, newSrc) if got.Start != 2 || got.End != 3 { t.Errorf("split %v, the new lines: %+v, want lines 2-3", split, got) } } - // A hunk header has no line. - rows := diff.Render("x.txt", before, after, diff.Options{}).Rows + // A comment on only a hunk header has no lines. + rows := diff.Render("x.txt", oldSrc, newSrc, diff.Options{}).Rows hunk := rows[:slices.IndexFunc(rows, isEqual)] - got := diff.NewComment("x.txt", diff.SideNew, hunk, before, after) + got := diff.NewComment("x.txt", diff.SideNew, hunk, oldSrc, newSrc) if len(hunk) == 0 || got.Start != 0 || got.End != 0 { t.Errorf("a hunk header %v: %+v, want no lines", hunk, got) } } -// TestSnippetIsTheRealDiff checks that the snippet matches git diff, with raw -// text and tabs, in both layouts. +// TestSnippetIsTheRealDiff checks that a comment's diff lines match the +// output of git diff, including tabs, in both layouts. func TestSnippetIsTheRealDiff(t *testing.T) { t.Parallel() - before := "func a() {\n\treturn 1\n}\n" - after := "func a() {\n\t// two\n\treturn 2\n}\n" + oldSrc := "func a() {\n\treturn 1\n}\n" + newSrc := "func a() {\n\t// two\n\treturn 2\n}\n" want := " func a() {\n-\treturn 1\n+\t// two\n+\treturn 2\n }" for _, split := range []bool{false, true} { - d := diff.Render("x.go", before, after, diff.Options{Split: split}) - got := strings.Join(snippet(d.Rows, before, after), "\n") + d := diff.Render("x.go", oldSrc, newSrc, diff.Options{Split: split}) + got := strings.Join(snippet(d.Rows, oldSrc, newSrc), "\n") if got != want { t.Errorf("split %v: snippet = \n%s\nwant:\n%s", split, got, want) } } dir := t.TempDir() - for name, src := range map[string]string{"old": before, "new": after} { + for name, src := range map[string]string{"old": oldSrc, "new": newSrc} { testgit.WriteFile(t, dir, name, src) } - // git diff exits with 1 when the files differ. - // No diff setting of the user applies. + // git diff exits with status 1 when the files differ. The user's git + // diff settings do not apply. git := proc.Command(t.Context(), testgit.ToolEnv(t), "git", "diff", "--no-index", "-U1", filepath.Join(dir, "old"), @@ -221,9 +224,53 @@ func TestSnippetIsTheRealDiff(t *testing.T) { } } -// TestCommentsJSON checks that the JSON keeps the anchor of a comment. It -// also checks a file of an older rexdiff, which has a stale field and no -// anchor. +// TestReviewStale checks that Review marks the comments that stale +// reports after their location, and leaves the others as they are. +func TestReviewStale(t *testing.T) { + t.Parallel() + var cs diff.Comments + cs.Add(comment("a.go", diff.SideNew, "", "x := 1\n", "fresh")) + cs.Add(comment("b.go", diff.SideOld, "gone()\n", "", "moved")) + got := cs.Review("", func(c *diff.Comment) bool { return c.Path == "b.go" }) + want := "a.go:1\n+x := 1\nfresh\n\n" + + "b.go:1 (removed) (stale)\n-gone()\nmoved" + if got != want { + t.Errorf("Review = \n%s\nwant:\n%s", got, want) + } +} + +// TestCommentMoved checks that Moved reports a comment whose lines hold +// other code or are past the end of the file, and not one whose code is +// still on its lines. +func TestCommentMoved(t *testing.T) { + t.Parallel() + src := "a\nb\nc\nd\n" + c := commentOn("x.txt", diff.SideNew, src, 2, 3) // b and c + tests := []struct { + text string + want bool + }{ + {src, false}, + {"a\r\nb\r\nc\r\n", false}, // CRLF line endings, d removed + {"a\nb\nc", false}, // no newline at the end + {"new\na\nb\nc\nd\n", true}, // a line added above + {"a\nb\nC\nd\n", true}, + {"a\nb\n", true}, // c removed: line 3 is past the end + {"", true}, + } + for _, tt := range tests { + if got := c.Moved(tt.text); got != tt.want { + t.Errorf("Moved(%q) = %t, want %t", tt.text, got, tt.want) + } + } + if (&diff.Comment{Start: 1, End: 1}).Moved("x\n") { + t.Error("Moved of a comment without code = true, want false") + } +} + +// TestCommentsJSON checks that a JSON round trip keeps each comment's +// anchor. It also checks reading a file from an older rexdiff, which has a +// stale field and no anchor, and the effect of SetRev. func TestCommentsJSON(t *testing.T) { t.Parallel() var cs diff.Comments @@ -258,21 +305,21 @@ func TestCommentsJSON(t *testing.T) { if c.Anchor != (diff.Anchor{InDiff: true}) || c.Start != 2 { t.Errorf("comment of an old file = %+v, want lines 2-3 on a diff", c) } - back.SetOldRev("x..y") - cs2.SetOldRev("a..b") + back.SetRev("x..y") + cs2.SetRev("a..b") for i, c := range back.All() { if c.Anchor != anchors[i] { - t.Errorf("comment %d after SetOldRev: anchor %+v, want %+v", i, c.Anchor, anchors[i]) + t.Errorf("comment %d after SetRev: anchor %+v, want %+v", i, c.Anchor, anchors[i]) } } if want := (diff.Anchor{InDiff: true, Rev: "a..b"}); c.Anchor != want { - t.Errorf("comment of an old file after SetOldRev: anchor %+v, want %+v", c.Anchor, want) + t.Errorf("comment of an old file after SetRev: anchor %+v, want %+v", c.Anchor, want) } } -// TestCommentsJSONLatin1 checks that a comment on a file that is not UTF-8, -// for example a Latin-1 file, survives a JSON round trip. The bytes that -// are not UTF-8 become U+FFFD. +// TestCommentsJSONLatin1 checks that a comment on a file that is not +// UTF-8, such as a Latin-1 file, survives a JSON round trip, with invalid +// bytes replaced by U+FFFD. func TestCommentsJSONLatin1(t *testing.T) { t.Parallel() const path = "caf\xe9.txt" diff --git a/diff/diff.go b/diff/diff.go index 337e2a4..d72a831 100644 --- a/diff/diff.go +++ b/diff/diff.go @@ -1,128 +1,138 @@ -// Package diff renders the changes to a file as terminal lines, and keeps -// review comments on the lines. +// Package diff renders the changes to a file as lines for a terminal, and +// stores review comments on those lines. // -// [Render] and [RenderFile] return a [Diff]. Each line in [Diff.Lines] has a -// [Row] that tells which source lines it shows. Use the rows to put a cursor -// or a [Comment] on a source line. The colours come from the terminal -// palette. +// [Render] and [RenderFile] return a [Diff]. Each of its lines has a [Row] +// that records which source lines it shows. The colours come from the +// terminal's palette. package diff import ( + "slices" "strings" - "github.com/aymanbagabas/go-udiff" - "github.com/rednafi/rexdiff/internal/diffview" ) -// Kind is the type of a rendered row. +// Kind is the kind of a rendered row. type Kind int -// These are the kinds of a row. +// The kinds of row. const ( - KindHunk Kind = iota // a hunk header, with no source line - KindEqual // a line that is the same on the two sides - KindInsert // a line that only the new side has - KindDelete // a line that only the old side has - KindChange // a deleted line beside an inserted line (split) + KindHunk Kind = iota // a hunk header, with no source line + KindEqual // a line that is the same on both sides + KindAdded // a line that exists only on the new side + KindDeleted // a line that exists only on the old side + KindChanged // a deleted line next to an added line, in split view ) // Side identifies one version of a file. type Side int -// These are the two sides of a file. +// The two sides of a file. const ( SideOld Side = iota // the version before the change SideNew // the version after the change ) -// Row tells which source lines one rendered line shows. Old and New are -// line numbers that start at 1. A value of 0 means that the row has no line -// on that side. Continued is true if the line continues the source lines of -// the line above, because [Options.Wrap] wraps them. -// -// A long run of equal lines folds into the hunk header below it. For a -// hunk header, Fold is that run. +// Row describes the source lines that one rendered line shows. type Row struct { - Kind Kind - Old, New int + Kind Kind + // Old and New are 1-based line numbers, or 0 if the row has no line on + // that side. + Old, New int + // Continued is true if the row continues the line above it because of + // [Options.Wrap]. Continued bool - Fold Fold -} - -// Fold is a run of equal lines that the diff hides. Old and New are the -// line numbers of its first line. Lines is the number of lines. Lines is 0 -// if the diff hides no line. -type Fold struct { - Old, New, Lines int + // Fold is the run of hidden equal lines above a hunk header. + Fold Fold } -// Line returns the line number of the row on side s. -func (r Row) Line(s Side) int { - if s == SideOld { +// Line returns the row's line number on side. +func (r Row) Line(side Side) int { + if side == SideOld { return r.Old } return r.New } -// Options control how [Render] and [RenderFile] draw their output. +// Fold is a run of equal lines that the diff hides. Old and New are the +// line numbers of its first line, and Lines is the number of lines. Lines +// is 0 if no lines are hidden. +type Fold struct { + Old, New, Lines int +} + +// Options controls how [Render] draws a diff. type Options struct { // Split shows the old lines on the left and the new lines on the right. - // If it is false, the old and the new lines are in one column. + // If it is false, the old and new lines share one column. Split bool // Width is the maximum width of a line, in cells. Zero means no limit. Width int - // Offset is the number of cells to scroll the code to the right. - Offset int - // Wrap continues a line that is wider than Width on the next lines, - // instead of cutting it. Then Offset does not apply. + // ScrollX is the number of cells to scroll the code to the right. + ScrollX int + // Wrap continues a line that is wider than Width on the following + // lines, instead of cutting it off. ScrollX is ignored when Wrap is set. Wrap bool - // Expanded reports whether the lines of a fold show. If Expanded is - // nil, no fold shows. RenderFile does not use it. + // Expanded reports whether the hidden lines of a fold are shown. If + // Expanded is nil, all folds stay hidden. Expanded func(Fold) bool - // Signs marks a changed line with + or -. Without Signs, a bar in the - // colour of the line marks it. + // Signs marks each changed line with + or -. Without Signs, a coloured + // bar marks it. Signs bool // Theme sets the colours. Theme Theme - // Cache makes a new render of the same lines fast. If Cache is nil, the - // render keeps nothing. + // Cache stores results for reuse by later renders. If Cache is nil, + // nothing is reused. Cache *Cache } -// Cache keeps the syntax colours of the lines between renders, for example -// for a sideways scroll. The zero value is an empty Cache. A Cache is not -// safe for concurrent use. +// Cache stores highlighted lines, the computed diff and the rows between +// renders. The zero value is an empty Cache. A Cache is not safe for +// concurrent use. type Cache struct { - lines diffview.Cache // the lines of Render - file struct{ path, src, out string } // the last file of RenderFile + lines diffview.Cache // highlighted lines and the last diff, for Render + rows struct { // the last rows that Render converted + from []diffview.Row + to []Row + } + file struct { // the last file passed to RenderFile + path, src string + raw string // the original content, before shownFile converted it to src + h *diffview.Highlighter + } } -// Diff is the rendered output. Lines[i] is the screen line for Rows[i]. -// Lines is empty when there is nothing to show, for example when the two -// versions are the same. +// Diff is the rendered output. [Diff.Line] returns the screen line for +// Rows[i], and renders it the first time it is requested. There are no +// lines if the two versions are the same. Do not modify Rows, because +// later renders with the same Cache may share them. A Diff uses its Cache +// and is not safe for concurrent use. type Diff struct { - Lines []string Rows []Row + lines *diffview.Lines split bool oldNum int // width of the old line-number column newNum int // width of the new line-number column newNumAt int // column where the new line numbers start - // The fields below are for [Diff.Cell]. - src [2]string // the content of each side + // The fields below are used by [Diff.Cell]. + texts [2]string // the content of each side codeAt [2]int // column where the code of each side starts - offset int // the sideways scroll, or 0 with wrap - wrap bool + // codeWidth is the width of the code of each side, or 0 if the lines + // have no width limit. + codeWidth [2]int + scrollX int // the horizontal scroll, or 0 when wrapping + wrap bool } -// Render compares before, the old content of the file at path, with after, -// its new content. The path selects the syntax highlighter. -func Render(path, before, after string, o Options) *Diff { - dv := diffview.New(path, before, after). +// Render returns the diff between oldText and newText, the old and new +// content of the file at path. The path selects the syntax highlighting. +func Render(path, oldText, newText string, o Options) *Diff { + dv := diffview.New(path, oldText, newText). Width(o.Width). - XOffset(o.Offset). + ScrollX(o.ScrollX). Split(o.Split). Signs(o.Signs). Wrap(o.Wrap). @@ -132,54 +142,32 @@ func Render(path, before, after string, o Options) *Diff { dv = dv.Cache(&o.Cache.lines) } if o.Expanded != nil { - dv = dv.Expand(func(f diffview.Fold) bool { return o.Expanded(foldOf(f)) }) + dv = dv.Expanded(func(f diffview.Fold) bool { return o.Expanded(Fold(f)) }) } - out, rows, g := dv.Render() - - d := &Diff{split: o.Split, src: [2]string{before, after}, wrap: o.Wrap} + ls, rows, g := dv.RenderLines() + d := &Diff{lines: ls, split: o.Split, texts: [2]string{oldText, newText}, wrap: o.Wrap} if !o.Wrap { - d.offset = o.Offset + d.scrollX = o.ScrollX } - for _, r := range rows { - d.Rows = append(d.Rows, Row{ - Kind: kindOf(r.Kind), - Old: r.Before, - New: r.After, - Continued: r.Continued, - Fold: foldOf(r.Fold), - }) - } - if len(d.Rows) > 0 { - d.Lines = strings.Split(out, "\n") + d.Rows = o.Cache.convert(rows) + d.oldNum, d.newNum, d.newNumAt = g.OldNum, g.NewNum, g.NewNumStart + d.codeAt = [2]int{g.OldCode, g.NewCode} + if o.Width > 0 { + d.codeWidth = [2]int{o.Width - g.OldCode, o.Width - g.NewCode} + if o.Split { + d.codeWidth[SideOld] = g.NewNumStart - g.OldCode + } } - d.oldNum, d.newNum, d.newNumAt = g.BeforeNum, g.AfterNum, g.AfterNumStart - d.codeAt = [2]int{g.BeforeCode, g.AfterCode} return d } -// foldOf returns the Fold of the diffview fold f. -func foldOf(f diffview.Fold) Fold { - return Fold{Old: f.Before, New: f.After, Lines: f.Lines} -} - -// kindOf returns the Kind of the diffview row kind k. -func kindOf(k diffview.RowKind) Kind { - switch k { - case diffview.RowHunk: - return KindHunk - case diffview.RowInsert: - return KindInsert - case diffview.RowDelete: - return KindDelete - case diffview.RowChange: - return KindChange - } - return KindEqual // diffview.RowEqual -} +// Line returns screen line i, which belongs to Rows[i]. +func (d *Diff) Line(i int) string { return d.lines.Line(i) } -// SideAt tells which side column x of a line belongs to. Column 0 is the -// first column of the line. If onNumber is true, x is on a line number. -func (d *Diff) SideAt(x int) (s Side, onNumber bool) { +// SideAt reports which side column x of a screen line belongs to, where +// column 0 is the first column. onGutter is true if x is in a line-number +// column. +func (d *Diff) SideAt(x int) (side Side, onGutter bool) { if d.split { switch { case x < d.oldNum: @@ -197,33 +185,21 @@ func (d *Diff) SideAt(x int) (s Side, onNumber bool) { return SideNew, x < d.oldNum+d.newNum } -// Count returns the number of lines that a change from before to after adds -// and deletes. As in herdr-reviewr, a change from a CRLF to an LF line -// ending, or back, is a change of the line. -func Count(before, after string) (added, deleted int) { - u, err := udiff.ToUnifiedDiff("", "", before, udiff.Lines(before, after), 0) - if err != nil { - return 0, 0 - } - for _, h := range u.Hunks { - for _, l := range h.Lines { - switch l.Kind { - case udiff.Insert: - added++ - case udiff.Delete: - deleted++ - } - } - } - return added, deleted +// CountLines returns the number of lines added and deleted by the change +// from oldText to newText. A line whose ending changes between CRLF and LF +// counts as changed. +func CountLines(oldText, newText string) (added, deleted int) { + return diffview.CountLines(oldText, newText) } -// Lines splits s into lines, without the LF or CRLF line endings. Line n of -// s is Lines(s)[n-1]. The lines of a [Comment] count in this way. -func Lines(s string) []string { - var lines []string - for l := range strings.Lines(s) { - lines = append(lines, trimEOL(l)) +// SplitLines splits s into lines and removes the LF or CRLF line endings. +// Line n of s is SplitLines(s)[n-1]. [Comment] line numbers use the same +// counting. Unlike [strings.Split], it returns no empty line after a final +// line ending. +func SplitLines(s string) []string { + lines := slices.Collect(strings.Lines(s)) + for i, l := range lines { + lines[i] = trimEOL(l) } return lines } @@ -233,8 +209,28 @@ func trimEOL(line string) string { return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") } -// countLines returns the number of lines of s, as [Lines] counts them. A -// last line without a newline counts as a line. +// convert returns rows as the rows of a [Diff], or nil if there are none. +// c keeps the result, because the next render often has the same rows. A +// nil c keeps nothing. +func (c *Cache) convert(rows []diffview.Row) []Row { + if len(rows) == 0 { + return nil + } + if c != nil && len(c.rows.from) == len(rows) && &c.rows.from[0] == &rows[0] { + return c.rows.to + } + out := make([]Row, len(rows)) + for i, r := range rows { + out[i] = Row{Kind: Kind(r.Kind), Old: r.Old, New: r.New, Continued: r.Continued, Fold: Fold(r.Fold)} + } + if c != nil { + c.rows.from, c.rows.to = rows, out + } + return out +} + +// countLines returns the number of lines in s, counted as [SplitLines] counts +// them. A final line without a newline counts as a line. func countLines(s string) int { n := strings.Count(s, "\n") if s != "" && !strings.HasSuffix(s, "\n") { diff --git a/diff/diff_test.go b/diff/diff_test.go index 3d5a2bc..b4f69c8 100644 --- a/diff/diff_test.go +++ b/diff/diff_test.go @@ -13,23 +13,32 @@ import ( ) const ( - oldSrc = "a\nb\nc\n" - newSrc = "a\nB\nc\nd\n" + oldText = "a\nb\nc\n" + newText = "a\nB\nc\nd\n" ) -// snippet returns the diff lines of a comment on rows, as -// [diff.Comments.Review] writes them. -func snippet(rows []diff.Row, before, after string) []string { +// screenLines returns every screen line of d, first to last. +func screenLines(d *diff.Diff) []string { + var lines []string + for i := range len(d.Rows) { + lines = append(lines, d.Line(i)) + } + return lines +} + +// snippet returns the diff lines that [diff.Comments.Review] writes for a +// comment on rows. +func snippet(rows []diff.Row, oldText, newText string) []string { var cs diff.Comments - cs.Add(diff.NewComment("x", diff.SideNew, rows, before, after)) - lines := strings.Split(cs.Review(""), "\n") - return lines[1 : len(lines)-1] // not the location and the empty text + cs.Add(diff.NewComment("x", diff.SideNew, rows, oldText, newText)) + lines := strings.Split(cs.Review("", nil), "\n") + return lines[1 : len(lines)-1] // drop the location and the empty comment text } // isEqual reports whether r is a [diff.KindEqual] row. func isEqual(r diff.Row) bool { return r.Kind == diff.KindEqual } -// kinds writes the rows as one character each, for example "@=-+=+". +// kinds returns one character for each row's kind, for example "@=-+=+". func kinds(rows []diff.Row) string { var b strings.Builder for _, r := range rows { @@ -38,30 +47,31 @@ func kinds(rows []diff.Row) string { return b.String() } -// renderer is a way to show two versions of a file. +// renderer is a named way to render two versions of a file. type renderer struct { name string render func(o diff.Options) *diff.Diff } -// renderers returns the unified and the split diff of before and after, -// and the file after. -func renderers(before, after string) []renderer { +// renderers returns renderers for the unified diff and the split diff of +// oldText and newText, and for the file view of newText. +func renderers(oldText, newText string) []renderer { return []renderer{ {"unified", func(o diff.Options) *diff.Diff { - return diff.Render("x.go", before, after, o) + return diff.Render("x.go", oldText, newText, o) }}, {"split", func(o diff.Options) *diff.Diff { o.Split = true - return diff.Render("x.go", before, after, o) + return diff.Render("x.go", oldText, newText, o) }}, {"file", func(o diff.Options) *diff.Diff { - return diff.RenderFile("x.go", after, o) + return diff.RenderFile("x.go", newText, fileOptions(o)) }}, } } -// TestRenderRowsFollowLines checks the row of each line, in each layout and +// TestRenderRowsFollowLines checks the row of each line, that there is one +// line for each row and that lines fit the width, in each layout and // theme. func TestRenderRowsFollowLines(t *testing.T) { t.Parallel() @@ -82,10 +92,10 @@ func TestRenderRowsFollowLines(t *testing.T) { rows: []diff.Row{ {Kind: diff.KindHunk}, {Kind: diff.KindEqual, Old: 1, New: 1}, - {Kind: diff.KindDelete, Old: 2}, - {Kind: diff.KindInsert, New: 2}, + {Kind: diff.KindDeleted, Old: 2}, + {Kind: diff.KindAdded, New: 2}, {Kind: diff.KindEqual, Old: 3, New: 3}, - {Kind: diff.KindInsert, New: 4}, + {Kind: diff.KindAdded, New: 4}, }, }, { @@ -94,19 +104,19 @@ func TestRenderRowsFollowLines(t *testing.T) { rows: []diff.Row{ {Kind: diff.KindHunk}, {Kind: diff.KindEqual, Old: 1, New: 1}, - {Kind: diff.KindChange, Old: 2, New: 2}, + {Kind: diff.KindChanged, Old: 2, New: 2}, {Kind: diff.KindEqual, Old: 3, New: 3}, - {Kind: diff.KindInsert, New: 4}, + {Kind: diff.KindAdded, New: 4}, }, }, } - for ti, th := range themes { + for ti, theme := range themes { for _, tt := range tests { name := fmt.Sprintf("split %v, theme %d", tt.split, ti) t.Run(name, func(t *testing.T) { t.Parallel() - o := diff.Options{Split: tt.split, Width: width, Theme: th} - d := diff.Render("x.go", oldSrc, newSrc, o) + o := diff.Options{Split: tt.split, Width: width, Theme: theme} + d := diff.Render("x.go", oldText, newText, o) if got := kinds(d.Rows); got != tt.kinds { t.Errorf("kinds = %q, want %q", got, tt.kinds) } @@ -115,11 +125,11 @@ func TestRenderRowsFollowLines(t *testing.T) { t.Errorf("row %d = %+v, want %+v", i, d.Rows[i], r) } } - if len(d.Lines) != len(d.Rows) { + if len(screenLines(d)) != len(d.Rows) { t.Errorf("%d lines for %d rows, want one line for each row", - len(d.Lines), len(d.Rows)) + len(screenLines(d)), len(d.Rows)) } - for i, l := range d.Lines { + for i, l := range screenLines(d) { if w := ansi.StringWidth(l); w > width { t.Errorf("line %d is %d cells wide, want %d or fewer", i, w, width) @@ -130,31 +140,31 @@ func TestRenderRowsFollowLines(t *testing.T) { } } -// TestRenderKinds checks the kinds of the rows of a diff. Equal versions -// give no rows. A change of a tab to spaces is a change, as Count and git -// tell. +// TestRenderKinds checks the row kinds of a diff. Equal versions produce +// no rows. Replacing a tab with spaces counts as a change, as it does in +// CountLines and git. func TestRenderKinds(t *testing.T) { t.Parallel() tests := []struct{ old, new, want string }{ - {oldSrc, oldSrc, ""}, + {oldText, oldText, ""}, {"\tx\n", " x\n", "@-+"}, } for _, tt := range tests { d := diff.Render("x.go", tt.old, tt.new, diff.Options{Width: 80}) - if got := kinds(d.Rows); got != tt.want || len(d.Lines) != len(d.Rows) { + if got := kinds(d.Rows); got != tt.want || len(screenLines(d)) != len(d.Rows) { t.Errorf("Render(%q, %q): kinds %q, %d lines, want %q, one line each", - tt.old, tt.new, got, len(d.Lines), tt.want) + tt.old, tt.new, got, len(screenLines(d)), tt.want) } } } -// TestRenderLineEnding checks that a change of only the line ending shows, -// as in herdr-reviewr. The line with the CR has a ^M. A CRLF that does not -// change shows nothing. +// TestRenderLineEnding checks that a change to only the line ending is +// shown, with ^M at the end of the CRLF line. An unchanged CRLF line is +// shown as equal, without ^M. func TestRenderLineEnding(t *testing.T) { t.Parallel() d := diff.Render("x.txt", "a\nb\r\nc\r\n", "a\r\nb\nc\r\n", diff.Options{Width: 80}) - var got []string // the kind and the last word of each row + var got []string // the kind and last word of each row for i, r := range d.Rows { words := strings.Fields(plainLine(t, d, i)) got = append(got, fmt.Sprintf("%c %s", "@=+-~"[r.Kind], words[len(words)-1])) @@ -165,29 +175,29 @@ func TestRenderLineEnding(t *testing.T) { } } -// TestRenderTabsAndOffset checks that a tab becomes spaces and that Offset -// scrolls the code. +// TestRenderTabsAndOffset checks that a tab is shown as spaces and that +// ScrollX scrolls the code. func TestRenderTabsAndOffset(t *testing.T) { t.Parallel() d := diff.Render("x.go", "", "\tx := 1\n", diff.Options{Width: 80}) - if got := plainLine(t, d, 1); !strings.Contains(got, "▌ x := 1") { - t.Errorf("tab: line = %q, want %q in it", got, "▌ x := 1") + if got := plainLine(t, d, 1); !strings.Contains(got, "▎ x := 1") { + t.Errorf("tab: line = %q, want %q in it", got, "▎ x := 1") } - d = diff.Render("x.go", "", "\tx := 1\n", diff.Options{Width: 80, Offset: 6}) + d = diff.Render("x.go", "", "\tx := 1\n", diff.Options{Width: 80, ScrollX: 6}) got := plainLine(t, d, 1) if strings.Contains(got, "x :=") || !strings.Contains(got, "= 1") { t.Errorf("offset 6: line = %q, want \"= 1\" and no \"x :=\"", got) } } -// TestMarks checks the mark at the start of the code of each line, in the two -// layouts. +// TestMarks checks the bar or sign at the start of the code on each line, +// in both layouts. func TestMarks(t *testing.T) { t.Parallel() - const before, after = "a\nb\nc\n", "a\nB\nc\n" - // By default a bar marks a changed line, as in herdr-reviewr. Signs gives - // + and -. The mark takes two cells in both cases, so the columns do not - // move. + const oldSrc, newSrc = "a\nb\nc\n", "a\nB\nc\n" + // By default a bar marks a changed line, and Signs uses + and - + // instead. The mark is two cells wide in both cases, so the columns + // stay in place. theme := diff.Theme{ Background: color.Black, Green: color.RGBA{G: 0xff, A: 0xff}, @@ -196,17 +206,17 @@ func TestMarks(t *testing.T) { tests := []struct { name string o diff.Options - lines []string // lines under the hunk header, no colours + lines []string // lines below the hunk header, without colours }{ {"unified bar", diff.Options{Width: 40}, []string{ - " 1 1 a", " 2 ▌ b", " 2 ▌ B", " 3 3 c", + " 1 1 a", " 2 ▎ b", " 2 ▎ B", " 3 3 c", }}, {"unified signs", diff.Options{Width: 40, Signs: true}, []string{ " 1 1 a", " 2 - b", " 2 + B", " 3 3 c", }}, {"split bar", diff.Options{Width: 40, Split: true}, []string{ " 1 a 1 a", - " 2 ▌ b 2 ▌ B", + " 2 ▎ b 2 ▎ B", " 3 c 3 c", }}, {"split signs", diff.Options{Width: 40, Split: true, Signs: true}, []string{ @@ -219,47 +229,47 @@ func TestMarks(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() tt.o.Theme = theme - d := diff.Render("x.txt", before, after, tt.o) - if len(d.Lines) != 1+len(tt.lines) { - t.Fatalf("%d lines, want %d", len(d.Lines), 1+len(tt.lines)) + d := diff.Render("x.txt", oldSrc, newSrc, tt.o) + if len(screenLines(d)) != 1+len(tt.lines) { + t.Fatalf("%d lines, want %d", len(screenLines(d)), 1+len(tt.lines)) } for i, want := range tt.lines { - got := strings.TrimRight(ansi.Strip(d.Lines[i+1]), " ") + got := strings.TrimRight(ansi.Strip(screenLines(d)[i+1]), " ") if got != want { t.Errorf("line %d = %q, want %q", i+1, got, want) } } - // The marks have the colour of their lines: ANSI red for the - // delete, then ANSI green for the insert. - got := markColours(d.Lines[1:]) + // Each mark has the colour of its line: ANSI red for the deleted + // line, then ANSI green for the added line. + got := markColors(screenLines(d)[1:]) if !slices.Equal(got, []string{"31", "32"}) { t.Errorf("the marks have colours %q, want 31 and 32", got) } }) } - // With a scroll to the right, an ellipsis shows after the bar. - d := diff.Render("x.txt", "", "abcdefgh\n", diff.Options{Width: 40, Offset: 2}) - if got := plainLine(t, d, 1); !strings.Contains(got, "▌…cdefgh") { - t.Errorf("offset: line = %q, want ▌…cdefgh", got) + // When the code is scrolled to the right, an ellipsis follows the bar. + d := diff.Render("x.txt", "", "abcdefgh\n", diff.Options{Width: 40, ScrollX: 2}) + if got := plainLine(t, d, 1); !strings.Contains(got, "▎…cdefgh") { + t.Errorf("offset: line = %q, want ▎…cdefgh", got) } - // The review text of a comment uses + and - for both kinds of mark. + // A comment's review text uses + and - with either kind of mark. for _, signs := range []bool{false, true} { - d := diff.Render("x.txt", before, after, diff.Options{Width: 40, Signs: signs}) - got := snippet(d.Rows[2:5], before, after) + d := diff.Render("x.txt", oldSrc, newSrc, diff.Options{Width: 40, Signs: signs}) + got := snippet(d.Rows[2:5], oldSrc, newSrc) if want := []string{"-b", "+B", " c"}; !slices.Equal(got, want) { t.Errorf("signs %v: snippet = %q, want %q", signs, got, want) } } } -// markColours returns the foreground of each bar, + or - in lines, in -// order, for example "31". The foreground is in the SGR sequence in front -// of the mark. -func markColours(lines []string) []string { +// markColors returns the foreground colour code, such as "31", of each +// bar, + or - in lines, in order. It reads the code from the SGR sequence +// before the mark. +func markColors(lines []string) []string { var out []string for _, l := range lines { for { - i := strings.IndexAny(l, "▌+-") + i := strings.IndexAny(l, "▎+-") if i < 0 { break } @@ -272,18 +282,18 @@ func markColours(lines []string) []string { return out } -// TestSideAt checks the side of each column, and if the column is on a line -// number. +// TestSideAt checks the side of various columns, and whether each column +// is on a line number. func TestSideAt(t *testing.T) { t.Parallel() - // The unified layout has " 1 " old numbers, " 1 " new numbers, and the - // code. - u := diff.Render("x.go", oldSrc, newSrc, diff.Options{Width: 80}) - // The split layout has old numbers, old code, new numbers, and new code. + // The unified layout has old line numbers like " 1 ", then new line + // numbers, then the code. + u := diff.Render("x.go", oldText, newText, diff.Options{Width: 80}) + // The split layout has old numbers, old code, new numbers and new code. split := diff.Options{Split: true, Width: 80} - s := diff.Render("x.go", oldSrc, newSrc, split) - // A file has line numbers, and then the code. - f := diff.RenderFile("x.go", oldSrc, diff.Options{Width: 80}) + s := diff.Render("x.go", oldText, newText, split) + // The file view has line numbers, then the code. + f := diff.RenderFile("x.go", oldText, diff.FileOptions{Width: 80}) tests := []struct { name string d *diff.Diff @@ -313,67 +323,127 @@ func TestSideAt(t *testing.T) { } } -// TestCount checks the numbers of added and deleted lines. -func TestCount(t *testing.T) { +// TestCountLines checks the counts of added and deleted lines. +func TestCountLines(t *testing.T) { t.Parallel() tests := []struct { old, new string added, deleted int }{ - {oldSrc, newSrc, 2, 1}, + {oldText, newText, 2, 1}, {"", "a\nb\n", 2, 0}, {"a\r\nb\r\n", "a\nb\n", 2, 2}, - {oldSrc, oldSrc, 0, 0}, + {oldText, oldText, 0, 0}, } for _, tt := range tests { - a, d := diff.Count(tt.old, tt.new) + a, d := diff.CountLines(tt.old, tt.new) if a != tt.added || d != tt.deleted { - t.Errorf("Count(%q, %q) = %d, %d, want %d, %d", + t.Errorf("CountLines(%q, %q) = %d, %d, want %d, %d", tt.old, tt.new, a, d, tt.added, tt.deleted) } } } -// TestRenderFile checks the line numbers and the rows of a file. +// TestManySpreadEdits checks that a file with many changes spread through +// it gets a diff of just those changes, as git finds. The diff of a +// 10,000-line file with 100 one-line edits once marked thousands of equal +// lines as changed. The 50,000-line files use the fast diff. +func TestManySpreadEdits(t *testing.T) { + t.Parallel() + for _, size := range []struct{ lines, edits int }{{10_000, 100}, {10_000, 1000}, {50_000, 100}, {50_000, 1000}} { + t.Run(fmt.Sprint(size.lines, "x", size.edits), func(t *testing.T) { + t.Parallel() + var o, n strings.Builder + every := size.lines / size.edits + for i := range size.lines { + line := fmt.Sprintf("line %d\n", i) + o.WriteString(line) + if i%every == every/2 { + line = fmt.Sprintf("edited %d\n", i) + } + n.WriteString(line) + } + oldSrc, newSrc := o.String(), n.String() + want := size.edits + if a, d := diff.CountLines(oldSrc, newSrc); a != want || d != want { + t.Errorf("CountLines = +%d -%d, want +%d -%d", a, d, want, want) + } + // Only the rows are needed, so the lines are not rendered. + u := diff.Render("x.txt", oldSrc, newSrc, diff.Options{Width: 80}) + if a, d := countKind(u.Rows, diff.KindAdded), countKind(u.Rows, diff.KindDeleted); a != want || d != want { + t.Errorf("unified: %d added and %d deleted rows, want %d and %d", a, d, want, want) + } + s := diff.Render("x.txt", oldSrc, newSrc, diff.Options{Width: 80, Split: true}) + if c := countKind(s.Rows, diff.KindChanged); c != want { + t.Errorf("split: %d changed rows, want %d", c, want) + } + }) + } +} + +// countKind returns the number of rows of kind k. +func countKind(rows []diff.Row, k diff.Kind) int { + n := 0 + for _, r := range rows { + if r.Kind == k { + n++ + } + } + return n +} + +// TestFewValues checks that the counts of a large diff of lines with few +// distinct values match the rows of its rendered diff. +func TestFewValues(t *testing.T) { + t.Parallel() + oldSrc, newSrc := fewValues(25_000) + a, d := diff.CountLines(oldSrc, newSrc) + u := diff.Render("x.txt", oldSrc, newSrc, diff.Options{Width: 80}) + if ra, rd := countKind(u.Rows, diff.KindAdded), countKind(u.Rows, diff.KindDeleted); ra != a || rd != d { + t.Errorf("the diff has %d added and %d deleted rows, but CountLines = +%d -%d", ra, rd, a, d) + } +} + +// TestRenderFile checks the line numbers and rows of a rendered file. func TestRenderFile(t *testing.T) { t.Parallel() src := "package x\n\nfunc f() {\n\treturn\n}\n" - d := diff.RenderFile("x.go", src, diff.Options{Width: 14}) - if len(d.Lines) != 5 || len(d.Rows) != 5 { + d := diff.RenderFile("x.go", src, diff.FileOptions{Width: 14}) + if len(screenLines(d)) != 5 || len(d.Rows) != 5 { t.Fatalf("RenderFile: %d lines and %d rows, want 5", - len(d.Lines), len(d.Rows)) + len(screenLines(d)), len(d.Rows)) } if d.Rows[3] != (diff.Row{Kind: diff.KindEqual, Old: 4, New: 4}) { t.Errorf("row 3 = %+v, want %+v", d.Rows[3], diff.Row{Kind: diff.KindEqual, Old: 4, New: 4}) } - if got := ansi.Strip(d.Lines[2]); got != " 3 func f() {" { + if got := ansi.Strip(screenLines(d)[2]); got != " 3 func f() {" { t.Errorf("line 2 = %q, want %q", got, " 3 func f() {") } - if got := ansi.Strip(d.Lines[0]); got != " 1 package x" { + if got := ansi.Strip(screenLines(d)[0]); got != " 1 package x" { t.Errorf("line 0 = %q, want %q", got, " 1 package x") } } -// TestWrap checks that a wrapped long line shows all of its text and fits the -// width. +// TestWrap checks that a wrapped long line shows all of its text and fits +// the width. func TestWrap(t *testing.T) { t.Parallel() long := "x := []string{" + strings.Repeat(`"word", `, 12) + "}" - before, after := "a\nb\n", "a\n"+long+"\nb\n" + oldSrc, newSrc := "a\nb\n", "a\n"+long+"\nb\n" const width = 40 - for _, tt := range renderers(before, after) { + for _, tt := range renderers(oldSrc, newSrc) { t.Run(tt.name, func(t *testing.T) { t.Parallel() cut := tt.render(diff.Options{Width: width}) d := tt.render(diff.Options{Width: width, Wrap: true}) - if len(d.Lines) != len(d.Rows) || len(d.Lines) <= len(cut.Lines) { + if len(screenLines(d)) != len(d.Rows) || len(screenLines(d)) <= len(screenLines(cut)) { t.Fatalf("%d lines and %d rows, "+ "want more than the %d lines without wrap", - len(d.Lines), len(d.Rows), len(cut.Lines)) + len(screenLines(d)), len(d.Rows), len(screenLines(cut))) } var text strings.Builder - for i, l := range d.Lines { + for i, l := range screenLines(d) { if w := ansi.StringWidth(l); w > width { t.Errorf("line %d is %d cells wide, want %d or fewer", i, w, width) @@ -382,8 +452,7 @@ func TestWrap(t *testing.T) { if r.New == 2 { text.WriteString(ansi.Strip(l)) } - // A continued row points at the same source lines as the - // row above. + // A continued row has the same source lines as the row above. prev := d.Rows[max(i-1, 0)] sameLines := prev.Old == r.Old && prev.New == r.New if r.Continued && !sameLines { @@ -398,8 +467,8 @@ func TestWrap(t *testing.T) { t.Errorf("wrapped lines = \n%s\nwant all of:\n%s", text.String(), long) } - // Wrap does not change the snippet of a comment. - got, want := snippet(d.Rows, before, after), snippet(cut.Rows, before, after) + // Wrapping does not change a comment's diff lines. + got, want := snippet(d.Rows, oldSrc, newSrc), snippet(cut.Rows, oldSrc, newSrc) if !slices.Equal(got, want) { t.Errorf("snippet with wrap = %q, want %q", got, want) } @@ -407,14 +476,15 @@ func TestWrap(t *testing.T) { } } -// TestCache checks that a shared [diff.Cache] gives the same lines as no -// cache. It tries other offsets, other languages, and new content. +// TestCache checks that renders sharing a [diff.Cache] return the same +// lines as renders without one, as the scroll offset, language and content +// change. func TestCache(t *testing.T) { t.Parallel() const src = "def f(): # a comment\n\treturn 1\n" tests := []struct { path, old, new string - file bool // RenderFile, of new + file bool // use RenderFile on new offset int }{ {path: "a.go", old: src, new: src + "x\n"}, @@ -427,11 +497,15 @@ func TestCache(t *testing.T) { var c diff.Cache for _, tt := range tests { render := func(c *diff.Cache) []string { - o := diff.Options{Width: 40, Offset: tt.offset, Cache: c} + o := diff.Options{Width: 40, ScrollX: tt.offset, Cache: c} if tt.file { - return diff.RenderFile(tt.path, tt.new, o).Lines + d := diff.RenderFile(tt.path, tt.new, fileOptions(o)) + if c != nil { // a busy machine can highlight later than a line needs + waitHighlighted(t, c) + } + return screenLines(d) } - return diff.Render(tt.path, tt.old, tt.new, o).Lines + return screenLines(diff.Render(tt.path, tt.old, tt.new, o)) } if got, want := render(&c), render(nil); !slices.Equal(got, want) { t.Errorf("%+v: lines with the cache = \n%q\nwant:\n%q", @@ -439,3 +513,26 @@ func TestCache(t *testing.T) { } } } + +// TestNoNewline checks that a changed last line without a line ending is +// marked, and that an unchanged one is not. +func TestNoNewline(t *testing.T) { + t.Parallel() + const mark = `\ No newline at end of file` + d := diff.Render("x.txt", "a\nb", "a\nb\n", diff.Options{Width: 80}) + var marked []string + for i := range screenLines(d) { + if l := plainLine(t, d, i); strings.Contains(l, mark) { + marked = append(marked, l) + } + } + if len(marked) != 1 || !strings.Contains(marked[0], "b") { + t.Errorf("Render(%q, %q) marks %q, want only the deleted b", "a\nb", "a\nb\n", marked) + } + d = diff.Render("x.txt", "a\nb", "c\nb", diff.Options{Width: 80}) + for i := range screenLines(d) { + if l := plainLine(t, d, i); strings.Contains(l, mark) { + t.Errorf("Render(%q, %q) line %d = %q, want no mark on the equal b", "a\nb", "c\nb", i, l) + } + } +} diff --git a/diff/file.go b/diff/file.go index 02f90fe..77aaa70 100644 --- a/diff/file.go +++ b/diff/file.go @@ -11,112 +11,185 @@ import ( "github.com/rednafi/rexdiff/internal/diffview" ) -// lineNumberColor is the ANSI palette colour of the line numbers. Colour 8 -// is bright black. -const lineNumberColor = "8" - -// RenderFile shows src, the content of the file at path, with line numbers -// and syntax colours. Each row is a [KindEqual] row, with the same line -// number on the two sides. The line numbers count as the [SideNew] side. -// RenderFile uses only the Width, Offset, Wrap, and Cache fields of o. -func RenderFile(path, src string, o Options) *Diff { - // Chroma makes a lone CR a line break. RenderFile shows a lone CR as a - // symbol, so that the lines are the same as [Lines] gives. +// lineNumColor is the ANSI palette colour of the line numbers (bright +// black). +const lineNumColor = "8" + +// FileOptions controls how [RenderFile] draws a file. The fields do what +// the fields of [Options] with the same names do. +type FileOptions struct { + Width int // the maximum width of a line, or 0 for no limit + ScrollX int // the cells to scroll the code to the right + Wrap bool // continue a long line on the following lines + Cache *Cache // the results to reuse, or nil +} + +// RenderFile renders src, the content of the file at path, with line +// numbers and syntax highlighting. Every row is a [KindEqual] row with the +// same line number on both sides, and the line numbers belong to +// [SideNew]. +func RenderFile(path, src string, o FileOptions) *Diff { raw := src - src = strings.NewReplacer( - "\r\n", "\n", - "\r", "\u240d", - "\t", strings.Repeat(" ", diffview.TabWidth), - ).Replace(src) - hl := strings.Split(o.Cache.fileColors(path, src), "\n") - hl = hl[:min(len(hl), countLines(src))] // chroma can add a final newline - digits := len(strconv.Itoa(len(hl))) + src, hl := o.Cache.fileColors(path, raw) + n := countLines(src) + digits := len(strconv.Itoa(n)) numWidth := digits + 2 // a space on each side of the number - // The style renders once, around a format for the number. - numFmt := lipgloss.NewStyle().Foreground(lipgloss.Color(lineNumberColor)). - Render(" %*d ") + " " - d := &Diff{newNum: numWidth, src: [2]string{raw, raw}, wrap: o.Wrap} + f := &fileLines{ + hl: hl, + o: o, + digits: digits, + // Render the style once, around a format verb for the number. + numFmt: lipgloss.NewStyle().Foreground(lipgloss.Color(lineNumColor)). + Render(" %*d ") + " ", + codeWidth: o.Width - numWidth - 1, // the width left after the number and a space + blank: strings.Repeat(" ", numWidth+1), + src: strings.Split(src, "\n"), + } + d := &Diff{newNum: numWidth, texts: [2]string{raw, raw}, wrap: o.Wrap} d.codeAt = [2]int{numWidth + 1, numWidth + 1} + if o.Width > 0 { + d.codeWidth = [2]int{f.codeWidth, f.codeWidth} + } if !o.Wrap { - d.offset = o.Offset + d.scrollX = o.ScrollX } - code := o.Width - numWidth - 1 // the width after the number and a space - blank := strings.Repeat(" ", numWidth+1) - for i, l := range hl { - for k, p := range fileLineParts(l, code, o) { - num := blank - if k == 0 { - num = fmt.Sprintf(numFmt, digits, i+1) - } - line := num + p - if o.Width > 0 { // the number or a wide character can be too wide - line = ansi.Truncate(line, o.Width, "") - } - d.Lines = append(d.Lines, line) - d.Rows = append(d.Rows, Row{ - Kind: KindEqual, - Old: i + 1, - New: i + 1, - Continued: k > 0, - }) + // Only a wrapped line that does not fit uses more than one screen line. + // The count comes from the plain text, so no line is highlighted here. + wraps := o.Wrap && o.Width > 0 && f.codeWidth > 0 + start := make([]int, n) + d.Rows = make([]Row, 0, n) + for i := range n { + parts := 1 + if wraps && !diffview.Fits(f.source(i), f.codeWidth) { + parts = len(diffview.WrapParts(f.plain(i), f.codeWidth)) + } + start[i] = len(d.Rows) + for k := range parts { + d.Rows = append(d.Rows, Row{Kind: KindEqual, Old: i + 1, New: i + 1, Continued: k > 0}) } } + d.lines = diffview.NewLines(len(d.Rows), start, f.render) return d } -// fileLineParts returns the screen lines of the highlighted line l. If o has -// a width, each part fits in code cells. With [Options.Wrap], a long line -// gives more than one part. Without Wrap, the line skips [Options.Offset] -// cells and is cut. -func fileLineParts(l string, code int, o Options) []string { +// fileLines renders the screen lines of [RenderFile]. +type fileLines struct { + hl *diffview.Highlighter // the highlighted source lines + o FileOptions + digits int // the number of digits in line numbers + codeWidth int // the width left for code + numFmt, blank string // the line-number format, and blank space of the same width + src []string // the source lines, as the highlighter got them +} + +// render returns the screen lines of the 0-based source line i. final is +// false if the highlighter did not have the line ready, so the lines have +// no colours for now. +func (f *fileLines) render(i int) (lines []string, final bool) { + l, _, final := f.hl.Line(i) + parts := fileLineParts(f.plain(i), l, f.codeWidth, f.o) + out := make([]string, len(parts)) + for k, p := range parts { + num := f.blank + if k == 0 { + num = fmt.Sprintf(f.numFmt, f.digits, i+1) + } + line := num + p + if f.o.Width > 0 { // the number or a wide character can overflow the width + line = ansi.Truncate(line, f.o.Width, "") + } + out[k] = line + } + return out, final +} + +// source returns the 0-based source line i, or "" if there is none. +func (f *fileLines) source(i int) string { + if i < len(f.src) { + return f.src[i] + } + return "" +} + +// plain returns the 0-based source line i escaped and without colours, as +// the highlighter shows a line that it does not highlight. +func (f *fileLines) plain(i int) string { return diffview.Escape(f.source(i)) } + +// fileLineParts splits the highlighted line l, whose plain text is plain, +// into screen lines. If o sets a width, each part fits in codeWidth cells. +// With [FileOptions.Wrap], a long line becomes several parts, broken where +// the plain text breaks. Without Wrap, the first [FileOptions.ScrollX] +// cells are skipped and the rest is cut to fit. +func fileLineParts(plain, l string, codeWidth int, o FileOptions) []string { switch { - case o.Width > 0 && code <= 0: + case o.Width > 0 && codeWidth <= 0: return []string{""} + case o.Width > 0 && o.Wrap && diffview.Fits(plain, codeWidth): + return []string{l} case o.Width > 0 && o.Wrap: - // Wrap can keep a leading space on a full line, so cut again. - wrapped := ansi.Hardwrap(lipgloss.Wrap(l, code, ""), code, true) - return strings.Split(wrapped, "\n") + return diffview.WrapLine(plain, l, codeWidth) case o.Width > 0: - return []string{ansi.Truncate(diffview.CutLeft(l, o.Offset), code, "…")} + return []string{ansi.Truncate(diffview.CutLeft(l, o.ScrollX), codeWidth, "…")} } - return []string{diffview.CutLeft(l, o.Offset)} + return []string{diffview.CutLeft(l, o.ScrollX)} } -// fileColors returns highlight(path, src), and keeps it for the next -// call. A nil c keeps nothing. -func (c *Cache) fileColors(path, src string) string { +// fileColors converts raw, the content of the file at path, for display by +// RenderFile, and returns the converted text and a highlighter for it. c +// stores both for the next call, and stops the old highlighter when the +// file changes. A nil c stores nothing. +func (c *Cache) fileColors(path, raw string) (string, *diffview.Highlighter) { if c == nil { - return highlight(path, src) + // Nothing stops this highlighter; its goroutine ends when it + // reaches the end of src. + src := shownFile(raw) + return src, highlighter(path, src) } f := &c.file - if f.path != path || f.src != src { - f.path, f.src, f.out = path, src, highlight(path, src) + src := f.src + if f.h == nil || f.raw != raw { + src = shownFile(raw) + } + if f.h == nil || f.path != path || f.src != src { + if f.h != nil { + f.h.Stop() // a Diff that uses it starts it again for the lines it reads + } + f.path, f.src, f.h = path, src, highlighter(path, src) } - return f.out + f.raw = raw + return src, f.h } -// highlight colours src, the whole file at path, at once, so that tokens -// that span lines get the correct colour. If it cannot highlight src, it -// returns src with its control characters replaced. -func highlight(path, src string) string { - it, err := diffview.Lexer(path, src).Tokenise(nil, src) - if err != nil { - return escapeKeepNewlines(src) +// StopHighlighting stops the background highlighting of the last file +// passed to [RenderFile] with c, for example when the file is no longer +// shown. A [Diff] of the file starts it again for the lines that it reads. +func (c *Cache) StopHighlighting() { + if c != nil && c.file.h != nil { + c.file.h.Stop() } - f := diffview.ANSIFormatter(lipgloss.NoColor{}, escapeKeepNewlines) - var b strings.Builder - if f.Format(&b, nil, it) != nil { - return escapeKeepNewlines(src) - } - return b.String() } -// escapeKeepNewlines replaces the control characters in a token, but keeps -// its line breaks. The caller splits the output into lines. -func escapeKeepNewlines(s string) string { - parts := strings.Split(s, "\n") - for i, p := range parts { - parts[i] = diffview.Escape(p) - } - return strings.Join(parts, "\n") +// Highlighting reports whether the last file passed to [RenderFile] with +// c is still being highlighted in the background. While it is, lines that +// were drawn without colours can get them, so the screen should be drawn +// again. +func (c *Cache) Highlighting() bool { return c != nil && c.file.h != nil && c.file.h.Busy() } + +// fileChars replaces CRLF with LF and each tab with spaces. It replaces a +// lone CR with a symbol, because chroma treats it as a line break. The +// resulting lines match those of [SplitLines]. +var fileChars = strings.NewReplacer("\r\n", "\n", "\r", diffview.CRSymbol, "\t", strings.Repeat(" ", diffview.TabWidth)) + +// shownFile returns raw converted for display by RenderFile. +func shownFile(raw string) string { return fileChars.Replace(raw) } + +// highlighter returns a [diffview.Highlighter] for src, the whole content +// of the file at path. If src cannot be highlighted, the lines are those +// of src with control characters escaped. +func highlighter(path, src string) *diffview.Highlighter { + return diffview.NewHighlighter(path, src, lipgloss.NoColor{}, escapeKeepNewlines, escapeKeepNewlines) } + +// escapeKeepNewlines escapes the control characters in s but keeps its +// line breaks. +func escapeKeepNewlines(s string) string { return diffview.EachLine(s, diffview.Escape) } diff --git a/diff/fold_test.go b/diff/fold_test.go index ec1d143..0c8bbeb 100644 --- a/diff/fold_test.go +++ b/diff/fold_test.go @@ -11,8 +11,8 @@ import ( "github.com/rednafi/rexdiff/diff" ) -// numbered returns lines "line 1" to "line n". The lines in upper are -// "LINE 1" and so on. +// numbered returns the lines "line 1" to "line n". The line numbers in +// upper use "LINE" instead of "line". func numbered(n int, upper ...int) string { var b strings.Builder for i := 1; i <= n; i++ { @@ -25,11 +25,12 @@ func numbered(n int, upper ...int) string { return b.String() } -// foldText writes a hunk header with fold f, for example "@14/14+13". -func foldText(f diff.Fold) string { return fmt.Sprintf("@%d/%d+%d", f.Old, f.New, f.Lines) } +// foldText formats a fold as text, for example "@14/14+13". +func foldText(fold diff.Fold) string { return fmt.Sprintf("@%d/%d+%d", fold.Old, fold.New, fold.Lines) } -// layout writes each row of d as one entry. A hunk header is "@" and its -// fold. Another row is its old and its new line number. +// layout returns one entry for each row of d. A hunk header becomes its +// fold, as written by foldText. Any other row becomes "old:new", its old +// and new line numbers. func layout(d *diff.Diff) []string { var out []string for _, r := range d.Rows { @@ -42,12 +43,12 @@ func layout(d *diff.Diff) []string { return out } -// headers returns the text of the hunk headers of d. +// headers returns the text of the hunk headers of d, one per line. func headers(d *diff.Diff) string { var out []string for i, r := range d.Rows { if r.Kind == diff.KindHunk { - out = append(out, strings.TrimSpace(ansi.Strip(d.Lines[i]))) + out = append(out, strings.TrimSpace(ansi.Strip(screenLines(d)[i]))) } } return strings.Join(out, "\n") @@ -57,7 +58,7 @@ func headers(d *diff.Diff) string { // header below it. func TestFolds(t *testing.T) { t.Parallel() - before, after := numbered(40), numbered(40, 10, 30) + oldText, newText := numbered(40), numbered(40, 10, 30) header := func(o, n, lines int) []string { return []string{foldText(diff.Fold{Old: o, New: n, Lines: lines})} } lines := func(lo, hi int) []string { var out []string @@ -67,8 +68,8 @@ func TestFolds(t *testing.T) { return out } change := func(n int) []string { return []string{fmt.Sprintf("%d:0", n), fmt.Sprintf("0:%d", n)} } - // Three lines show beside each change. A fold that Expanded accepts shows - // all of its lines. + // Three lines of context are shown around each change. A fold that + // Expanded accepts shows all of its lines. top, middle, tail, none := header(1, 1, 6), header(14, 14, 13), header(34, 34, 7), header(0, 0, 0) tests := []struct { name string @@ -80,11 +81,11 @@ func TestFolds(t *testing.T) { middle, lines(27, 29), change(30), lines(31, 33), tail, )}, - {"middle shows", func(f diff.Fold) bool { return f.New == 14 }, slices.Concat( + {"middle shows", func(fold diff.Fold) bool { return fold.New == 14 }, slices.Concat( top, lines(7, 9), change(10), lines(11, 29), change(30), lines(31, 33), tail, )}, - {"top and tail show", func(f diff.Fold) bool { return f.New != 14 }, slices.Concat( + {"top and tail show", func(fold diff.Fold) bool { return fold.New != 14 }, slices.Concat( none, lines(1, 9), change(10), lines(11, 13), middle, lines(27, 29), change(30), lines(31, 40), )}, @@ -96,16 +97,16 @@ func TestFolds(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() o := diff.Options{Width: 60, Expanded: tt.expanded} - d := diff.Render("f.txt", before, after, o) + d := diff.Render("f.txt", oldText, newText, o) if got := layout(d); !slices.Equal(got, tt.want) { t.Errorf("layout = \n%v\nwant:\n%v", got, tt.want) } }) } - // The header gives the number of hidden lines. A fold at the end has no - // hunk header. In the split layout, the header continues across the - // right side. No line is wider than the screen. + // Each header shows the number of hidden lines. A fold at the end has + // no hunk range. In the split layout, the header spans both sides. No + // line is wider than the screen. for _, tt := range []struct { o diff.Options want string @@ -117,12 +118,12 @@ func TestFolds(t *testing.T) { "… ⋯ 13 unmodified lines @@ -27,7 +27,7 @@\n" + "… ⋯ 7 unmodified lines"}, } { - d := diff.Render("f.txt", before, after, tt.o) + d := diff.Render("f.txt", oldText, newText, tt.o) if got := headers(d); got != tt.want { t.Errorf("split %v: headers = \n%s\nwant:\n%s", tt.o.Split, got, tt.want) } - for i, l := range d.Lines { + for i, l := range screenLines(d) { if w := ansi.StringWidth(l); w > tt.o.Width { t.Errorf("split %v: line %d is %d cells wide, want %d or fewer", tt.o.Split, i, w, tt.o.Width) @@ -131,7 +132,8 @@ func TestFolds(t *testing.T) { } } -// TestFoldEdges checks the folds that show no line. +// TestFoldEdges checks folds in edge cases: a single equal line between +// two changes, a line added at the top, and a file with no equal lines. func TestFoldEdges(t *testing.T) { t.Parallel() tests := []struct { @@ -139,13 +141,13 @@ func TestFoldEdges(t *testing.T) { old, new string want string }{ - // Changes at 10 and 18 leave line 14 alone between the margins. The - // line shows, because a fold of one line saves no row. + // Changes at lines 10 and 18 leave only line 14 between the context + // lines. It is shown, because folding one line saves no rows. { "one line", numbered(21), numbered(21, 10, 18), "@1/1+6 7:7 8:8 9:9 10:0 0:10 11:11 12:12 13:13 14:14 15:15 16:16 17:17 18:0 0:18 19:19 20:20 21:21", }, - // After an insert, the old and the new numbers of a fold differ. + // After an added line, the old and new line numbers of a fold differ. { "insert at the top", numbered(12), "new\n" + numbered(12), "@0/0+0 0:1 1:2 2:3 3:4 @4/5+9", @@ -170,7 +172,7 @@ func TestFoldEdges(t *testing.T) { } } -// folds returns the folds of the hunk headers of d. +// folds returns the folds of the hunk headers in d. func folds(d *diff.Diff) []diff.Fold { var out []diff.Fold for _, r := range d.Rows { diff --git a/diff/fuzz_test.go b/diff/fuzz_test.go index 2207078..55023d8 100644 --- a/diff/fuzz_test.go +++ b/diff/fuzz_test.go @@ -13,10 +13,12 @@ import ( "github.com/rednafi/rexdiff/diff" ) -// paths select the lexers that the fuzz tests use. +// paths are file names that select the syntax highlighting in the fuzz +// tests. var paths = []string{"x.go", "x.py", "x.txt", "x.md", "x.html", "x.json", "Makefile", "x"} -// hostile is file content that can attack a terminal or break the layout. +// hostile holds file contents that could attack a terminal or break the +// layout. var hostile = []string{ "", "\n", @@ -32,11 +34,13 @@ var hostile = []string{ "\x9b31m\xff\xfe\n", "日本語のテキスト😀👨‍👩‍👧\n", "a\u0301\u200d\u202e\n", + "/*\u202e } \u2066if\u2069 \u2066*/\u200b\ufeff\u061c\u200e\u200f\n", + strings.Repeat("中\u202e文\u2066", 40) + "\n", strings.Repeat("long ", 200) + "\n", "no newline at end", } -// sgr matches the colour sequences that the renderer writes. +// sgr matches the colour escape sequences that the renderer writes. var sgr = regexp.MustCompile(`\x1b\[[0-9;:]*m`) // isUnsafeControl reports whether r is a control character other than a @@ -45,13 +49,20 @@ func isUnsafeControl(r rune) bool { return r != '\t' && r != '\n' && unicode.IsControl(r) } -// fuzzOptions makes Options from the fuzz values. -func fuzzOptions(layout, width, offset uint8, colored bool) diff.Options { +// isBidiControl reports whether r is a bidirectional control character, +// which can reorder the text that a terminal shows. +func isBidiControl(r rune) bool { + return r == '\u061c' || r == '\u200e' || r == '\u200f' || + r >= '\u202a' && r <= '\u202e' || r >= '\u2066' && r <= '\u2069' +} + +// fuzzOptions builds Options from fuzz inputs. +func fuzzOptions(layout, width, scrollX uint8, colored bool) diff.Options { o := diff.Options{ - Split: layout&1 != 0, - Wrap: layout&2 != 0, - Width: int(width % 60), - Offset: int(offset % 20), + Split: layout&1 != 0, + Wrap: layout&2 != 0, + Width: int(width % 60), + ScrollX: int(scrollX % 20), } if colored { o.Theme = diff.Theme{ @@ -62,14 +73,16 @@ func fuzzOptions(layout, width, offset uint8, colored bool) diff.Options { return o } -// checkLines checks the invariants that all rendered output must keep. +// checkLines checks that d has one line for each row, that each line fits +// the width in o, and that no line contains control characters other than +// colour sequences. func checkLines(t *testing.T, d *diff.Diff, o diff.Options) { t.Helper() - if len(d.Lines) != len(d.Rows) { + if len(screenLines(d)) != len(d.Rows) { t.Fatalf("%d lines for %d rows, want one line for each row", - len(d.Lines), len(d.Rows)) + len(screenLines(d)), len(d.Rows)) } - for i, l := range d.Lines { + for i, l := range screenLines(d) { if w := ansi.StringWidth(l); o.Width > 0 && w > o.Width { t.Errorf("line %d is %d cells wide, want %d or fewer: %q", i, w, o.Width, l) @@ -78,18 +91,40 @@ func checkLines(t *testing.T, d *diff.Diff, o diff.Options) { if strings.ContainsFunc(rest, unicode.IsControl) { t.Errorf("line %d = %q, want no control characters", i, l) } + if strings.ContainsFunc(rest, isBidiControl) { + t.Errorf("line %d = %q, want no bidirectional controls", i, l) + } + } +} + +// checkSameText checks that two renders of the same diff show the same +// rows and text when their lines are read in opposite orders. The colours +// can differ: a long line that misses its highlighting time limit shows +// plain. +func checkSameText(t *testing.T, a, b *diff.Diff) { + t.Helper() + want := screenLines(a) + if !slices.Equal(a.Rows, b.Rows) { + t.Fatalf("rows differ:\n first %v\n second %v", a.Rows, b.Rows) + } + for i := range slices.Backward(b.Rows) { + if got := ansi.Strip(b.Line(i)); got != ansi.Strip(want[i]) { + t.Fatalf("line %d = %q, want %q", i, got, ansi.Strip(want[i])) + } } } -// checkRows checks that each row points at real lines, in order. -func checkRows(t *testing.T, rows []diff.Row, before, after string) { +// checkRows checks that each row refers to existing lines, that line +// numbers increase, and that a continued row repeats the lines of the row +// above. +func checkRows(t *testing.T, rows []diff.Row, oldText, newText string) { t.Helper() - nOld, nNew := len(diff.Lines(before)), len(diff.Lines(after)) + oldCount, newCount := len(diff.SplitLines(oldText)), len(diff.SplitLines(newText)) lastOld, lastNew := 0, 0 for i, r := range rows { - if r.Old < 0 || r.Old > nOld || r.New < 0 || r.New > nNew { + if r.Old < 0 || r.Old > oldCount || r.New < 0 || r.New > newCount { t.Fatalf("row %d = %+v, want Old in 0-%d and New in 0-%d", - i, r, nOld, nNew) + i, r, oldCount, newCount) } if r.Continued { if i == 0 || rows[i-1].Old != r.Old || rows[i-1].New != r.New { @@ -108,29 +143,31 @@ func checkRows(t *testing.T, rows []diff.Row, before, after string) { } } -// FuzzRender checks the invariants of Render. It also checks that Count -// agrees with the snippet. +// FuzzRender checks that the output of Render is well formed, that a lazy +// render matches it, and that CountLines agrees with a comment's diff +// lines. func FuzzRender(f *testing.F) { for i, s := range hostile { f.Add(uint8(i), "a\nb\n", s, uint8(i), uint8(i*7), uint8(i%3), i%2 == 0) f.Add(uint8(i), s, "a\n"+s+"b\n", uint8(i+1), uint8(i*5), uint8(0), i%2 == 1) } - f.Add(uint8(0), oldSrc, newSrc, uint8(3), uint8(1), uint8(0), false) + f.Add(uint8(0), oldText, newText, uint8(3), uint8(1), uint8(0), false) f.Add(uint8(0), "\tx\n", " x\n", uint8(0), uint8(0), uint8(0), false) f.Fuzz(func( t *testing.T, p uint8, - before, after string, - layout, width, offset uint8, + oldSrc, newSrc string, + layout, width, scrollX uint8, colored bool, ) { - o := fuzzOptions(layout, width, offset, colored) - d := diff.Render(paths[int(p)%len(paths)], before, after, o) + o := fuzzOptions(layout, width, scrollX, colored) + d := diff.Render(paths[int(p)%len(paths)], oldSrc, newSrc, o) checkLines(t, d, o) - checkRows(t, d.Rows, before, after) - if o.Width == 0 && o.Offset == 0 && !strings.Contains(before+after, "…") { - // With no width limit, no line is cut. - for i, l := range d.Lines { + checkSameText(t, d, diff.Render(paths[int(p)%len(paths)], oldSrc, newSrc, o)) + checkRows(t, d.Rows, oldSrc, newSrc) + if o.Width == 0 && o.ScrollX == 0 && !strings.Contains(oldSrc+newSrc, "…") { + // Without a width limit, no line is cut. + for i, l := range screenLines(d) { cut := strings.Contains(ansi.Strip(l), "…") if cut && d.Rows[i].Kind != diff.KindHunk { t.Fatalf("line %d = %q, want no cut with no width limit", i, l) @@ -138,14 +175,14 @@ func FuzzRender(f *testing.F) { } } - added, deleted := diff.Count(before, after) - if (added == 0 && deleted == 0) != (len(d.Lines) == 0) { + added, deleted := diff.CountLines(oldSrc, newSrc) + if (added == 0 && deleted == 0) != (len(screenLines(d)) == 0) { t.Fatalf("Count = %d, %d and Render has %d lines, "+ "want zero counts only for zero lines", - added, deleted, len(d.Lines)) + added, deleted, len(screenLines(d))) } var plus, minus int - for _, l := range snippet(d.Rows, before, after) { + for _, l := range snippet(d.Rows, oldSrc, newSrc) { switch l[0] { case '+': plus++ @@ -160,17 +197,67 @@ func FuzzRender(f *testing.F) { }) } -// FuzzSnippetSameInLayouts checks that the split and the unified layouts give -// the same snippet. +// FuzzRenderApply checks that a diff with every fold expanded changes the +// old text into the new one: its rows list every old line and every new +// line once and in order, and each equal row joins two identical lines, +// line endings included. +func FuzzRenderApply(f *testing.F) { + for _, s := range hostile { + f.Add("a\nb\n", s) + f.Add(s, "a\n"+s+"b\n") + } + f.Add(oldText, newText) + f.Add("a\nb\nc\nd\n", "x\nb\ny\nz\nd\n") + f.Add("a\r\nb\r\n", "a\nb\r\n") + f.Add("x\ny\nz\n", "y\nx\nz") + f.Fuzz(func(t *testing.T, oldSrc, newSrc string) { + o := diff.Options{Expanded: func(diff.Fold) bool { return true }} + d := diff.Render("x.txt", oldSrc, newSrc, o) + oldLines := slices.Collect(strings.Lines(oldSrc)) + newLines := slices.Collect(strings.Lines(newSrc)) + if len(d.Rows) == 0 && oldSrc != newSrc { + t.Fatalf("no rows for different texts %q and %q", oldSrc, newSrc) + } + nextOld, nextNew := 1, 1 + for i, r := range d.Rows { + if r.Continued || r.Kind == diff.KindHunk { + continue + } + if r.Kind != diff.KindAdded { + if r.Old != nextOld { + t.Fatalf("row %d = %+v, want old line %d", i, r, nextOld) + } + nextOld++ + } + if r.Kind != diff.KindDeleted { + if r.New != nextNew { + t.Fatalf("row %d = %+v, want new line %d", i, r, nextNew) + } + nextNew++ + } + if r.Kind == diff.KindEqual && oldLines[r.Old-1] != newLines[r.New-1] { + t.Fatalf("equal row %d joins different lines %q and %q", + i, oldLines[r.Old-1], newLines[r.New-1]) + } + } + if len(d.Rows) > 0 && (nextOld != len(oldLines)+1 || nextNew != len(newLines)+1) { + t.Fatalf("the rows end before old line %d and new line %d, want %d and %d", + nextOld, nextNew, len(oldLines)+1, len(newLines)+1) + } + }) +} + +// FuzzSnippetSameInLayouts checks that a comment gets the same diff lines +// from the split layout as from the unified layout. func FuzzSnippetSameInLayouts(f *testing.F) { - f.Add(oldSrc, newSrc) + f.Add(oldText, newText) f.Add("a\nb\nc\nd\n", "x\nb\ny\nz\nd\n") f.Add("\tx\r\n", "\ty\n") - f.Fuzz(func(t *testing.T, before, after string) { - u := diff.Render("x.txt", before, after, diff.Options{}) + f.Fuzz(func(t *testing.T, oldSrc, newSrc string) { + u := diff.Render("x.txt", oldSrc, newSrc, diff.Options{}) o := diff.Options{Split: true, Width: 30, Wrap: true} - s := diff.Render("x.txt", before, after, o) - got, want := snippet(s.Rows, before, after), snippet(u.Rows, before, after) + s := diff.Render("x.txt", oldSrc, newSrc, o) + got, want := snippet(s.Rows, oldSrc, newSrc), snippet(u.Rows, oldSrc, newSrc) if !slices.Equal(got, want) { t.Fatalf("split snippet = %q, want the unified snippet %q", got, want) @@ -178,7 +265,9 @@ func FuzzSnippetSameInLayouts(f *testing.F) { }) } -// FuzzRenderFile checks that RenderFile gives one row for each source line. +// FuzzRenderFile checks that the output of RenderFile is well formed, that +// a lazy render matches it, and that it has one non-continued row for each +// source line. func FuzzRenderFile(f *testing.F) { for i, s := range hostile { f.Add(uint8(i), s, uint8(i), uint8(i*7), uint8(i%3), i%2 == 0) @@ -189,11 +278,12 @@ func FuzzRenderFile(f *testing.F) { t *testing.T, p uint8, src string, - layout, width, offset uint8, + layout, width, scrollX uint8, colored bool, ) { - o := fuzzOptions(layout, width, offset, colored) - d := diff.RenderFile(paths[int(p)%len(paths)], src, o) + o := fuzzOptions(layout, width, scrollX, colored) + d := diff.RenderFile(paths[int(p)%len(paths)], src, fileOptions(o)) + checkLazyFile(t, paths[int(p)%len(paths)], src, o) checkLines(t, d, o) checkRows(t, d.Rows, src, src) n := 0 @@ -202,40 +292,42 @@ func FuzzRenderFile(f *testing.F) { n++ } } - if want := len(diff.Lines(src)); n != want { + if want := len(diff.SplitLines(src)); n != want { t.Fatalf("RenderFile gives %d rows, want %d", n, want) } }) } -// FuzzCount checks that the counts of Count are in the range of the line -// counts. -func FuzzCount(f *testing.F) { +// FuzzCountLines checks that CountLines returns zero for equal texts, the +// full line count against an empty text, and otherwise counts no larger +// than the texts' line counts. +func FuzzCountLines(f *testing.F) { for _, s := range hostile { f.Add(s, "a\n") } f.Fuzz(func(t *testing.T, a, b string) { - if added, deleted := diff.Count(a, a); added != 0 || deleted != 0 { - t.Errorf("Count(%q, %q) = %d, %d, want 0, 0", a, a, added, deleted) + if added, deleted := diff.CountLines(a, a); added != 0 || deleted != 0 { + t.Errorf("CountLines(%q, %q) = %d, %d, want 0, 0", a, a, added, deleted) } - na, nb := len(diff.Lines(a)), len(diff.Lines(b)) - if added, deleted := diff.Count("", a); added != na || deleted != 0 { - t.Errorf("Count(\"\", %q) = %d, %d, want %d, 0", + na, nb := len(diff.SplitLines(a)), len(diff.SplitLines(b)) + if added, deleted := diff.CountLines("", a); added != na || deleted != 0 { + t.Errorf("CountLines(\"\", %q) = %d, %d, want %d, 0", a, added, deleted, na) } - if added, deleted := diff.Count(a, ""); added != 0 || deleted != na { - t.Errorf("Count(%q, \"\") = %d, %d, want 0, %d", + if added, deleted := diff.CountLines(a, ""); added != 0 || deleted != na { + t.Errorf("CountLines(%q, \"\") = %d, %d, want 0, %d", a, added, deleted, na) } - added, deleted := diff.Count(a, b) + added, deleted := diff.CountLines(a, b) if added < 0 || deleted < 0 || added > nb || deleted > na { - t.Errorf("Count(%q, %q) = %d, %d, want 0-%d, 0-%d", + t.Errorf("CountLines(%q, %q) = %d, %d, want 0-%d, 0-%d", a, b, added, deleted, nb, na) } }) } -// FuzzComments checks that the review text has no control characters. +// FuzzComments checks that the review text and comment locations contain +// no control characters. func FuzzComments(f *testing.F) { for i, s := range hostile { f.Add("a\nb\nc\n", s, "a.go", s, uint8(i), uint8(i%3)) @@ -248,23 +340,23 @@ func FuzzComments(f *testing.F) { uint8(1), uint8(1), ) - f.Fuzz(func(t *testing.T, before, after, path, text string, start, size uint8) { - lines := diff.Lines(before) + f.Fuzz(func(t *testing.T, oldText, newText, path, text string, start, size uint8) { + lines := diff.SplitLines(oldText) if len(lines) == 0 { return } s := int(start) % len(lines) e := min(s+int(size), len(lines)-1) var cs diff.Comments - c := commentOn(path, diff.SideNew, before, s+1, e+1) + c := commentOn(path, diff.SideNew, oldText, s+1, e+1) c.Text = text if c.Start != s+1 || c.End != e+1 { t.Errorf("commentOn lines %d-%d = %+v, want lines %d-%d", s+1, e+1, c, s+1, e+1) } cs.Add(c) - cs.Add(comment(path, diff.SideOld, before, after, text)) - r := cs.Review(text) + cs.Add(comment(path, diff.SideOld, oldText, newText, text)) + r := cs.Review(text, nil) if strings.ContainsFunc(r, isUnsafeControl) { t.Errorf("Review = %q, want no control characters", r) } diff --git a/diff/hostile_test.go b/diff/hostile_test.go index 9ac0746..86a7fb6 100644 --- a/diff/hostile_test.go +++ b/diff/hostile_test.go @@ -1,17 +1,19 @@ package diff_test import ( + "fmt" "image/color" "strings" "testing" + "time" "github.com/charmbracelet/x/ansi" "github.com/rednafi/rexdiff/diff" ) -// TestHostileContent checks the lines and the rows of each hostile file, in -// all layouts. +// TestHostileContent checks the lines and rows of [Render] and +// [RenderFile] for each hostile input, with several layout options. func TestHostileContent(t *testing.T) { t.Parallel() opts := []diff.Options{ @@ -21,14 +23,14 @@ func TestHostileContent(t *testing.T) { {Width: 9, Wrap: true}, {Split: true, Width: 30}, {Split: true, Width: 30, Wrap: true}, - {Width: 20, Offset: 3}, + {Width: 20, ScrollX: 3}, } for _, s := range hostile { for _, o := range opts { d := diff.Render("x.go", "a\n", s, o) checkLines(t, d, o) checkRows(t, d.Rows, "a\n", s) - f := diff.RenderFile("x.go", s, o) + f := diff.RenderFile("x.go", s, fileOptions(o)) checkLines(t, f, o) checkRows(t, f.Rows, s, s) if t.Failed() { @@ -38,8 +40,8 @@ func TestHostileContent(t *testing.T) { } } -// TestEscapesAreVisible checks that an escape in a file shows as a symbol, -// so that the reader sees it. +// TestEscapesAreVisible checks that escape sequences and control +// characters in a file are shown as visible symbols. func TestEscapesAreVisible(t *testing.T) { t.Parallel() tests := []struct{ src, want string }{ @@ -55,16 +57,117 @@ func TestEscapesAreVisible(t *testing.T) { if got := plainLine(t, d, 1); !strings.Contains(got, tt.want) { t.Errorf("Render(%q) = %q, want it to show %q", tt.src, got, tt.want) } - f := diff.RenderFile("x.txt", tt.src, diff.Options{}) - if len(f.Lines) != 1 || !strings.Contains(ansi.Strip(f.Lines[0]), tt.want) { + f := diff.RenderFile("x.txt", tt.src, diff.FileOptions{}) + if len(screenLines(f)) != 1 || !strings.Contains(ansi.Strip(screenLines(f)[0]), tt.want) { t.Errorf("RenderFile(%q) = %q, want one line that shows %q", - tt.src, f.Lines, tt.want) + tt.src, screenLines(f), tt.want) } } } +// TestBidiControlsAreVisible checks that bidirectional controls and +// zero-width characters in a file are shown as their code points, so that +// they cannot reorder or hide the code on screen, in a highlighted file +// and in a plain one. The zero-width joiner and variation selectors stay, +// because emoji need them. +func TestBidiControlsAreVisible(t *testing.T) { + t.Parallel() + tests := []struct{ src, want string }{ + // A "Trojan Source" line: the override makes the comment appear + // after the return on screen. + {"if admin { /*\u202e } \u2066if user\u2069 \u2066*/ return\n", "/* } if user */ return"}, + {"a\u202ab\u202bc\u202cd\u202de\n", "abcde"}, + {"a\u2067b\u2068c\n", "abc"}, + {"a\u200eb\u200fc\u061cd\n", "abcd"}, + {"\ufeffa\u200bb\u2060c\n", "abc"}, + {"x := \"👨\u200d👩\u200d👧 ❤\ufe0f\"\n", "x := \"👨\u200d👩\u200d👧 ❤\ufe0f\""}, + } + for _, tt := range tests { + for _, name := range []string{"x.go", "x.txt"} { + d := diff.Render(name, "", tt.src, diff.Options{}) + if got := plainLine(t, d, 1); !strings.Contains(got, tt.want) { + t.Errorf("Render(%q, %q) = %q, want it to show %q", name, tt.src, got, tt.want) + } + f := diff.RenderFile(name, tt.src, diff.FileOptions{}) + if len(screenLines(f)) != 1 || !strings.Contains(ansi.Strip(screenLines(f)[0]), tt.want) { + t.Errorf("RenderFile(%q, %q) = %q, want one line that shows %q", + name, tt.src, screenLines(f), tt.want) + } + } + } +} + +// TestBidiControlCell checks that a click on any cell of a shown bidi +// control finds that control, and that a click after it finds the +// character after it. +func TestBidiControlCell(t *testing.T) { + t.Parallel() + d := diff.Render("x.txt", "", "a\u202eb\n", diff.Options{Width: 40}) + at := column(t, d, 1, "", false) + for x := at; x < at+len(""); x++ { + if _, _, i, ok := d.Cell(1, x); !ok || i != 1 { + t.Errorf("Cell(1, %d) = index %d, %t, want 1, true", x, i, ok) + } + } + if _, _, i, ok := d.Cell(1, at+len("")); !ok || i != 2 { + t.Errorf("Cell after the control = index %d, %t, want 2, true", i, ok) + } +} + +// TestSlowLexers checks that files whose chroma lexers never stop, or +// take seconds, on some lines are drawn quickly. Each line used to take +// more than 40 s or never finish. +func TestSlowLexers(t *testing.T) { + t.Parallel() + tests := []struct{ name, line string }{ + {"x.jungle", strings.Repeat(`"`, 100)}, + {"x.jungle", strings.Repeat("x := 1 // y", 10)}, + {"x.jsonata", strings.Repeat(`\`, 100)}, + {"x.ly", strings.Repeat("a", 1500)}, + {"x.mess", strings.Repeat("a", 1500)}, + {"x.dts", strings.Repeat("a", 1500)}, + {"x.service", strings.Repeat("a", 1500)}, + } + for _, tt := range tests { + within(t, 10*time.Second, tt.name, func() { + for _, o := range []diff.Options{{}, {Width: 40, Wrap: true}} { + diff.Render(tt.name, "", tt.line+"\n", o) + diff.RenderFile(tt.name, tt.line+"\n", fileOptions(o)) + } + }) + } +} + +// TestSlowLines checks that a diff of long lines that a lexer takes +// seconds to highlight is drawn quickly. Without a time limit, the V +// shell lexer takes 2 s on each of these lines. +func TestSlowLines(t *testing.T) { + t.Parallel() + var src strings.Builder + for i := range 4 { + fmt.Fprintf(&src, "%s%d\n", strings.Repeat("/*a", 499), i) + } + within(t, 6*time.Second, "x.vsh", func() { diff.Render("x.vsh", "", src.String(), diff.Options{}) }) +} + +// within fails the test if f takes longer than limit. f keeps running +// after the failure. +func within(t *testing.T, limit time.Duration, what string, f func()) { + t.Helper() + done := make(chan struct{}) + go func() { + defer close(done) + f() + }() + select { + case <-done: + case <-time.After(limit): + t.Fatalf("%s takes more than %s to draw", what, limit) + } +} + // TestWrapWideText checks that wrapped CJK and emoji text fits the width -// and that no character is lost. +// and that no characters are lost. func TestWrapWideText(t *testing.T) { t.Parallel() line := strings.Repeat("日本語😀", 10) @@ -75,7 +178,7 @@ func TestWrapWideText(t *testing.T) { d := diff.Render("x.txt", "", line+"\n", o) checkLines(t, d, o) var text strings.Builder - for i, l := range d.Lines { + for i, l := range screenLines(d) { if d.Rows[i].New == 1 { text.WriteString(ansi.Strip(l)) } @@ -88,8 +191,8 @@ func TestWrapWideText(t *testing.T) { } } -// TestRenderFileLineCount checks the number of rows for each kind of line -// ending. +// TestRenderFileLineCount checks the number of rows that [RenderFile] +// returns for different line endings. func TestRenderFileLineCount(t *testing.T) { t.Parallel() tests := []struct { @@ -105,7 +208,7 @@ func TestRenderFileLineCount(t *testing.T) { } for _, tt := range tests { for _, path := range []string{"x.txt", "Makefile", "x.go"} { - if d := diff.RenderFile(path, tt.src, diff.Options{}); len(d.Rows) != tt.rows { + if d := diff.RenderFile(path, tt.src, diff.FileOptions{}); len(d.Rows) != tt.rows { t.Errorf("RenderFile(%q, %q) has %d rows, want %d", path, tt.src, len(d.Rows), tt.rows) } @@ -113,17 +216,18 @@ func TestRenderFileLineCount(t *testing.T) { } } -// TestRenderFileNarrow checks that wide text fits a width of 1 to 7 cells. +// TestRenderFileNarrow checks that wrapped wide text fits widths of 1 to 7 +// cells. func TestRenderFileNarrow(t *testing.T) { t.Parallel() for w := 1; w < 8; w++ { o := diff.Options{Width: w, Wrap: true} - checkLines(t, diff.RenderFile("x.txt", "日本\n 0000\n", o), o) + checkLines(t, diff.RenderFile("x.txt", "日本\n 0000\n", fileOptions(o)), o) } } // TestReviewHostileComment checks that Review quotes a hostile path and -// removes the control characters from the code and the text. +// removes control characters from the code and the comment text. func TestReviewHostileComment(t *testing.T) { t.Parallel() c := comment("a\x1b]52;c;aGk=\x07.go", diff.SideNew, @@ -137,17 +241,17 @@ func TestReviewHostileComment(t *testing.T) { } } -// TestBlackTint checks that an equal line with the text of a deleted line has -// no background when the tint of deleted lines is black. +// TestBlackTint checks that an equal line with the same text as a deleted +// line has no background colour when deleted lines are tinted black. func TestBlackTint(t *testing.T) { t.Parallel() black := color.RGBA{A: 255} - th := diff.Theme{Background: black, Foreground: color.White, Red: black} - d := diff.Render("a.txt", "x\ny\nx\n", "y\nx\n", diff.Options{Theme: th}) + theme := diff.Theme{Background: black, Foreground: color.White, Red: black} + d := diff.Render("a.txt", "x\ny\nx\n", "y\nx\n", diff.Options{Theme: theme}) for i, r := range d.Rows { - if r.Kind == diff.KindEqual && strings.Contains(d.Lines[i], "48;2;0;0;0mx") { + if r.Kind == diff.KindEqual && strings.Contains(screenLines(d)[i], "48;2;0;0;0mx") { t.Errorf("equal line %d = %q, want no background of a deleted line", - r.New, d.Lines[i]) + r.New, screenLines(d)[i]) } } } diff --git a/diff/lazy_test.go b/diff/lazy_test.go new file mode 100644 index 0000000..5536e1e --- /dev/null +++ b/diff/lazy_test.go @@ -0,0 +1,173 @@ +package diff_test + +import ( + "fmt" + "image/color" + "slices" + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "github.com/rednafi/rexdiff/diff" +) + +// checkLazy checks that lazy has the same rows and lines as eager. It +// reads the lines of lazy from last to first, so that no line relies on +// earlier lines having been rendered. +func checkLazy(t *testing.T, eager, lazy *diff.Diff) { + t.Helper() + want := screenLines(eager) + if !slices.Equal(eager.Rows, lazy.Rows) { + t.Fatalf("lazy rows differ:\n eager %v\n lazy %v", eager.Rows, lazy.Rows) + } + if len(lazy.Rows) != len(want) { + t.Fatalf("rows = %d, want %d", len(lazy.Rows), len(want)) + } + for i := range slices.Backward(lazy.Rows) { + if got := lazy.Line(i); got != want[i] { + t.Fatalf("lazy line %d = %q, want %q", i, got, want[i]) + } + } +} + +// checkLazyFile checks that two RenderFiles of src at path with the +// options o return the same rows and lines, whatever order they are read +// in. The first waits for the highlighting that both use: a line that the +// highlighter has not reached in time shows without colours. +func checkLazyFile(t *testing.T, path, src string, o diff.Options) { + t.Helper() + o.Cache = &diff.Cache{} + eager := diff.RenderFile(path, src, fileOptions(o)) + waitHighlighted(t, o.Cache) + checkLazy(t, eager, diff.RenderFile(path, src, fileOptions(o))) +} + +// lazyFile returns a Go file of about n lines that includes long lines, +// lines with wide characters and indented lines. If edit is true, some +// lines differ and end in CRLF. No line is longer than 256 bytes, so that +// the time limit on highlighting a long line on its own never applies. +func lazyFile(n int, edit bool) string { + var b strings.Builder + b.WriteString("package x\n\n") + for i := range n { + switch { + case i%17 == 0: + fmt.Fprintf(&b, "\tlong%d := %q\n", i, strings.Repeat("word ", 5+i%40)) + case i%13 == 0: + fmt.Fprintf(&b, "\t\tw%d := \"日本語のテキスト😀 %s\"\n", i, strings.Repeat("中文", i%30)) + case i%11 == 0 && edit: + fmt.Fprintf(&b, "\tx%d := %d // edited\r\n", i, i) + default: + fmt.Fprintf(&b, "\tx%d := %d\n", i, i) + } + } + return b.String() +} + +// TestLazy checks that a lazy render returns the same rows and lines as an +// eager render, for both layouts, several widths and scroll offsets, and +// with and without wrapping. +func TestLazy(t *testing.T) { + t.Parallel() + oldSrc, newSrc := lazyFile(300, false), lazyFile(310, true) + for _, split := range []bool{false, true} { + for _, wrap := range []bool{false, true} { + for _, width := range []int{0, 1, 10, 30, 80, 141} { + for _, scrollX := range []int{0, 9} { + name := fmt.Sprintf("split=%v,wrap=%v,width=%d,scrollX=%d", split, wrap, width, scrollX) + t.Run(name, func(t *testing.T) { + t.Parallel() + expanded := func(f diff.Fold) bool { return f.Old%2 == 0 } + o := diff.Options{ + Split: split, Wrap: wrap, Width: width, ScrollX: scrollX, + Signs: scrollX > 0, Expanded: expanded, + Theme: diff.Theme{Background: color.Black, Foreground: color.White}, + } + lo := o + lo.Cache = &diff.Cache{} + checkLazy(t, diff.Render("x.go", oldSrc, newSrc, o), diff.Render("x.go", oldSrc, newSrc, lo)) + checkLazyFile(t, "x.go", newSrc, o) + for _, s := range hostile { + checkLazy(t, diff.Render("x.txt", "a\n"+s, s+"b\n", o), diff.Render("x.txt", "a\n"+s, s+"b\n", lo)) + checkLazyFile(t, "x.txt", s, o) + } + }) + } + } + } + } + // Test non-ASCII lines that fit or wrap at each width. Colour codes + // between the runes of an emoji can change its width. + wide := []string{"é 日本\n", "x := 👨‍👩 + 🇬🇧 é\n", "x := 👨‍👩 + 🇬🇧 " + strings.Repeat("日本 ", 20) + "\n"} + for width := range 40 { + o := diff.Options{Wrap: true, Width: width, Theme: diff.Theme{Background: color.Black, Foreground: color.White}} + lo := o + lo.Cache = &diff.Cache{} + for _, s := range wide { + checkLazy(t, diff.Render("x.go", "a\n"+s, s+"b\n", o), diff.Render("x.go", "a\n"+s, s+"b\n", lo)) + checkLazyFile(t, "x.go", s, o) + } + } +} + +// TestCacheKeepsDiff checks that lazy renders that reuse one Cache return +// the same rows and lines as renders without a Cache, while the layout, +// wrapping, folds, width and horizontal scroll change between renders. +func TestCacheKeepsDiff(t *testing.T) { + t.Parallel() + oldSrc, newSrc := lazyFile(120, false), lazyFile(110, true) + c := &diff.Cache{} + for range 2 { + for _, split := range []bool{true, false} { + for _, wrap := range []bool{false, true} { + for _, all := range []bool{false, true} { + for _, width := range []int{90, 60} { + for _, scrollX := range []int{0, 7} { + expanded := func(diff.Fold) bool { return all } + o := diff.Options{Split: split, Wrap: wrap, Width: width, ScrollX: scrollX, Expanded: expanded} + want := diff.Render("x.go", oldSrc, newSrc, o) + o.Cache = c + checkLazy(t, want, diff.Render("x.go", oldSrc, newSrc, o)) + } + } + } + } + } + } +} + +// waitHighlighted waits until c highlights no file in the background. +func waitHighlighted(t *testing.T, c *diff.Cache) { + t.Helper() + for deadline := time.Now().Add(time.Minute); c.Highlighting(); time.Sleep(time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("the highlighting runs for a minute") + } + } +} + +// TestFileKeepsLines checks that a lazy Diff still renders its last line +// highlighted after the same Cache renders another file, which stops the +// background highlighting of the first. +func TestFileKeepsLines(t *testing.T) { + t.Parallel() + src := lazyFile(3000, false) + o := diff.Options{Width: 80, Cache: &diff.Cache{}} + big := diff.RenderFile("x.go", src, fileOptions(o)) + diff.RenderFile("y.go", "package y\n", fileOptions(o)) + wc := &diff.Cache{} + want := diff.RenderFile("x.go", src, diff.FileOptions{Width: 80, Cache: wc}) + waitHighlighted(t, wc) + // The lazy Diff highlights the line again, and shows it plain until + // then. + last := len(big.Rows) - 1 + deadline := time.Now().Add(time.Minute) + for got := big.Line(last); got != want.Line(last); got = big.Line(last) { + if ansi.Strip(got) != ansi.Strip(want.Line(last)) || time.Now().After(deadline) { + t.Fatalf("last line = %q, want %q", got, want.Line(last)) + } + time.Sleep(time.Millisecond) + } +} diff --git a/diff/main_test.go b/diff/main_test.go index b37f0ec..1c6d42b 100644 --- a/diff/main_test.go +++ b/diff/main_test.go @@ -3,7 +3,13 @@ package diff_test import ( "testing" + "github.com/rednafi/rexdiff/diff" "github.com/rednafi/rexdiff/internal/testgit" ) func TestMain(m *testing.M) { testgit.Main(m) } + +// fileOptions returns the fields of o that [diff.RenderFile] uses. +func fileOptions(o diff.Options) diff.FileOptions { + return diff.FileOptions{Width: o.Width, ScrollX: o.ScrollX, Wrap: o.Wrap, Cache: o.Cache} +} diff --git a/diff/review.go b/diff/review.go index 4fa69f6..95981f0 100644 --- a/diff/review.go +++ b/diff/review.go @@ -6,36 +6,44 @@ import ( "github.com/rednafi/rexdiff/internal/termtext" ) -// Review returns the comments as herdr-reviewr text for a coding agent. It -// writes the prefix, if not empty, then the comments in [Comments.All] order. -// Blank lines separate them. The text has no control characters. +// Review returns the comments as text for a coding agent. The text starts +// with prefix, if it is not empty, followed by the comments in +// [Comments.All] order, separated by blank lines. The text contains no +// control characters. // -// This is an example of a comment: +// A comment looks like this: // // a.go:3-4 // x := 1 // +y := 2 // Merge these lines. -func (cs *Comments) Review(prefix string) string { +// +// If stale is not nil and reports true for a comment, " (stale)" follows +// its location, because its lines may no longer hold the code shown. +func (cs *Comments) Review(prefix string, stale func(*Comment) bool) string { var parts []string if p := strings.TrimRight(termtext.Plain(prefix), " \n\t"); p != "" { parts = append(parts, p) } for _, c := range cs.All() { - parts = append(parts, format(c)) + parts = append(parts, formatComment(c, stale != nil && stale(c))) } return strings.Join(parts, "\n\n") } -// format returns c as text for [Comments.Review]. -func format(c *Comment) string { +// formatComment returns c as text for [Comments.Review], marked stale if +// stale is true. +func formatComment(c *Comment, stale bool) string { var b strings.Builder b.WriteString(termtext.Plain(c.Location())) - // Write the rows as the diff shows them, so that the agent reads a real - // diff. + if stale { + b.WriteString(" (stale)") + } + // Write the rows as unified diff lines, or the code lines if there are + // no rows. if len(c.snippet) > 0 { for _, l := range c.snippet { - b.WriteString("\n") + b.WriteByte('\n') b.WriteString(termtext.Plain(l)) } } else { @@ -44,7 +52,7 @@ func format(c *Comment) string { mark = "-" } for _, l := range c.code { - b.WriteString("\n") + b.WriteByte('\n') b.WriteString(mark) b.WriteString(termtext.Plain(l)) } @@ -55,14 +63,14 @@ func format(c *Comment) string { text = append(text, strings.TrimRight(l, " \t")) } } - b.WriteString("\n") + b.WriteByte('\n') b.WriteString(strings.Join(text, "\n")) return b.String() } // snippet returns rows as unified diff lines. See [NewComment]. -func snippet(rows []Row, before, after string) []string { - oldLines, newLines := Lines(before), Lines(after) +func snippet(rows []Row, oldText, newText string) []string { + oldLines, newLines := SplitLines(oldText), SplitLines(newText) line := func(ls []string, n int) string { if n >= 1 && n-1 < len(ls) { return ls[n-1] @@ -82,11 +90,11 @@ func snippet(rows []Row, before, after string) []string { case KindEqual: flush() out = append(out, " "+line(newLines, r.New)) - case KindInsert: + case KindAdded: plus = append(plus, "+"+line(newLines, r.New)) - case KindDelete: + case KindDeleted: minus = append(minus, "-"+line(oldLines, r.Old)) - case KindChange: + case KindChanged: minus = append(minus, "-"+line(oldLines, r.Old)) plus = append(plus, "+"+line(newLines, r.New)) } diff --git a/diff/testdata/fuzz/FuzzRender/03c1cdf0ef3dea55 b/diff/testdata/fuzz/FuzzRender/03c1cdf0ef3dea55 new file mode 100644 index 0000000..f248bb0 --- /dev/null +++ b/diff/testdata/fuzz/FuzzRender/03c1cdf0ef3dea55 @@ -0,0 +1,8 @@ +go test fuzz v1 +byte('a') +string("0\x9e0") +string("0") +byte('\x01') +byte('\x00') +byte('\x00') +bool(false) diff --git a/diff/testdata/fuzz/FuzzRender/597030a707a26025 b/diff/testdata/fuzz/FuzzRender/597030a707a26025 new file mode 100644 index 0000000..4091cc7 --- /dev/null +++ b/diff/testdata/fuzz/FuzzRender/597030a707a26025 @@ -0,0 +1,8 @@ +go test fuzz v1 +byte('\b') +string("") +string("👨\u200d👩") +byte('\x04') +byte('\x00') +byte('\x00') +bool(true) diff --git a/diff/testdata/fuzz/FuzzRenderFile/3ed8df90f10ed889 b/diff/testdata/fuzz/FuzzRenderFile/3ed8df90f10ed889 new file mode 100644 index 0000000..d72b42d --- /dev/null +++ b/diff/testdata/fuzz/FuzzRenderFile/3ed8df90f10ed889 @@ -0,0 +1,7 @@ +go test fuzz v1 +byte('\f') +string("0\x8b") +byte('/') +byte('\x05') +byte('z') +bool(false) diff --git a/diff/theme.go b/diff/theme.go index b3025e5..50902c4 100644 --- a/diff/theme.go +++ b/diff/theme.go @@ -4,26 +4,27 @@ import ( "image/color" ) -// Theme sets the colours of the output. The lightness of Background selects -// the dark or the light diff colours. +// Theme sets the colours of the output. The lightness of Background +// selects the dark or light diff colours. type Theme struct { - // Background and Foreground are the colours of the terminal. They are nil - // if the terminal does not report them. Then the diff tints no background. + // Background and Foreground are the terminal's colours, or nil if the + // terminal does not report them. If Background is nil, lines are not + // tinted. Background, Foreground color.Color - // Green and Red are the ANSI colours 2 and 1 of the terminal. Inserted and - // deleted lines get a tint towards them. If they are nil, the diff uses + // Green and Red are the terminal's ANSI colours 2 and 1. Added and + // deleted lines are tinted towards them. If they are nil, the diff uses // fixed colours. Green, Red color.Color } -// dark reports whether the background is dark. An unknown background counts -// as dark. +// dark reports whether the background is dark. A nil background counts as +// dark. func (t Theme) dark() bool { if t.Background == nil { return true } r, g, b, _ := t.Background.RGBA() hi, lo := max(r, g, b), min(r, g, b) - // The HSL lightness, from 0 to 1. + // HSL lightness, from 0 to 1. return float64(hi+lo)/2/0xffff < 0.5 } diff --git a/go.mod b/go.mod index d1ffb2a..c02aab0 100644 --- a/go.mod +++ b/go.mod @@ -8,13 +8,14 @@ require ( charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 github.com/alecthomas/chroma/v2 v2.27.0 - github.com/aymanbagabas/go-udiff v0.4.1 github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 github.com/charmbracelet/x/ansi v0.11.9 github.com/charmbracelet/x/vt v0.1.0 github.com/creack/pty v1.1.24 github.com/rogpeppe/go-internal v1.16.0 github.com/sahilm/fuzzy v0.1.3 + github.com/yuin/goldmark v1.8.6 + znkr.io/diff v1.1.4 ) require ( @@ -36,7 +37,6 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v1.0.0 // indirect - github.com/yuin/goldmark v1.8.6 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect golang.org/x/net v0.59.0 // indirect golang.org/x/sync v0.24.0 // indirect diff --git a/go.sum b/go.sum index 27a9607..26dfbf3 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -87,3 +89,5 @@ golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= +znkr.io/diff v1.1.4 h1:ib4zEF5iyjcudfZGW5tCxiSitY+o4ZOz1gwEJ8KJFj8= +znkr.io/diff v1.1.4/go.mod h1:NGb8XOI3voBG4nlWWNB2NulIQzdCfe9igGPVSXs6oSM= diff --git a/internal/diffview/caps_test.go b/internal/diffview/caps_test.go new file mode 100644 index 0000000..8caefa9 --- /dev/null +++ b/internal/diffview/caps_test.go @@ -0,0 +1,199 @@ +package diffview_test + +import ( + "fmt" + "strings" + "testing" + "time" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rednafi/rexdiff/internal/diffview" +) + +// newHighlighter returns a Highlighter for src at path that escapes the +// text of tokens, as RenderFile does. +func newHighlighter(path, src string) *diffview.Highlighter { + return diffview.NewHighlighter(path, src, lipgloss.NoColor{}, escapeLines, escapeLines) +} + +// escapeLines escapes each line of s. +func escapeLines(s string) string { + lines := strings.Split(s, "\n") + for i, l := range lines { + lines[i] = diffview.Escape(l) + } + return strings.Join(lines, "\n") +} + +// wait waits until h has highlighted every line, as long as nothing +// calls Stop. +func wait(t *testing.T, h *diffview.Highlighter) { + t.Helper() + for deadline := time.Now().Add(time.Minute); h.Busy(); time.Sleep(time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("the highlighting runs for a minute") + } + } +} + +// coloured reports whether s contains an escape sequence. +func coloured(s string) bool { return strings.Contains(s, "\x1b[") } + +// TestHighlighterLongLine checks that a Highlighter draws a line too long +// to lex plain and escaped, and still highlights the other lines. +func TestHighlighterLongLine(t *testing.T) { + t.Parallel() + long := "var s = \"\x07" + strings.Repeat("x", 5000) + "\"" + h := newHighlighter("x.go", "package a\n"+long+"\nfunc f() {}\n") + wait(t, h) + if l, _, _ := h.Line(0); !coloured(l) { + t.Errorf("line 0 = %q, want colours", l) + } + if l, _, _ := h.Line(1); l != diffview.Escape(long) { + t.Errorf("line 1 = %.40q…, want the line plain and escaped", l) + } + if l, _, _ := h.Line(2); !coloured(l) { + t.Errorf("line 2 = %q, want colours", l) + } +} + +// TestHighlighterLargeFile checks that a Highlighter still highlights the +// lines of a file too large to lex as a whole. +func TestHighlighterLargeFile(t *testing.T) { + t.Parallel() + if raceOn { + t.Skip("the race detector makes each line miss its highlighting time limit") + } + comment := "// " + strings.Repeat("x", 1400) + src := "package a\n" + strings.Repeat(comment+"\n", 400) + "func f() {}\n" // about 560 KB + h := newHighlighter("x.go", src) + wait(t, h) + for i, want := range map[int]string{0: "package a", 1: comment, 401: "func f() {}"} { + if l, _, _ := h.Line(i); !coloured(l) || ansi.Strip(l) != want { + t.Errorf("line %d = %.40q…, want it highlighted", i, l) + } + } +} + +// TestHighlighterSlow checks that Line does not wait for a lexer that is +// slow, and returns the line plain instead: one screen of such lines takes +// far less time than the lexing. +func TestHighlighterSlow(t *testing.T) { + t.Parallel() + // The Java lexer takes about a second on these lines. + words := "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor\n" + src := strings.Repeat(words, 300) + "class A {}\n" + h := newHighlighter("x.java", src) + defer h.Stop() + start := time.Now() + l, ok, final := h.Line(300) + if !ok || final || l != "class A {}" { + t.Errorf("Line(300) = %q, %v, %v, want the line plain and not final", l, ok, final) + } + for i := 299; i > 259; i-- { + if _, _, final := h.Line(i); final { + t.Errorf("Line(%d) is final before lexing passes line 300", i) + } + } + // The race detector slows the lexer down too much for the time check. + if d := time.Since(start); d > 250*time.Millisecond && !raceOn { + t.Errorf("41 lines took %v, want Line to give up waiting sooner", d) + } +} + +// TestHighlighterStop checks that Busy reports true while lexing goes on, +// and that Stop ends the lexing of a source that is slow to lex within a +// second, rather than after its time budget. +func TestHighlighterStop(t *testing.T) { + t.Parallel() + h := newHighlighter("x.java", slowJava()) + if !h.Busy() { + t.Error("Busy = false at the start, want true while lexing") + } + h.Stop() + start := time.Now() + wait(t, h) + // The race detector slows the lexer down too much for the time check. + if d := time.Since(start); d > time.Second && !raceOn { + t.Errorf("lexing runs %v after Stop, want at most a second", d) + } +} + +// TestHighlighterTypicalFile checks that a typical Go file of 100 KB is +// coloured to the end: no line of it misses a time limit. +func TestHighlighterTypicalFile(t *testing.T) { + t.Parallel() + var b strings.Builder + b.WriteString("package a\n\nimport \"fmt\"\n\n") + for i := 0; b.Len() < 100<<10; i++ { + fmt.Fprintf(&b, "// F%d prints its argument.\nfunc F%d(x int) string {\n\treturn fmt.Sprintf(\"%%d: %%s\", x, \"text\")\n}\n\n", i, i) + } + src := b.String() + h := newHighlighter("a.go", src) + defer h.Stop() + wait(t, h) + lines := []int{0, 2} + if !raceOn { // the race detector makes lexing slower than its time limit + lines = append(lines, strings.Count(src[:strings.LastIndex(src, "func ")], "\n")) + } + for _, i := range lines { + if l, _, final := h.Line(i); !final || !coloured(l) { + t.Errorf("line %d = %q, final %v, want it highlighted", i, l, final) + } + } +} + +// TestWrapLine checks that WrapLine breaks a coloured line where WrapParts +// breaks its plain text, even when colours split a grapheme cluster. +func TestWrapLine(t *testing.T) { + t.Parallel() + family := "👨\u200d👩\u200d👧" // one cluster of five runes + plain := strings.Repeat("ab "+family+" cd ", 20) + red, reset := "\x1b[31m", "\x1b[m" + // Colour each rune of the cluster on its own, as a lexer can. + code := strings.ReplaceAll(plain, family, red+"👨"+reset+"\u200d"+red+"👩"+reset+"\u200d👧") + code = strings.ReplaceAll(code, "ab", red+"ab"+reset) + for _, width := range []int{1, 3, 7, 10, 25} { + want := diffview.WrapParts(plain, width) + got := diffview.WrapLine(plain, code, width) + if len(got) != len(want) { + t.Fatalf("width %d: %d parts, want %d", width, len(got), len(want)) + } + for k := range got { + if ansi.Strip(got[k]) != want[k] { + t.Fatalf("width %d: part %d = %q, want the text %q", width, k, got[k], want[k]) + } + } + if !coloured(strings.Join(got, "")) { + t.Errorf("width %d: the parts have no colours", width) + } + } + // A code line that does not match plain gives the plain parts. + if got := diffview.WrapLine(plain, "other", 10); strings.Join(got, "") != strings.Join(diffview.WrapParts(plain, 10), "") { + t.Errorf("WrapLine with other text = %q, want the plain parts", got) + } +} + +// slowJava returns about 100 KB of lines of words, on which the Java +// lexer takes about 30 s when it lexes them as a whole. +func slowJava() string { + return strings.Repeat(strings.Repeat("lorem ipsum dolor sit amet ", 7)+"\n", 500) + "class A {}\n" +} + +// TestHighlighterBudget checks that a Highlighter stops lexing a source +// that is slow to lex after its time budget, and draws the rest plain. +func TestHighlighterBudget(t *testing.T) { + t.Parallel() + src := slowJava() + h := newHighlighter("x.java", src) + start := time.Now() + wait(t, h) + if d := time.Since(start); d > 10*time.Second { + t.Errorf("lexing took %v, want it to stop after its budget", d) + } + if l, ok, final := h.Line(500); !ok || !final || l != "class A {}" { + t.Errorf("Line(500) = %q, %v, %v, want the line plain and final", l, ok, final) + } +} diff --git a/internal/diffview/edits.go b/internal/diffview/edits.go new file mode 100644 index 0000000..7a09e38 --- /dev/null +++ b/internal/diffview/edits.go @@ -0,0 +1,94 @@ +package diffview + +import ( + "strings" + + zdiff "znkr.io/diff" + "znkr.io/diff/textdiff" +) + +// hunk is a run of lines of a diff. OldStart and NewStart are the old +// and new line numbers of its first line. +type hunk struct { + OldStart, NewStart int + Lines []diffLine +} + +// diffLine is one line of a hunk. Content keeps its line ending. +type diffLine struct { + Kind Kind // KindEqual, KindAdded, or KindDeleted + Content string +} + +// next returns the old and new line numbers of the line after l, where +// oldNum and newNum are those of l. +func (l diffLine) next(oldNum, newNum int) (int, int) { + if l.Kind != KindAdded { + oldNum++ + } + if l.Kind != KindDeleted { + newNum++ + } + return oldNum, newNum +} + +// fastDiffLines is the number of lines, old and new together, above which +// edits uses the fast diff. The diff runs while the screen is drawn, and +// the default diff becomes slow on large rewrites. The fast diff can mark +// a few extra lines as changed. +const fastDiffLines = 10_000 + +// edits returns the line edits between oldText and newText, from newText +// to oldText: a Delete is a line of newText, and an Insert a line of +// oldText. Reversing makes textdiff pick the same diff as git when two +// diffs are equally short. Above fastDiffLines lines, edits uses the fast +// diff, so that no input makes it slow. +func edits(oldText, newText string) []textdiff.Edit[string] { + opts := []textdiff.Option{textdiff.IndentHeuristic()} + if strings.Count(oldText, "\n")+strings.Count(newText, "\n") > fastDiffLines { + opts = append(opts, zdiff.Fast()) + } + return textdiff.Edits(newText, oldText, opts...) +} + +// lineDiff returns a hunk that holds every line of oldText and newText, or +// nil if the texts are equal. In each run of changes, the deleted lines +// come before the added lines. A line whose ending changes counts as +// changed. +func lineDiff(oldText, newText string) *hunk { + h := &hunk{OldStart: 1, NewStart: 1} + var added []diffLine // the added lines of the current run + changed := false + for _, e := range edits(oldText, newText) { + switch e.Op { + case zdiff.Delete: + added = append(added, diffLine{Kind: KindAdded, Content: e.Line}) + changed = true + case zdiff.Insert: + h.Lines = append(h.Lines, diffLine{Kind: KindDeleted, Content: e.Line}) + changed = true + case zdiff.Match: + h.Lines = append(append(h.Lines, added...), diffLine{Kind: KindEqual, Content: e.Line}) + added = added[:0] + } + } + h.Lines = append(h.Lines, added...) + if !changed { + return nil + } + return h +} + +// CountLines returns the number of lines added and deleted by the change +// from oldText to newText. +func CountLines(oldText, newText string) (added, deleted int) { + for _, e := range edits(oldText, newText) { + switch e.Op { + case zdiff.Delete: + added++ + case zdiff.Insert: + deleted++ + } + } + return added, deleted +} diff --git a/internal/diffview/fold.go b/internal/diffview/fold.go index b3396fc..09bdf13 100644 --- a/internal/diffview/fold.go +++ b/internal/diffview/fold.go @@ -1,71 +1,96 @@ package diffview -import "github.com/aymanbagabas/go-udiff" - -// foldMargin is the number of equal lines that show beside a change. A -// longer run of equal lines folds into one row, as in herdr-reviewr. +// foldMargin is the number of equal lines shown before and after each +// change. const foldMargin = 3 -// Fold is a run of equal lines that the view hides. Before and After are -// the line numbers of its first line. Lines is the number of lines. Lines -// is 0 if the view hides no line. +// Fold is a run of equal lines that the diff hides. Old and New are the +// line numbers of its first line, and Lines is the number of lines. Lines +// is 0 if no lines are hidden. type Fold struct { - Before, After, Lines int + Old, New, Lines int } -// foldHunk splits h into the hunks that show. folds[i] is the run of lines -// that hides above hunks[i]. If the last run hides, the last hunk has no -// lines. A run of equal lines hides if it has more than one line and no -// line of it is within foldMargin of a change. A run does not hide if -// expanded returns true for it. -func foldHunk(h *udiff.Hunk, expanded func(Fold) bool) (hunks []*udiff.Hunk, folds []Fold) { +// keptLines reports, for each line of h, whether it is within foldMargin +// lines of a change. Such lines are never hidden. +func keptLines(h *hunk) []bool { n := len(h.Lines) keep := make([]bool, n) for i, l := range h.Lines { - if l.Kind != udiff.Equal { + if l.Kind != KindEqual { for j := max(i-foldMargin, 0); j <= min(i+foldMargin, n-1); j++ { keep[j] = true } } } - var cur *udiff.Hunk + return keep +} + +// hiddenFolds returns, in order, the runs of equal lines in h that are +// hidden. A run is hidden if it has more than one line, keep marks none of +// its lines, and expanded is nil or returns false for it. keep is +// keptLines(h). +func hiddenFolds(h *hunk, keep []bool, expanded func(Fold) bool) []Fold { + var hidden []Fold + oldNum, newNum := h.OldStart, h.NewStart + for i := 0; i < len(h.Lines); { + if keep[i] { + oldNum, newNum = h.Lines[i].next(oldNum, newNum) + i++ + continue + } + j := i + for j < len(h.Lines) && !keep[j] { + j++ + } + fold := Fold{Old: oldNum, New: newNum, Lines: j - i} + if fold.Lines > 1 && (expanded == nil || !expanded(fold)) { + hidden = append(hidden, fold) + } + // The run has only equal lines. + oldNum, newNum, i = oldNum+fold.Lines, newNum+fold.Lines, j + } + return hidden +} + +// foldHunk splits h into visible hunks around the hidden runs from +// hiddenFolds. folds[i] is the fold above hunks[i]. If the last run is +// hidden, the last hunk has no lines. keep is keptLines(h). +func foldHunk(h *hunk, keep []bool, hidden []Fold) (hunks []*hunk, folds []Fold) { + n := len(h.Lines) + var cur *hunk var above Fold - b, a := h.FromLine, h.ToLine + oldNum, newNum := h.OldStart, h.NewStart i := 0 + shown := 0 // lines before this index belong to a run that is shown for i < n { - if !keep[i] { + if !keep[i] && i >= shown { j := i for j < n && !keep[j] { j++ } - f := Fold{Before: b, After: a, Lines: j - i} - if f.Lines > 1 && (expanded == nil || !expanded(f)) { - above, cur = f, nil - b, a, i = b+f.Lines, a+f.Lines, j + fold := Fold{Old: oldNum, New: newNum, Lines: j - i} + if len(hidden) > 0 && hidden[0] == fold { + hidden = hidden[1:] + above, cur = fold, nil + oldNum, newNum, i = oldNum+fold.Lines, newNum+fold.Lines, j continue } - for k := i; k < j; k++ { - keep[k] = true // the run shows as a whole - } + shown = j // the whole run is shown } if cur == nil { - cur = &udiff.Hunk{FromLine: b, ToLine: a} + cur = &hunk{OldStart: oldNum, NewStart: newNum} hunks = append(hunks, cur) folds = append(folds, above) above = Fold{} } l := h.Lines[i] cur.Lines = append(cur.Lines, l) - if l.Kind != udiff.Insert { - b++ - } - if l.Kind != udiff.Delete { - a++ - } + oldNum, newNum = l.next(oldNum, newNum) i++ } if above.Lines > 0 { - hunks = append(hunks, &udiff.Hunk{FromLine: b, ToLine: a}) + hunks = append(hunks, &hunk{OldStart: oldNum, NewStart: newNum}) folds = append(folds, above) } return hunks, folds diff --git a/internal/diffview/highlight.go b/internal/diffview/highlight.go index fff826f..0d0f5a2 100644 --- a/internal/diffview/highlight.go +++ b/internal/diffview/highlight.go @@ -5,177 +5,563 @@ package diffview import ( "cmp" "image/color" - "io" + "slices" "strings" "sync" + "sync/atomic" + "time" "charm.land/lipgloss/v2" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/lexers" ) -// Cache keeps highlighted lines, so that chroma highlights each line once. -// Views that share a Cache reuse its lines and its last lexer. The zero -// value is an empty Cache. A Cache is not safe for concurrent use. +// Cache stores highlighted lines, the last lexer and the last diff for the +// views that share it. The zero value is an empty Cache. A Cache is not +// safe for concurrent use. type Cache struct { lines map[syntaxKey]string - size int // the bytes of the lines and their sources + size int // the total size in bytes of the lines and their sources - // lexer keeps the last lexer, because to select a lexer from the - // content is slow. + // lexer is the most recently used lexer. lexer struct { path, content string l chroma.Lexer } + + // diff is the most recent diff. gen counts the diffs computed, starting + // at 1. + diff struct { + oldText, newText string + hunks []*hunk + keep [][]bool // the keptLines of each hunk + gen int + } + folded foldedDiff // the most recent diff after folding } -// maxCacheSize limits the bytes of the lines and the sources in a Cache. -// If a put goes above this size, put empties the Cache first. -const maxCacheSize = 32 << 20 +// maxCacheBytes limits the total size of the lines and sources in a Cache. +// When a new line would exceed it, the Cache is emptied first. +const maxCacheBytes = 32 << 20 -// put keeps the highlighted line s for key k. -func (c *Cache) put(k syntaxKey, s string) { - // k.source is a part of the file string. A copy lets the cache keep - // the line without the whole file. - k.source = strings.Clone(k.source) - n := len(k.source) + len(s) - if c.lines == nil || c.size+n > maxCacheSize { +// put stores the highlighted line s under key. +func (c *Cache) put(key syntaxKey, s string) { + // key.source is a substring of the whole file. Copying it lets the + // cache keep the line without keeping the whole file in memory. + key.source = strings.Clone(key.source) + n := len(key.source) + len(s) + if c.lines == nil || c.size+n > maxCacheBytes { c.lines, c.size = make(map[syntaxKey]string), 0 } - c.lines[k] = s + c.lines[key] = s c.size += n } -// syntaxKey is the key of a [Cache]. It has the lexer, the source, and the -// background colour of the source. noColor is true for no background, because -// lipgloss.NoColor has the same RGBA value as black. +// syntaxKey is the key of a line in a [Cache]: the lexer, the source and +// the background colour. noColor is true when there is no background, +// because lipgloss.NoColor has the same RGBA value as black. type syntaxKey struct { lexer, source string noColor bool r, g, b, a uint32 } -// newSyntaxKey returns the key of source for the lexer named lexer, on -// bgColor. -func newSyntaxKey(lexer, source string, bgColor color.Color) syntaxKey { - k := syntaxKey{lexer: lexer, source: source} - if _, none := bgColor.(lipgloss.NoColor); bgColor == nil || none { - k.noColor = true - return k +// newSyntaxKey returns the key for source highlighted by the lexer named +// lexer on the background bg. +func newSyntaxKey(lexer, source string, bg color.Color) syntaxKey { + key := syntaxKey{lexer: lexer, source: source} + if _, none := bg.(lipgloss.NoColor); bg == nil || none { + key.noColor = true + return key } - k.r, k.g, k.b, k.a = bgColor.RGBA() - return k + key.r, key.g, key.b, key.a = bg.RGBA() + return key } -// highlightCode returns source with syntax colours on the background -// bgColor. The cache keeps the result. If chroma fails, highlightCode -// returns source as it is. -func (v *View) highlightCode(source string, bgColor color.Color) string { - // Chroma makes a lone CR a line break. Thus show each CR as a symbol - // first. - source = strings.ReplaceAll(source, "\r", "\u240d") +// Limits on syntax highlighting, because chroma's lexers take time that +// grows with the square of the length of a line, or worse. A line longer +// than maxHighlightLine bytes is drawn plain, with control characters +// escaped. A [Highlighter] lexes a file longer than maxHighlightFile bytes, or with +// a line longer than maxHighlightLine, one line at a time. +const ( + maxHighlightLine = 1500 + maxHighlightFile = 512 << 10 +) + +// Some lexers take far longer than the length of a line suggests on +// unusual text, such as a line of one repeated character, and the Changes +// tab highlights its lines while the screen is drawn. So highlighting a +// line on its own that is longer than budgetFreeLine bytes stops after +// lineBudget, and the line is drawn plain. Highlighting stops only between +// two tokens, so a line can take longer than lineBudget. A shorter line is +// always highlighted in full. +const ( + lineBudget = 5 * time.Millisecond + budgetFreeLine = 256 +) + +// highlightLine highlights source, a single line, with lexer, passing the +// text of each token through value. ok is false if the lexer fails or +// panics, or if source is longer than budgetFreeLine bytes and takes +// longer than lineBudget. +func highlightLine(lexer chroma.Lexer, source string, ts *tokenStyler, value func(string) string) (out string, ok bool) { + it, err := lexer.Tokenise(nil, source) + if err != nil { + return "", false + } + var deadline time.Time + if len(source) > budgetFreeLine { + deadline = time.Now().Add(lineBudget) + } + return renderTokens(it, ts, value, deadline) +} + +// slowLexers are the chroma lexers that are never used. On some short +// lines, Jungle and JSONata never stop, and the others take far longer +// than lineBudget for a single token, which lineBudget cannot cut short. Their files are drawn plain. +var slowLexers = []string{"Devicetree", "JSONata", "Jungle", "LilyPond", "Markless", "SYSTEMD"} + +// highlightCode returns source with syntax highlighting on the background +// bg, and stores the result in the cache. If source is longer than +// maxHighlightLine or chroma fails, highlightCode returns source escaped +// and without colours. +func (v *View) highlightCode(source string, bg color.Color) string { + // Chroma treats a lone CR as a line break. + source = strings.ReplaceAll(source, "\r", CRSymbol) + if len(source) > maxHighlightLine { + return Escape(source) + } lexer := v.chromaLexer() - key := newSyntaxKey(lexer.Config().Name, source, bgColor) + key := newSyntaxKey(lexer.Config().Name, source, bg) if cached, ok := v.cache.lines[key]; ok { return cached } - it, err := lexer.Tokenise(nil, source) - if err != nil { - return source - } - var b strings.Builder - f := ANSIFormatter(bgColor, processChromaValue) - if err := f.Format(&b, nil, it); err != nil { - return source + out, ok := highlightLine(lexer, source, newTokenStyler(bg), escapeChromaValue) + if !ok { + // Keep the plain line too, so that a line that was too slow once + // does not get its colours on a later draw and flicker. + out = Escape(source) } - out := b.String() v.cache.put(key, out) return out } -// processChromaValue escapes the text of a token for the terminal. A code -// line has no line break. Thus processChromaValue removes the trailing line -// breaks. -func processChromaValue(value string) string { +// renderTokens renders the tokens of it with ts, passing the text of each +// token through value first. Like [chroma.Coalesce], it joins adjacent +// tokens of the same type. ok is false if the lexer panics or the tokens +// are not all read by deadline, unless deadline is the zero time. it must +// come from a lexer that is not coalesced, because a coalesced lexer can +// take a long time to return a token, or never return one. +func renderTokens(it chroma.Iterator, ts *tokenStyler, value func(string) string, deadline time.Time) (out string, ok bool) { + defer func() { + if recover() != nil { + ok = false + } + }() + var b, run strings.Builder // run holds the text of the tokens of type t + var t chroma.TokenType + for token := it(); token != chroma.EOF; token = it() { + if !deadline.IsZero() && time.Now().After(deadline) { + return "", false + } + if token.Value == "" { + continue + } + if token.Type != t && run.Len() > 0 { + b.WriteString(ts.render(t, value(run.String()))) + run.Reset() + } + t = token.Type + run.WriteString(token.Value) + } + if run.Len() > 0 { + b.WriteString(ts.render(t, value(run.String()))) + } + return b.String(), true +} + +// escapeChromaValue escapes the text of a token for the terminal and +// removes its trailing line breaks. +func escapeChromaValue(value string) string { return Escape(strings.TrimRight(value, "\n")) } -// chromaLexer returns the lexer of the old file, or of the new file if -// the old file is empty, and keeps it in the cache. +// chromaLexer returns the lexer for the old file, or for the new file if +// the old file is empty, and stores it in the cache. func (v *View) chromaLexer() chroma.Lexer { lx := &v.cache.lexer - content := cmp.Or(v.before, v.after) + content := cmp.Or(v.oldText, v.newText) if lx.l == nil || lx.path != v.path || lx.content != content { lx.path, lx.content = v.path, content - lx.l = Lexer(lx.path, lx.content) + lx.l = fileLexer(lx.path, lx.content) } return lx.l } -// Lexer returns the coalesced lexer for the file at path. If no lexer -// matches the name, it selects one from the content. If no lexer matches -// the content, it returns the fallback lexer. -func Lexer(path, content string) chroma.Lexer { - // matchLexer caches the lexer, and the lexer is already coalesced. - if l := matchLexer(path); l != nil { - return l +// maxAnalyseBytes limits the content that fileLexer looks at, because the +// time that chroma takes to pick a lexer grows with the content. +const maxAnalyseBytes = 64 << 10 + +// fileLexer returns the chroma lexer for the file at path. If no lexer +// matches the file name, it picks one based on the start of content. If +// none matches the content either, or the lexer is one of slowLexers, it +// returns the fallback lexer. The lexer is not coalesced. +func fileLexer(path, content string) chroma.Lexer { + lexer := matchLexer(path) + if lexer == nil { + lexer = lexers.Analyse(content[:min(len(content), maxAnalyseBytes)]) } - l := lexers.Analyse(content) - if l == nil { - l = lexers.Fallback + if lexer == nil || slices.Contains(slowLexers, lexer.Config().Name) { + lexer = lexers.Fallback } - return chroma.Coalesce(l) + return lexer } -// lexerCache keeps the lexer of each file name, or nil if none matches. To -// match a name is slow, because lexers.Match tests the patterns of each -// lexer. The mutex makes the cache safe for more than one goroutine. +// lexerCache maps each file name to its lexer, or to nil if none matches. +// lexerCacheMu guards it. var ( lexerCacheMu sync.RWMutex lexerCache = map[string]chroma.Lexer{} ) -// matchLexer returns the coalesced chroma lexer for the file name, or nil if -// no lexer matches. The lexer is safe to use again. +// matchLexer returns the chroma lexer for fileName, or nil if no lexer +// matches. It stores the result in lexerCache. func matchLexer(fileName string) chroma.Lexer { lexerCacheMu.RLock() - l, ok := lexerCache[fileName] + lexer, ok := lexerCache[fileName] lexerCacheMu.RUnlock() if ok { - return l - } - if l = lexers.Match(fileName); l != nil { - l = chroma.Coalesce(l) + return lexer } + lexer = lexers.Match(fileName) lexerCacheMu.Lock() - lexerCache[fileName] = l + lexerCache[fileName] = lexer lexerCacheMu.Unlock() - return l + return lexer +} + +// tokenStyler colours chroma tokens with the terminal's ANSI palette on a +// background, ignoring the chroma style. It renders each style once and +// reuses its escape codes around the text of each token. A tokenStyler is +// not safe for concurrent use. +type tokenStyler struct { + plain lipgloss.Style + parts map[chroma.TokenType][2]string // the escape codes before and after the text +} + +// newTokenStyler returns a tokenStyler for the background bg. +func newTokenStyler(bg color.Color) *tokenStyler { + return &tokenStyler{ + plain: lipgloss.NewStyle().Background(bg), + parts: map[chroma.TokenType][2]string{}, + } +} + +// style returns the style for a token of type t. +func (ts *tokenStyler) style(t chroma.TokenType) lipgloss.Style { + if c, italic, ok := ansiFor(t); ok { + return ts.plain.Foreground(lipgloss.Color(c)).Italic(italic) + } + return ts.plain +} + +// render returns the same result as renderLines(ts.style(t), value), but +// faster. +func (ts *tokenStyler) render(t chroma.TokenType, value string) string { + if strings.ContainsAny(value, "\t\r") { + return renderLines(ts.style(t), value) // the style changes tabs and CRs + } + p, ok := ts.parts[t] + if !ok { + // Escape codes contain only digits and punctuation, so they cannot + // contain X. + before, after, _ := strings.Cut(ts.style(t).Render("X"), "X") + p = [2]string{before, after} + ts.parts[t] = p + } + if p == [2]string{} { + return value + } + // Style each line on its own, so that a line shown alone keeps its + // colour. + return p[0] + strings.ReplaceAll(value, "\n", p[1]+"\n"+p[0]) + p[1] +} + +// Highlighter highlights a whole source with the same colours as a +// [View], so tokens that span several lines get the correct colour. A +// background goroutine highlights the source until it reaches the end or +// [Highlighter.Stop] is called. A source longer than maxHighlightFile, or +// with a line longer than maxHighlightLine, is highlighted one line at a +// time instead, and its long lines are plain. Lexing a source takes at +// most lexBudget in all, and the lines that it has not reached by then +// are plain. [Highlighter.Line] waits a little for its line, and returns +// the line plain if it is not ready by then. While [Highlighter.Busy] +// reports true, such lines can get their colours, so the screen should be +// drawn again. The methods are safe for concurrent use. +type Highlighter struct { + mu sync.Mutex + grew chan struct{} // closed, and replaced, when lexing adds lines or ends + lines []string // the lines highlighted so far + done bool // lines holds every line + stop atomic.Bool // Stop has asked the background goroutine to end + running bool // the background goroutine is running + // Line has given up waiting for line lateUntil-1, so it does not wait + // again until lexing gets past that line. + lateUntil int + + // plain holds the lines of the source, for a line that is not ready + // and for lexing line by line. + plain []string + fallback func(string) string + + // The lexer state, used only by the background goroutine. + lexer chroma.Lexer // the lexer for each line, or nil to lex the whole source + it chroma.Iterator // the tokens of the whole source, not coalesced + ts *tokenStyler + process func(string) string + cur strings.Builder // the start of the line being highlighted + run strings.Builder // the text of the tokens of type runType not yet rendered + runType chroma.TokenType + src string // the source, used by fallback after a panic + spent time.Duration // the time spent lexing so far } -// ANSIFormatter returns a chroma formatter that colours the tokens with the -// ANSI palette colours, on the background bgColor. It ignores the chroma -// style. It calls processValue on the text of each token first. -func ANSIFormatter(bgColor color.Color, processValue func(string) string) chroma.Formatter { - format := func(w io.Writer, _ *chroma.Style, it chroma.Iterator) error { - plain := lipgloss.NewStyle().Background(bgColor) - for token := it(); token != chroma.EOF; token = it() { - value := processValue(token.Value) - s := plain - if c, italic, ok := ansiFor(token.Type); ok { - s = plain.Foreground(lipgloss.Color(c)).Italic(italic) - } - if _, err := io.WriteString(w, renderLines(s, value)); err != nil { - return err - } +// lexBatch is the number of lines that lexAll adds at a time. +const lexBatch = 256 + +// lexBudget limits the time that a Highlighter spends lexing its source, +// because some lexers take far longer on some text than its size +// suggests. A typical file of maxHighlightFile bytes takes far less. +const lexBudget = 2 * time.Second + +// lineWait is how long Line waits for the background goroutine to reach +// its line. It is short, because Line runs while the screen is drawn and +// so holds up the next key press. The first lexBatch lines of a typical +// file are ready well within it. +const lineWait = 25 * time.Millisecond + +// NewHighlighter returns a Highlighter for src, the content of the file at +// path, on the background bg, and starts its background goroutine. It +// passes the text of each token through processValue. If chroma cannot +// lex src, the lines are those of fallback(src). If the lexer panics, the +// lines from the panic onwards are those of fallback(src). A line that is +// not highlighted is fallback(line). +func NewHighlighter(path, src string, bg color.Color, processValue, fallback func(string) string) *Highlighter { + h := &Highlighter{grew: make(chan struct{}), plain: strings.Split(src, "\n"), fallback: fallback} + h.ts, h.process = newTokenStyler(bg), processValue + lexer := fileLexer(path, src) + if len(src) > maxHighlightFile || slices.ContainsFunc(h.plain, func(l string) bool { return len(l) > maxHighlightLine }) { + h.lexer = lexer + } else { + // The whole source is lexed in the background, so a slow token + // does not hold up the screen. The lexer is not coalesced, so that + // Stop and lexBudget take effect between any two tokens. + it, err := lexer.Tokenise(nil, src) + if err != nil { + h.lines, h.done = strings.Split(fallback(src), "\n"), true + return h } - return nil + h.it, h.src = it, src } - return chroma.FormatterFunc(format) + h.start() + return h +} + +// start starts the background goroutine. h.mu must be held, or h must not +// yet be shared. +func (h *Highlighter) start() { + h.running = true + h.stop.Store(false) + go h.lexAll(len(h.lines)) } -// ansiFor returns the ANSI palette colour of a token type. If ok is false, -// the token has the default foreground of the terminal. +// lexAll highlights the source from line n until it reaches the end or +// Stop is called. It runs in its own goroutine. +func (h *Highlighter) lexAll(n int) { + for { + lines, eof := h.lex(n, lexBatch) + n += len(lines) + h.mu.Lock() + h.lines = append(h.lines, lines...) + h.done = eof + stop := eof || h.stop.Load() + if stop { + h.running = false + } + close(h.grew) + h.grew = make(chan struct{}) + h.mu.Unlock() + if stop { + return + } + } +} + +// lex highlights up to n more lines and returns them. done is the number +// of lines already highlighted. eof is true if lex reached the end of the +// source, and then the last line returned holds the rest of the source. +// lex returns early, with the lines so far, if Stop is called. Once lexing +// has taken lexBudget, the remaining lines are plain. +func (h *Highlighter) lex(done, n int) (lines []string, eof bool) { + start := time.Now() + defer func() { h.spent += time.Since(start) }() + overBudget := func() bool { return h.spent+time.Since(start) > lexBudget } + if h.lexer != nil { + return h.lexLines(done, n, overBudget) + } + return h.lexTokens(done, n, overBudget) +} + +// lexLines is lex for a source that is highlighted one line at a time. +func (h *Highlighter) lexLines(done, n int, overBudget func() bool) (lines []string, eof bool) { + end := min(done+n, len(h.plain)) + for _, l := range h.plain[done:end] { + switch { + case h.stop.Load(): + return lines, false + case overBudget(): + lines = append(lines, h.fallback(l)) + default: + lines = append(lines, h.lexLine(l)) + } + } + return lines, end == len(h.plain) +} + +// lexTokens is lex for a source that is highlighted as a whole. It reads +// tokens until it has n more lines. +func (h *Highlighter) lexTokens(done, n int, overBudget func() bool) (lines []string, eof bool) { + for len(lines) < n { + if h.stop.Load() { + return lines, false + } + token, ok := h.next() + if !ok || overBudget() { + return append(lines, h.plainFrom(done+len(lines))...), true + } + if token.Value == "" && token != chroma.EOF { + continue + } + if h.run.Len() > 0 && (token.Type != h.runType || token == chroma.EOF) { + // Like chroma.Coalesce, render adjacent tokens of the same + // type together. + lines = h.addRun(lines) + } + if token == chroma.EOF { + lines = append(lines, h.cur.String()) + h.it, h.cur, h.src = nil, strings.Builder{}, "" + return lines, true + } + h.runType = token.Type + h.run.WriteString(token.Value) + } + return lines, false +} + +// addRun renders the tokens in h.run, appends the lines that they end to +// lines, and keeps the start of the next line in h.cur. +func (h *Highlighter) addRun(lines []string) []string { + s := h.ts.render(h.runType, h.process(h.run.String())) + h.run.Reset() + for { + before, after, found := strings.Cut(s, "\n") + h.cur.WriteString(before) + if !found { + return lines + } + lines = append(lines, h.cur.String()) + h.cur.Reset() + s = after + } +} + +// plainFrom returns the lines of the source from the 0-based line i on, +// as fallback gives them, and ends lexing. +func (h *Highlighter) plainFrom(i int) []string { + all := strings.Split(h.fallback(h.src), "\n") + h.it, h.cur, h.run, h.src = nil, strings.Builder{}, strings.Builder{}, "" + return all[min(i, len(all)):] +} + +// lexLine highlights the source line l on its own. It returns fallback(l) +// if l is longer than maxHighlightLine or the lexer fails. +func (h *Highlighter) lexLine(l string) string { + if len(l) <= maxHighlightLine { + value := func(v string) string { return h.process(strings.TrimRight(v, "\n")) } + if out, ok := highlightLine(h.lexer, l, h.ts, value); ok { + return out + } + } + return h.fallback(l) +} + +// next returns the next token. If the lexer panics, next recovers and +// returns ok false. +func (h *Highlighter) next() (token chroma.Token, ok bool) { + defer func() { + if recover() != nil { + ok = false + } + }() + return h.it(), true +} + +// Line returns the 0-based line i of the highlighted output, as +// strings.Split would split the whole output. ok is false if i is past the +// last line. If the background goroutine has not reached the line within +// lineWait, Line returns it plain, with final false, and does not wait +// again until the goroutine has passed it. A later call returns the line +// highlighted. After Stop, Line starts the goroutine again. +func (h *Highlighter) Line(i int) (line string, ok, final bool) { + h.mu.Lock() + defer h.mu.Unlock() + if len(h.lines) <= i && !h.done && !h.running { + h.start() + } + var timeout <-chan time.Time + for len(h.lines) <= i && !h.done && len(h.lines) >= h.lateUntil { + if timeout == nil { + t := time.NewTimer(lineWait) + defer t.Stop() + timeout = t.C + } + grew := h.grew + h.mu.Unlock() + select { + case <-grew: + h.mu.Lock() + case <-timeout: + h.mu.Lock() + h.lateUntil = i + 1 + } + } + switch { + case i < len(h.lines): + return h.lines[i], true, true + case h.done: + return "", false, true + } + h.lateUntil = max(h.lateUntil, i+1) + if i < len(h.plain) { + return h.fallback(h.plain[i]), true, false + } + return "", false, false +} + +// Busy reports whether the background goroutine is still highlighting, +// so that lines that Line returned plain can get their colours. +func (h *Highlighter) Busy() bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.running && !h.done +} + +// Stop ends the background goroutine after its current token or line. A +// later call to Line starts it again. +func (h *Highlighter) Stop() { h.stop.Store(true) } + +// ansiFor returns the ANSI palette colour for a token type. If ok is +// false, the token uses the terminal's default foreground. func ansiFor(t chroma.TokenType) (c string, italic, ok bool) { switch { case t.InCategory(chroma.Comment): @@ -196,18 +582,10 @@ func ansiFor(t chroma.TokenType) (c string, italic, ok bool) { return "", false, false } -// renderLines styles each line of value alone, and joins them with plain -// newlines. A token can contain newlines. For example, a chroma -// CommentSingle ends with one. Lip Gloss pads all lines of a multi-line -// string to one width. Without renderLines, this padding adds spaces to -// the line after a comment. +// renderLines styles each line of value separately and joins them with +// plain newlines. This avoids the padding that Lip Gloss adds to a +// multi-line string, for example a chroma CommentSingle token, which ends +// with a newline. func renderLines(s lipgloss.Style, value string) string { - if !strings.Contains(value, "\n") { - return s.Render(value) - } - lines := strings.Split(value, "\n") - for i, line := range lines { - lines[i] = s.Render(line) - } - return strings.Join(lines, "\n") + return EachLine(value, func(l string) string { return s.Render(l) }) } diff --git a/internal/diffview/norace_test.go b/internal/diffview/norace_test.go new file mode 100644 index 0000000..69e4014 --- /dev/null +++ b/internal/diffview/norace_test.go @@ -0,0 +1,6 @@ +//go:build !race + +package diffview_test + +// raceOn reports whether the tests run with the race detector. +const raceOn = false diff --git a/internal/diffview/race_test.go b/internal/diffview/race_test.go new file mode 100644 index 0000000..fc1ae21 --- /dev/null +++ b/internal/diffview/race_test.go @@ -0,0 +1,6 @@ +//go:build race + +package diffview_test + +// raceOn reports whether the tests run with the race detector. +const raceOn = true diff --git a/internal/diffview/render.go b/internal/diffview/render.go index 1f8e247..0ee1424 100644 --- a/internal/diffview/render.go +++ b/internal/diffview/render.go @@ -5,173 +5,296 @@ package diffview import ( "fmt" "slices" + "strconv" "strings" + "unicode/utf8" "charm.land/lipgloss/v2" - "github.com/aymanbagabas/go-udiff" "github.com/charmbracelet/x/ansi" ) const ( - leadingSymbolsSize = 2 // the width of a symbol, such as symInsert - lineNumPadding = 1 // the space on each side of a line number - ellipsis = "…" // shows where a line is cut + leadingMarksWidth = 2 // the width of a mark, such as markAdded + lineNumPadding = 1 // the spaces on each side of a line number + ellipsis = "…" // marks where a line is cut off ) -// These symbols are at the start of the code of a line. A bar in the colour -// of the line marks each changed line. [View.Signs] shows + and - in place -// of the bar. +// The marks shown before the code on each line. A changed line has a bar +// in its colour, or + or - with [View.Signs]. const ( - symNone = " " // an equal line, a missing line, or a continued line - symBar = "▌ " - symInsert = "+ " - symDelete = "- " + markNone = " " // an equal line, a missing line, or a continued line + markBar = "▎ " + markAdded = "+ " + markDeleted = "- " ) -// RowKind is the type of a rendered row. -type RowKind int +// Kind is the kind of a rendered row. +type Kind int -// The values of RowKind. +// The kinds of row. const ( - RowHunk RowKind = iota // a hunk header - RowEqual // a line that is the same in the two versions - RowInsert // an inserted line - RowDelete // a deleted line - RowChange // a deleted line beside an inserted line (split) + KindHunk Kind = iota // a hunk header, with no source line + KindEqual // a line that is the same on both sides + KindAdded // a line that exists only on the new side + KindDeleted // a line that exists only on the old side + KindChanged // a deleted line next to an added line, in split view ) -// Row tells which source lines one rendered line shows. Before and After are -// line numbers from 1, or 0 if the row has no line on that side. Continued -// is true for the second and the next screen lines of a wrapped line. -// For a hunk header, Fold is the run of lines that hides above the header. +// Side identifies one version of a file. +type Side int + +// The two sides of a file. +const ( + SideOld Side = iota // the version before the change + SideNew // the version after the change +) + +// Row describes the source lines that one rendered line shows. Old and New +// are 1-based line numbers, or 0 if the row has no line on that side. +// Continued is true if the row continues the line above it because of +// wrapping. For a hunk header, Fold is the run of hidden equal lines above +// the header. type Row struct { - Kind RowKind - Before, After int - Continued bool - Fold Fold + Kind Kind + Old, New int + Continued bool + Fold Fold } -// renderUnified renders the unified layout. Each line has the old and the -// new line numbers, then the code. -func (v *View) renderUnified() string { - var b strings.Builder - nums := func(ls lineStyle, before, after any) string { - return lineNum(ls, before, v.beforeNumDigits) + - lineNum(ls, after, v.afterNumDigits) +// Line returns the row's line number on the given side. +func (r Row) Line(side Side) int { + if side == SideOld { + return r.Old } + return r.New +} + +// item is one row of the diff before it is rendered to screen lines: a +// hunk header, a line in the unified layout, or a pair of lines in the +// split layout. +type item struct { + row Row + header int // the hunk index for a header, or -1 + line *diffLine // the line, in the unified layout + pair splitLine // the pair of lines, in the split layout + oldNum, newNum int // the line numbers of the line or pair +} + +// items returns the items of the layout in order: each hunk's header, +// followed by its lines. +func (v *View) items() []item { + n := len(v.hunks) // one header per hunk, and at most one item per line + for _, h := range v.hunks { + n += len(h.Lines) + } + out := make([]item, 0, n) for i, h := range v.hunks { - v.writeHeader(&b, i, v.unifiedHeader(i)) - oldN, newN := h.FromLine, h.ToLine - for _, l := range h.Lines { - ls, sym := v.kindStyle(l.Kind) - var r Row - var num string + out = append(out, item{row: Row{Kind: KindHunk, Fold: v.folds[i]}, header: i}) + oldNum, newNum := h.OldStart, h.NewStart + if v.split { + for _, l := range hunkToSplit(h) { + it := item{row: Row{Kind: splitKind(l)}, header: -1, pair: l, oldNum: oldNum, newNum: newNum} + if l.old != nil { + it.row.Old = oldNum + oldNum++ + } + if l.new != nil { + it.row.New = newNum + newNum++ + } + out = append(out, it) + } + continue + } + for k := range h.Lines { + l := &h.Lines[k] + it := item{header: -1, line: l, oldNum: oldNum, newNum: newNum} switch l.Kind { - case udiff.Equal: - r = Row{Kind: RowEqual, Before: oldN, After: newN} - num = nums(ls, oldN, newN) - oldN++ - newN++ - case udiff.Insert: - r = Row{Kind: RowInsert, After: newN} - num = nums(ls, " ", newN) - newN++ - case udiff.Delete: - r = Row{Kind: RowDelete, Before: oldN} - num = nums(ls, oldN, " ") - oldN++ + case KindEqual: + it.row = Row{Kind: KindEqual, Old: oldNum, New: newNum} + oldNum++ + newNum++ + case KindAdded: + it.row = Row{Kind: KindAdded, New: newNum} + newNum++ + case KindDeleted: + it.row = Row{Kind: KindDeleted, Old: oldNum} + oldNum++ } - blank := nums(ls, " ", " ") - v.writeRow(&b, r, v.codeLines(ls, num, blank, sym, l.Content, v.codeWidth)) + out = append(out, it) } } - return b.String() + return out +} + +// itemLines renders it as screen lines. +func (v *View) itemLines(it *item) []string { + switch { + case it.header >= 0 && v.split: + return []string{v.splitHeader(it.header)} + case it.header >= 0: + return []string{v.unifiedHeader(it.header)} + case v.split: + return v.splitLines(it) + } + return v.unifiedLines(it) +} + +// unifiedLines renders the line of it in the unified layout. Each screen +// line has the old and new line numbers, followed by the code. +func (v *View) unifiedLines(it *item) []string { + nums := func(ls lineStyle, oldNum, newNum string) string { + return lineNum(ls, oldNum, v.oldNumDigits) + + lineNum(ls, newNum, v.newNumDigits) + } + l := it.line + ls, mark := v.kindStyle(l.Kind) + var num string + switch l.Kind { + case KindEqual: + num = nums(ls, strconv.Itoa(it.oldNum), strconv.Itoa(it.newNum)) + case KindAdded: + num = nums(ls, " ", strconv.Itoa(it.newNum)) + case KindDeleted: + num = nums(ls, strconv.Itoa(it.oldNum), " ") + } + blank := nums(ls, " ", " ") + return v.codeLines(ls, num, blank, mark, l.Content, v.codeWidth) +} + +// splitLines renders the pair of lines of it in the split layout, with the +// old line on the left and the new line on the right. +func (v *View) splitLines(it *item) []string { + l := it.pair + left, leftFill := v.splitSide(l.old, it.oldNum, v.oldNumDigits, v.codeWidth) + right, rightFill := v.splitSide(l.new, it.newNum, v.newNumDigits, v.newWidth()) + n := max(len(left), len(right)) + left, right = fillTo(left, n, leftFill), fillTo(right, n, rightFill) + lines := make([]string, n) + for k := range n { + lines[k] = left[k] + right[k] + } + return lines +} + +// newWidth returns the code width of the new side in the split layout. +func (v *View) newWidth() int { return v.codeWidth + btoi(v.extraColOnNew) } + +// itemHeight returns the number of screen lines that itemLines renders +// for it. It does not render lines that fit in the width. +func (v *View) itemHeight(it *item) int { + switch { + case it.header >= 0 || !v.wrap: + return 1 + case v.split: + return max(v.partCount(it.pair.old, v.codeWidth), v.partCount(it.pair.new, v.newWidth())) + } + return v.partCount(it.line, v.codeWidth) +} + +// partCount returns the number of screen lines that line l needs when +// wrapped to width cells. A nil l needs one line. +func (v *View) partCount(l *diffLine, width int) int { + if l == nil { + return 1 + } + content := strings.TrimSuffix(l.Content, "\n") + if Fits(content, width) { + return 1 + } + // codeLines breaks the line where WrapParts breaks its plain text, so + // the count needs no highlighting. + return len(WrapParts(plainCode(content), width)) +} + +// plainCode returns the code line content escaped and without colours, as +// highlightCode returns it when it cannot highlight. +func plainCode(content string) string { return Escape(strings.ReplaceAll(content, "\r", CRSymbol)) } + +// Fits reports whether line s, after escaping, fits in width cells both +// with and without colours. It returns false if any grapheme cluster has +// more than one rune, because colour codes between the runes can change +// its width. +func Fits(s string, width int) bool { + width = max(width, 1) + if len(s) <= width && isPrintableASCII(s) { + return true + } + s, w := Escape(s), 0 + for s != "" { + c, cw := ansi.FirstGraphemeCluster(s, ansi.GraphemeWidth) + if w += cw; w > width || utf8.RuneCountInString(c) > 1 { + return false + } + s = s[len(c):] + } + return true +} + +// isPrintableASCII reports whether s contains only printable ASCII +// characters. Each byte of such a string is one cell wide, and [Escape] +// leaves it unchanged. +func isPrintableASCII(s string) bool { + for i := range len(s) { + if s[i] < ' ' || s[i] > '~' { + return false + } + } + return true } // unifiedHeader renders the divider line of hunk i in the unified layout. func (v *View) unifiedHeader(i int) string { div := v.style.dividerLine header := ansi.Truncate(v.headerText(i), v.fullCodeWidth, ellipsis) - return div.lineNumber.Render(pad(ellipsis, v.beforeNumDigits)) + - div.lineNumber.Render(pad(ellipsis, v.afterNumDigits)) + + return div.num.Render(pad(ellipsis, v.oldNumDigits)) + + div.num.Render(pad(ellipsis, v.newNumDigits)) + div.code.Width(v.fullCodeWidth).Render(header) } -// renderSplit renders the split layout. The old lines are on the left and -// the new lines are on the right. -func (v *View) renderSplit() string { - var b strings.Builder - afterWidth := v.codeWidth + btoi(v.extraColOnAfter) - for i, h := range v.hunks { - v.writeHeader(&b, i, v.splitHeader(i)) - oldN, newN := h.FromLine, h.ToLine - for _, l := range hunkToSplit(h) { - r := Row{Kind: splitKind(l)} - left, leftFill := v.splitSide(l.before, oldN, v.beforeNumDigits, v.codeWidth) - right, rightFill := v.splitSide(l.after, newN, v.afterNumDigits, afterWidth) - if l.before != nil { - r.Before = oldN - oldN++ - } - if l.after != nil { - r.After = newN - newN++ - } - n := max(len(left), len(right)) - left, right = fillTo(left, n, leftFill), fillTo(right, n, rightFill) - lines := make([]string, n) - for k := range n { - lines[k] = left[k] + right[k] - } - v.writeRow(&b, r, lines) - } - } - return b.String() -} - // splitHeader renders the divider line of hunk i in the split layout. The -// header starts on the left side and can go on across the right side. +// header starts on the left side and can extend across the right side. func (v *View) splitHeader(i int) string { div := v.style.dividerLine - // The header covers the two code columns and the new line numbers. - width := 2*v.fullCodeWidth + btoi(v.extraColOnAfter) + v.afterNumDigits + lineNumPadding*2 - return div.lineNumber.Render(pad(ellipsis, v.beforeNumDigits)) + + // The header spans both code columns and the new line numbers. + width := 2*v.fullCodeWidth + btoi(v.extraColOnNew) + v.newNumDigits + lineNumPadding*2 + return div.num.Render(pad(ellipsis, v.oldNumDigits)) + div.code.Width(width).Render(ansi.Truncate(v.headerText(i), width, ellipsis)) } -// splitSide renders line l on one side of the split layout, one string for -// each screen line. l is nil if that side has no line. n is the line number -// of l. The fill function renders an empty screen line. Use fill when the -// other side has more screen lines. -func (v *View) splitSide(l *udiff.Line, n, digits, width int) (lines []string, fill func() string) { - ls, sym, content := v.style.missingLine, symNone, "" +// splitSide renders line l, with line number n, on one side of the split +// layout, returning one string per screen line. A nil l means the line is +// missing on this side. fill renders an empty screen line, used when the +// other side has more lines. +func (v *View) splitSide(l *diffLine, n, digits, width int) (lines []string, fill func() string) { + ls, mark, content := v.style.missingLine, markNone, "" if l != nil { - ls, sym = v.kindStyle(l.Kind) + ls, mark = v.kindStyle(l.Kind) content = l.Content } noNum := lineNum(ls, " ", digits) num := noNum if l != nil { - num = lineNum(ls, n, digits) + num = lineNum(ls, strconv.Itoa(n), digits) } - fill = func() string { return v.codeLines(ls, noNum, "", symNone, "", width)[0] } - return v.codeLines(ls, num, noNum, sym, content, width), fill + fill = func() string { return v.codeLines(ls, noNum, "", markNone, "", width)[0] } + return v.codeLines(ls, num, noNum, mark, content, width), fill } -// splitKind returns the kind of the row of l in the split layout. -func splitKind(l splitLine) RowKind { +// splitKind returns the row kind of l in the split layout. +func splitKind(l splitLine) Kind { switch { - case l.before != nil && l.after != nil && l.before.Kind == udiff.Equal: - return RowEqual - case l.before != nil && l.after != nil: - return RowChange - case l.before != nil: - return RowDelete + case l.old != nil && l.new != nil && l.old.Kind == KindEqual: + return KindEqual + case l.old != nil && l.new != nil: + return KindChanged + case l.old != nil: + return KindDeleted } - return RowInsert + return KindAdded } -// fillTo adds the line that fill renders to lines, until lines has n lines. +// fillTo appends lines rendered by fill until lines has n lines. func fillTo(lines []string, n int, fill func() string) []string { if len(lines) >= n { return lines @@ -179,87 +302,172 @@ func fillTo(lines []string, n int, fill func() string) []string { return append(lines, slices.Repeat([]string{fill()}, n-len(lines))...) } -// kindStyle returns the style and the symbol of a line of kind k. -func (v *View) kindStyle(k udiff.OpKind) (lineStyle, string) { +// kindStyle returns the style and mark for a line of kind k. +func (v *View) kindStyle(k Kind) (lineStyle, string) { switch k { - case udiff.Insert: - return v.style.insertLine, v.mark(symInsert) - case udiff.Delete: - return v.style.deleteLine, v.mark(symDelete) + case KindAdded: + return v.style.addedLine, v.mark(markAdded) + case KindDeleted: + return v.style.deletedLine, v.mark(markDeleted) } - return v.style.equalLine, symNone + return v.style.equalLine, markNone } -// mark returns sign if the View shows signs, else the bar. +// mark returns sign if v shows signs, and the bar otherwise. func (v *View) mark(sign string) string { if v.signs { return sign } - return symBar -} - -// writeHeader writes header, the divider line of hunk i, to b, with its -// row. -func (v *View) writeHeader(b *strings.Builder, i int, header string) { - v.writeRow(b, Row{Kind: RowHunk, Fold: v.folds[i]}, []string{header}) -} - -// writeRow writes the screen lines of one row to b. It adds r to the rows -// once for each screen line. -func (v *View) writeRow(b *strings.Builder, r Row, lines []string) { - for k, l := range lines { - b.WriteString(l) - b.WriteByte('\n') - r.Continued = k > 0 - v.rows = append(v.rows, r) - } + return markBar } -// lineNum renders the line number n in a column of digits cells. -func lineNum(ls lineStyle, n any, digits int) string { - return ls.lineNumber.Render(pad(n, digits)) +// lineNum renders the line number n in a column that is digits cells wide. +func lineNum(ls lineStyle, n string, digits int) string { + return ls.num.Render(pad(n, digits)) } -// codeLines renders one source line with nums, sym, and the code in width -// cells. It cuts a long line. With wrap, the line continues on more screen -// lines, which show blank in place of nums and no sym. -func (v *View) codeLines(ls lineStyle, nums, blank, sym, content string, width int) []string { - code := v.highlightCode(strings.TrimSuffix(content, "\n"), ls.code.GetBackground()) +// codeLines renders one source line as nums, then mark, then the code in +// width cells. Without wrapping, it cuts off a long line. With wrapping, +// the line continues on more screen lines, which show blank instead of +// nums and no mark. +func (v *View) codeLines(ls lineStyle, nums, blank, mark, content string, width int) []string { + content = strings.TrimSuffix(content, "\n") + code := v.highlightCode(content, ls.code.GetBackground()) parts := []string{code} if v.wrap { - // Wrap can keep a leading space on a full line, so cut again. + if !Fits(content, width) { + parts = WrapLine(plainCode(content), code, width) + } w := max(width, 1) - parts = strings.Split(ansi.Hardwrap(lipgloss.Wrap(code, w, ""), w, true), "\n") for i, p := range parts { - // A wide character can be wider than w. + // A single wide character can be wider than w. parts[i] = ansi.Truncate(p, w, "") } } else { - code = CutLeft(code, v.xOffset) - parts[0] = ansi.Truncate(code, width, ellipsis) - if v.xOffset > 0 && strings.TrimSpace(parts[0]) != "" { - sym = strings.TrimSuffix(sym, " ") + ellipsis + parts[0] = CutLeft(code, v.scrollX) + if v.width > 0 { + parts[0] = ansi.Truncate(parts[0], width, ellipsis) + } + if v.scrollX > 0 && strings.TrimSpace(parts[0]) != "" { + mark = strings.TrimSuffix(mark, " ") + ellipsis } } - full := lipgloss.NewStyle().MaxWidth(width + leadingSymbolsSize) + full := lipgloss.NewStyle().MaxWidth(width + leadingMarksWidth) lines := make([]string, len(parts)) for i, p := range parts { if i > 0 { - nums, sym = blank, symNone + nums, mark = blank, markNone + } + if v.width <= 0 && !v.wrap { + // With no width limit nothing is cut. Colours can make a + // cluster of runes measure wider than its plain text, so the + // line is padded, not cut, to width. + pad := strings.Repeat(" ", max(width-ansi.StringWidth(p), 0)) + lines[i] = nums + ls.mark.Render(mark) + ls.code.Render(p+pad) + continue } - lines[i] = nums + full.Render(ls.symbol.Render(sym)+ls.code.Width(width).Render(p)) + lines[i] = nums + full.Render(ls.mark.Render(mark)+ls.code.Width(width).Render(p)) } return lines } -// headerText returns the text of the header of hunk i, for example -// " ⋯ 12 unmodified lines @@ -15,7 +15,8 @@ ". The text has the fold -// above the hunk if lines hide. It has the hunk header if the hunk has -// lines. The text ends with a space, as the hunk header does. +// WrapLine wraps a line to width cells and returns the screen lines. +// plain is the text of the line, escaped, and code is the same text with +// colours. The lines break where [WrapParts] breaks plain, so their number +// does not depend on the colours, which can change the width of a +// grapheme cluster of several runes. If code does not hold the text of +// plain with only escape sequences added, the lines have no colours. +func WrapLine(plain, code string, width int) []string { + parts := WrapParts(plain, width) + if code != plain { + if coloured, ok := colourParts(plain, code, parts); ok { + return coloured + } + } + return parts +} + +// colourParts returns parts, the screen lines that wrapping took from +// plain in order, with the colours that code gives their text. Each line +// starts with the escape sequences in effect at its first character, and +// ends with a reset if any are in effect at its end. ok is false if code +// is not plain with escape sequences added. +func colourParts(plain, code string, parts []string) (out []string, ok bool) { + pos, i := 0, 0 // the bytes of plain and code read so far + var sgr string // the escape sequences since the last reset + // escapes reads the escape sequences at code[i:], and writes them to b + // if b is not nil. + escapes := func(b *strings.Builder) { + for i < len(code) && code[i] == ansi.ESC { + seq, _, n, _ := ansi.DecodeSequence(code[i:], ansi.NormalState, nil) + if seq == "\x1b[m" || seq == "\x1b[0m" { + sgr = "" + } else { + sgr += seq + } + if b != nil { + b.WriteString(seq) + } + i += n + } + } + // text reads the next byte of plain from code, writing it to b if b + // is not nil. It reports whether code has that byte there. + text := func(b *strings.Builder) bool { + escapes(b) + if i >= len(code) || code[i] != plain[pos] { + return false + } + if b != nil { + b.WriteByte(code[i]) + } + i, pos = i+1, pos+1 + return true + } + out = make([]string, len(parts)) + for k, part := range parts { + start := strings.Index(plain[pos:], part) + if start < 0 { + return nil, false + } + for end := pos + start; pos < end; { // the text that wrapping dropped + if !text(nil) { + return nil, false + } + } + escapes(nil) + var b strings.Builder + b.WriteString(sgr) + for end := pos + len(part); pos < end; { + if !text(&b) { + return nil, false + } + } + if sgr != "" { + b.WriteString("\x1b[m") + } + out[k] = b.String() + } + return out, true +} + +// WrapParts wraps the highlighted code to width cells and returns the +// screen lines. +func WrapParts(code string, width int) []string { + // lipgloss.Wrap can leave a leading space on a full line, so hard-wrap + // the result again. + w := max(width, 1) + return strings.Split(ansi.Hardwrap(lipgloss.Wrap(code, w, ""), w, true), "\n") +} + +// headerText returns the header text of hunk i, for example +// " ⋯ 12 unmodified lines @@ -15,7 +15,8 @@ ". It includes the size of +// the fold above the hunk if the fold hides lines, and the hunk range if +// the hunk has lines. It ends with a space. func (v *View) headerText(i int) string { h, s := v.hunks[i], "" - if f := v.folds[i]; f.Lines > 0 { - s = fmt.Sprintf(" ⋯ %d unmodified lines", f.Lines) + if fold := v.folds[i]; fold.Lines > 0 { + s = fmt.Sprintf(" ⋯ %d unmodified lines", fold.Lines) } if len(h.Lines) == 0 { return s + " " @@ -267,25 +475,17 @@ func (v *View) headerText(i int) string { return s + hunkHeader(h) } -// hunkHeader returns the header line of hunk h, for example +// hunkHeader returns the range header of hunk h, for example // " @@ -1,3 +1,4 @@ ". -func hunkHeader(h *udiff.Hunk) string { - before, after := hunkLineCounts(h) - return fmt.Sprintf(" @@ -%d,%d +%d,%d @@ ", h.FromLine, before, h.ToLine, after) +func hunkHeader(h *hunk) string { + oldCount, newCount := hunkLineCounts(h) + return fmt.Sprintf(" @@ -%d,%d +%d,%d @@ ", h.OldStart, oldCount, h.NewStart, newCount) } // hunkLineCounts returns the number of old and new lines in hunk h. -func hunkLineCounts(h *udiff.Hunk) (before, after int) { +func hunkLineCounts(h *hunk) (oldCount, newCount int) { for _, l := range h.Lines { - switch l.Kind { - case udiff.Equal: - before++ - after++ - case udiff.Insert: - after++ - case udiff.Delete: - before++ - } + oldCount, newCount = l.next(oldCount, newCount) } - return before, after + return oldCount, newCount } diff --git a/internal/diffview/split.go b/internal/diffview/split.go index 5bc7d7a..c645e8d 100644 --- a/internal/diffview/split.go +++ b/internal/diffview/split.go @@ -4,50 +4,50 @@ package diffview import ( "slices" - - "github.com/aymanbagabas/go-udiff" ) -// splitLine is one row of the split layout. A nil side has no line. +// splitLine is one row of the split layout. A nil field means that side +// has no line. type splitLine struct { - before *udiff.Line - after *udiff.Line + old *diffLine + new *diffLine } -// hunkToSplit pairs the lines of h for the split layout. An equal line is on -// both sides. A deleted line gets the next inserted line beside it, if no -// equal line comes first. -func hunkToSplit(h *udiff.Hunk) []splitLine { +// hunkToSplit pairs the lines of h for the split layout. An equal line +// appears on both sides. A deleted line is paired with the next unpaired +// added line, unless an equal line comes first. Any other added line +// appears alone on the new side, in its original position. +func hunkToSplit(h *hunk) []splitLine { lines := slices.Clone(h.Lines) out := make([]splitLine, 0, len(lines)) - for len(lines) > 0 { - ul := lines[0] - lines = lines[1:] - var sl splitLine + taken := make([]bool, len(lines)) // added lines already paired with a deleted line + // next is the index where the search for an added line to pair starts. + // Every line before it is either already paired or not an added line. + next := 0 + for i := range lines { + ul := &lines[i] + var l splitLine switch ul.Kind { - case udiff.Equal: - sl.before, sl.after = &ul, &ul - case udiff.Insert: - sl.after = &ul - case udiff.Delete: - sl.before = &ul - sl.after, lines = takeInsert(lines) + case KindEqual: + l.old, l.new = ul, ul + case KindAdded: + if taken[i] { + continue + } + l.new = ul + case KindDeleted: + l.old = ul + next = max(next, i+1) + for next < len(lines) && lines[next].Kind != KindEqual && + (lines[next].Kind != KindAdded || taken[next]) { + next++ + } + if next < len(lines) && lines[next].Kind == KindAdded { + l.new, taken[next] = &lines[next], true + next++ + } } - out = append(out, sl) + out = append(out, l) } return out } - -// takeInsert removes the first inserted line before the next equal line from -// lines. It returns that line, or nil if there is none, and the other lines. -func takeInsert(lines []udiff.Line) (*udiff.Line, []udiff.Line) { - for i, l := range lines { - switch l.Kind { - case udiff.Insert: - return &l, slices.Delete(lines, i, i+1) - case udiff.Equal: - return nil, lines - } - } - return nil, lines -} diff --git a/internal/diffview/style.go b/internal/diffview/style.go index 694fcfe..f68d04e 100644 --- a/internal/diffview/style.go +++ b/internal/diffview/style.go @@ -9,23 +9,23 @@ import ( "charm.land/lipgloss/v2" ) -// lineStyle is the style of the parts of one type of line. +// lineStyle holds the styles of the parts of one kind of line. type lineStyle struct { - lineNumber lipgloss.Style - symbol lipgloss.Style - code lipgloss.Style + num lipgloss.Style + mark lipgloss.Style + code lipgloss.Style } -// style is the style of each type of line. +// style holds the style of each kind of line. type style struct { dividerLine lineStyle missingLine lineStyle equalLine lineStyle - insertLine lineStyle - deleteLine lineStyle + addedLine lineStyle + deletedLine lineStyle } -// The colours of the ANSI palette of the terminal, as Lip Gloss colours. +// Colours from the terminal's ANSI palette, as Lip Gloss colours. const ( ansiRed = "1" ansiGreen = "2" @@ -36,22 +36,24 @@ const ( ansiGrey = "8" // bright black ) -// The amounts that terminalStyle mixes into the background. +// The amounts of colour that terminalStyle mixes into the background. const ( - tintDark = 0.16 // inserted and deleted lines on a dark background - tintLight = 0.13 // inserted and deleted lines on a light background - tintNumber = 1.7 // multiplies the tint for the numbers of those lines - shadeDivider = 0.07 // the hunk headers, toward the foreground - shadeMissing = 0.03 // the empty side of a split row, toward the foreground + tintDark = 0.16 // added and deleted lines on a dark background + tintLight = 0.13 // added and deleted lines on a light background + tintNum = 1.7 // multiplies the tint for the line numbers of those lines + shadeDivider = 0.07 // hunk headers, towards the foreground + shadeMissing = 0.03 // the empty side of a split row, towards the foreground ) -// The tint colours when the terminal does not report its green or red. +// The tint colours used when the terminal does not report its green or +// red. const ( fallbackGreen = "#2ea043" fallbackRed = "#f85149" ) -// terminalStyle makes the style of [View.Theme] from the terminal colours. +// terminalStyle builds the style for [View.Theme] from the terminal's +// colours. func terminalStyle(bg, fg, green, red color.Color, dark bool) style { none := lipgloss.NoColor{} green = cmp.Or(green, lipgloss.Color(fallbackGreen)) @@ -73,42 +75,43 @@ func terminalStyle(bg, fg, green, red color.Color, dark bool) style { return mix(bg, fg, t) } plain := lipgloss.NewStyle() - // number is the style of a line number: right-aligned, with a space on - // each side. - number := func(s lipgloss.Style) lipgloss.Style { + // num returns the style of a line number: right-aligned, with a space + // on each side. + num := func(s lipgloss.Style) lipgloss.Style { return s.Padding(0, lineNumPadding).Align(lipgloss.Right) } - // changed is the style of an inserted or a deleted line, tinted toward c. + // changed returns the style of an added or deleted line, tinted + // towards c. changed := func(text string, c color.Color) lineStyle { - fg := plain.Foreground(lipgloss.Color(text)) + textStyle := plain.Foreground(lipgloss.Color(text)) return lineStyle{ - lineNumber: number(fg.Background(tint(c, amt*tintNumber))), - symbol: fg.Background(tint(c, amt)), - code: plain.Background(tint(c, amt)), + num: num(textStyle.Background(tint(c, amt*tintNum))), + mark: textStyle.Background(tint(c, amt)), + code: plain.Background(tint(c, amt)), } } divider := plain.Background(subtle(shadeDivider)) missing := plain.Background(subtle(shadeMissing)) return style{ dividerLine: lineStyle{ - lineNumber: number(divider.Foreground(lipgloss.Color(ansiGrey))), - code: divider.Foreground(lipgloss.Color(ansiCyan)), + num: num(divider.Foreground(lipgloss.Color(ansiGrey))), + code: divider.Foreground(lipgloss.Color(ansiCyan)), }, missingLine: lineStyle{ - lineNumber: number(missing), - symbol: missing, - code: missing, + num: num(missing), + mark: missing, + code: missing, }, equalLine: lineStyle{ - lineNumber: number(plain.Foreground(lipgloss.Color(ansiGrey))), - code: plain, + num: num(plain.Foreground(lipgloss.Color(ansiGrey))), + code: plain, }, - insertLine: changed(ansiGreen, green), - deleteLine: changed(ansiRed, red), + addedLine: changed(ansiGreen, green), + deletedLine: changed(ansiRed, red), } } -// mix mixes a toward b by the amount t, from 0 to 1. +// mix blends colour a towards b by the amount t, from 0 to 1. func mix(a, b color.Color, t float64) color.Color { ar, ag, ab, _ := a.RGBA() br, bg, bb, _ := b.RGBA() diff --git a/internal/diffview/text.go b/internal/diffview/text.go index 552410c..f2f7b7c 100644 --- a/internal/diffview/text.go +++ b/internal/diffview/text.go @@ -5,43 +5,107 @@ package diffview import ( "fmt" "strings" + "unicode" "unicode/utf8" "github.com/charmbracelet/x/ansi" + + "github.com/rednafi/rexdiff/internal/termtext" ) // TabWidth is the number of spaces that replace a tab. const TabWidth = 4 -// The Unicode Control Pictures that Escape shows in place of a control -// character. +// tabSpaces is the text that replaces a tab. +var tabSpaces = strings.Repeat(" ", TabWidth) + +// ExpandTabs replaces each tab in s with TabWidth spaces. +func ExpandTabs(s string) string { return strings.ReplaceAll(s, "\t", tabSpaces) } + +// EachLine applies f to each line of s, split at the LFs, and joins the +// results with LFs. +func EachLine(s string, f func(string) string) string { + if !strings.Contains(s, "\n") { + return f(s) + } + lines := strings.Split(s, "\n") + for i, l := range lines { + lines[i] = f(l) + } + return strings.Join(lines, "\n") +} + +// CRSymbol replaces a lone CR before highlighting, because chroma treats +// a CR as a line break. It is the picture that Escape shows for a CR. +const CRSymbol = "\u240d" + +// The Unicode Control Pictures that Escape uses in place of control +// characters. const ( - // The picture of U+0000. The pictures of U+0001 to U+001F follow it. + // The picture for U+0000. The pictures for U+0001 to U+001F follow it + // in order. controlPictures = '\u2400' delPicture = '\u2421' ) // Escape replaces each control character with its Unicode Control Picture, -// so the terminal shows it and does not run it. A C1 control becomes -// U+FFFD, because it has no picture. +// so that the terminal displays it instead of acting on it. C1 control +// characters have no picture and become U+FFFD. Escape also shows each +// other invisible character, as [isHidden] reports, as its code point in +// angle brackets, for example . A bidirectional control would +// otherwise reorder the text that the terminal shows, so that code reads +// differently from how it runs, and a zero-width character would hide in +// plain sight. Bytes that are not valid UTF-8 become U+FFFD. func Escape(content string) string { - return strings.Map(func(r rune) rune { + if utf8.ValidString(content) && !strings.ContainsFunc(content, needsEscape) { + return content + } + var b strings.Builder + b.Grow(len(content)) + for _, r := range content { switch { case r <= 0x1f: // C0 controls - return controlPictures + r + b.WriteRune(controlPictures + r) case r == ansi.DEL: - return delPicture + b.WriteRune(delPicture) case r >= 0x80 && r <= 0x9f: // C1 controls - return utf8.RuneError + b.WriteRune(utf8.RuneError) + case isHidden(r): + fmt.Fprintf(&b, "", r) + default: + b.WriteRune(r) } - return r - }, content) + } + return b.String() +} + +// needsEscape reports whether [Escape] replaces r. +func needsEscape(r rune) bool { + if r < utf8.RuneSelf { + return r <= 0x1f || r == ansi.DEL + } + return r <= 0x9f || isHidden(r) +} + +// isHidden reports whether r is a character other than a control +// character that a terminal does not show as a mark of its own, or that +// changes the order of the text around it: a format character, such as +// U+202E (right-to-left override), U+00AD (soft hyphen) or a tag character +// (U+E0001 to U+E007F), a line or paragraph separator, or a filler, such as +// U+3164 (Hangul filler). It leaves out U+200C and U+200D (zero-width +// non-joiner and joiner), which some scripts and emoji sequences need. The +// variation selectors, which emoji need too, are not format characters. +func isHidden(r rune) bool { + return r != '\u200c' && r != '\u200d' && !unicode.IsControl(r) && termtext.Invisible(r) } -// CutLeft removes the first n cells of s, which can have ANSI codes. If -// the cut is inside a wide character, a space takes the place of the rest -// of that character. Thus the text after the cut moves by n cells. +// CutLeft removes the first n cells of s, which can contain ANSI codes. If +// the cut falls inside a wide character, spaces replace the rest of that +// character, so the remaining text moves left by exactly n cells. func CutLeft(s string, n int) string { + if n <= 0 { + return s // nothing to remove; a negative n would otherwise add spaces + } want := max(ansi.StringWidth(s)-n, 0) cut := ansi.TruncateLeft(s, n, "") for ansi.StringWidth(cut) > want { @@ -51,9 +115,8 @@ func CutLeft(s string, n int) string { return strings.Repeat(" ", want-ansi.StringWidth(cut)) + cut } -// pad right-aligns v in a column of width cells. -func pad(v any, width int) string { - s := fmt.Sprint(v) +// pad right-aligns s in a column of width cells. +func pad(s string, width int) string { w := ansi.StringWidth(s) if w >= width { return s @@ -61,7 +124,7 @@ func pad(v any, width int) string { return strings.Repeat(" ", width-w) + s } -// btoi returns 1 if b is true, else 0. +// btoi returns 1 if b is true, and 0 otherwise. func btoi(b bool) int { if b { return 1 diff --git a/internal/diffview/text_test.go b/internal/diffview/text_test.go new file mode 100644 index 0000000..30e97dc --- /dev/null +++ b/internal/diffview/text_test.go @@ -0,0 +1,33 @@ +package diffview_test + +import ( + "testing" + + "github.com/rednafi/rexdiff/internal/diffview" +) + +// TestEscapeInvisible checks that Escape shows invisible characters as +// their code points, so that none hides or takes a width that the +// terminal does not show. It also checks that Escape keeps the joiners and +// variation selectors that emoji and some scripts need, and visible blanks +// such as U+2800 (Braille pattern blank). +func TestEscapeInvisible(t *testing.T) { + t.Parallel() + tests := []struct{ in, want string }{ + {"a\u3164b", "ab"}, + {"\u115f\u1160\uffa0", ""}, + {"a\u2028b\u2029c", "abc"}, + {"soft\u00adhyphen", "softhyphen"}, + {"tag\U000e0041\U000e007f", "tag"}, + {"a\u180eb", "ab"}, + {"👨\u200d👩\u200d👧 ❤\ufe0f", "👨\u200d👩\u200d👧 ❤\ufe0f"}, + {"a\u200cb", "a\u200cb"}, + {"braille\u2800", "braille\u2800"}, + {"a\x00b\x7f\u0085", "a\u2400b\u2421\ufffd"}, + } + for _, tt := range tests { + if got := diffview.Escape(tt.in); got != tt.want { + t.Errorf("Escape(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/internal/diffview/view.go b/internal/diffview/view.go index e2af0ec..17ee2b3 100644 --- a/internal/diffview/view.go +++ b/internal/diffview/view.go @@ -1,59 +1,60 @@ // Derived from charmbracelet/crush (FSL-1.1-MIT). See ../DERIVED.md. -// Package diffview draws the difference between two versions of a file in -// the terminal, with syntax colours. +// Package diffview draws the difference between two versions of a file for +// a terminal, with syntax highlighting. package diffview import ( "image/color" + "slices" + "sort" "strconv" "strings" "charm.land/lipgloss/v2" - "github.com/aymanbagabas/go-udiff" "github.com/charmbracelet/x/ansi" ) -// View draws the difference between two versions of a file. Make one with -// [New], set its options, and call [View.Render]. +// View draws the difference between two versions of a file. Create one +// with [New], set its options, and call [View.RenderLines]. type View struct { - path string // selects the lexer - before, after string - split bool // the old and the new lines side by side - width int - xOffset int - wrap bool // wrap long lines instead of cutting them - signs bool // mark a changed line with + or -, not with a bar - style style - expanded func(Fold) bool // the folds that show, see [View.Expand] - cache *Cache - - // Render sets the fields below. - hunks []*udiff.Hunk - folds []Fold // folds[i] hides above hunks[i] - rows []Row - codeWidth int - fullCodeWidth int // with the leading symbols - extraColOnAfter bool // the new side of the split layout is one cell wider - beforeNumDigits int - afterNumDigits int + path string // selects the syntax highlighting + oldText, newText string + split bool // show the old and new lines side by side + width int + scrollX int + wrap bool // wrap long lines instead of cutting them + signs bool // mark changed lines with + or - instead of a bar + style style + expanded func(Fold) bool // reports which folds are expanded; see [View.Expanded] + cache *Cache + + // prepare sets the fields below. + hunks []*hunk + folds []Fold // folds[i] is the fold above hunks[i] + codeWidth int + fullCodeWidth int // codeWidth plus the leading marks + extraColOnNew bool // in split view, the new side is one cell wider + oldNumDigits int + newNumDigits int } -// New returns a View of the change from before to after, the old and the -// new content of the file at path. The path selects the syntax colours. -// The View has the unified layout, no width limit, and no tints. -func New(path, before, after string) *View { +// New returns a View of the change from oldText to newText, the old and +// new content of the file at path. The path selects the syntax +// highlighting. The View starts with the unified layout, no width limit +// and no tints. +func New(path, oldText, newText string) *View { return &View{ - path: path, - before: before, - after: after, - style: terminalStyle(nil, nil, nil, nil, true), - cache: &Cache{}, + path: path, + oldText: oldText, + newText: newText, + style: terminalStyle(nil, nil, nil, nil, true), + cache: &Cache{}, } } -// Split sets the split layout if split is true. The old lines are on the -// left and the new lines are on the right. +// Split selects the split layout if split is true, with the old lines on +// the left and the new lines on the right. func (v *View) Split(split bool) *View { v.split = split return v @@ -65,146 +66,319 @@ func (v *View) Width(width int) *View { return v } -// XOffset sets the number of cells to scroll the code to the right. -func (v *View) XOffset(xOffset int) *View { - v.xOffset = xOffset +// ScrollX sets the number of cells to scroll the code to the right. +func (v *View) ScrollX(scrollX int) *View { + v.scrollX = scrollX return v } -// Wrap makes long lines continue on the next screen lines if wrap is -// true, instead of cutting them. Then the x offset does not apply. +// Wrap sets whether long lines continue on the following screen lines +// instead of being cut off. When wrap is true, [View.ScrollX] is ignored. func (v *View) Wrap(wrap bool) *View { v.wrap = wrap return v } -// Signs marks an inserted line with + and a deleted line with - if signs -// is true, in place of a bar. +// Signs sets whether added lines are marked with + and deleted lines with +// -, instead of a bar. func (v *View) Signs(signs bool) *View { v.signs = signs return v } -// Expand shows the lines of each fold for which expanded returns true. -func (v *View) Expand(expanded func(Fold) bool) *View { +// Expanded shows the hidden lines of each fold for which expanded returns +// true. +func (v *View) Expanded(expanded func(Fold) bool) *View { v.expanded = expanded return v } -// Cache makes v keep its highlighted lines in c. The Cache c must not be +// Cache makes v store its highlighted lines and diff in c. c must not be // nil. func (v *View) Cache(c *Cache) *View { v.cache = c return v } -// Theme sets the colours from the colours of the terminal. These are the -// background, the foreground, and the ANSI green and red. The text colours -// are ANSI palette indices, so the terminal selects the real colours. -// Theme mixes the tints of inserted and deleted lines from the background -// toward green and red. If green or red is nil, it uses a fixed green or -// red. If bg is nil, it adds no tint. Dark selects the tint amounts for a -// dark background. +// Theme sets the colours from the terminal's background, foreground, green +// and red. Text colours are ANSI palette indices. Added and deleted lines +// are tinted by mixing the background towards green and red. If green or +// red is nil, a fixed colour is used instead. If bg is nil, lines are not +// tinted. dark selects the tint strengths for a dark background. func (v *View) Theme(bg, fg, green, red color.Color, dark bool) *View { v.style = terminalStyle(bg, fg, green, red, dark) return v } -// Render returns the rendered diff, one row for each of its lines, and the -// columns of the line numbers. -func (v *View) Render() (string, []Row, Geometry) { - v.rows = nil - if err := v.computeDiff(); err != nil { - return err.Error(), nil, v.geometry() +// renderAll renders every line of the prepared view and returns them with +// their rows. +func (v *View) renderAll() (string, []Row) { + var b strings.Builder + var rows []Row + items := v.items() + for k := range items { + it := &items[k] + for i, l := range v.itemLines(it) { + b.WriteString(l) + b.WriteByte('\n') + r := it.row + r.Continued = i > 0 + rows = append(rows, r) + } + } + frame := lipgloss.NewStyle() + if v.width > 0 { + frame = frame.MaxWidth(v.width) } - v.markEndings() - v.replaceTabs() - v.detectNumDigits() + return frame.Render(strings.TrimSuffix(b.String(), "\n")), rows +} + +// prepare computes the hunks, folds and column widths of the view. +func (v *View) prepare() { + v.computeDiff() + v.computeNumDigits() if v.width <= 0 { - v.detectCodeWidth() + v.computeCodeWidth() } else { v.resizeCodeWidth() } +} - style := lipgloss.NewStyle() - if v.width > 0 { - style = style.MaxWidth(v.width) +// Lines holds screen lines that are rendered when they are first +// requested. Each unit of the output, such as a line of a diff, takes one +// or more screen lines, which are rendered together. Lines is not safe for +// concurrent use. +type Lines struct { + render func(k int) (lines []string, final bool) // renders unit k + start []int // start[k] is the first screen line of unit k + lines []string + done []bool // done[i] is true if lines[i] is rendered for good +} + +// NewLines returns n screen lines, where start[k] is the first screen line +// of unit k, and render renders the screen lines of unit k. If render +// returns fewer lines than the unit has, the rest are blank. If final is +// false, Lines renders the unit again the next time one of its lines is +// requested. +func NewLines(n int, start []int, render func(k int) (lines []string, final bool)) *Lines { + return &Lines{render: render, start: start, lines: make([]string, n), done: make([]bool, n)} +} + +// Line returns screen line i. +func (ls *Lines) Line(i int) string { + if ls.done[i] { + return ls.lines[i] } - var out string - if v.split { - out = v.renderSplit() - } else { - out = v.renderUnified() + // The last unit that starts at or before i. A unit with no lines + // starts at the same line as the next unit, so it is never chosen. + k := sort.Search(len(ls.start), func(k int) bool { return ls.start[k] > i }) - 1 + first, end := ls.start[k], len(ls.lines) + if k+1 < len(ls.start) { + end = ls.start[k+1] + } + out, final := ls.render(k) + for j := first; j < end; j++ { + l := "" + if j-first < len(out) { + l = out[j-first] + } + ls.lines[j], ls.done[j] = l, final } - return style.Render(strings.TrimSuffix(out, "\n")), v.rows, v.geometry() + return ls.lines[i] } -// computeDiff computes the hunks and the folds of the diff. -func (v *View) computeDiff() error { - edits := udiff.Lines(v.before, v.after) - // With a context as long as the file, the diff has one hunk with all - // the lines. foldHunk then hides the long runs of equal lines. - context := strings.Count(v.before, "\n") + 1 - unified, err := udiff.ToUnifiedDiff("", "", v.before, edits, context) - if err != nil { - return err +// fit pads or cuts l to the width of v, as renderAll does. If v has no +// width, fit returns l unchanged. l contains no tabs, because +// replaceTabs has already replaced them. +func (v *View) fit(l string) string { + if v.width <= 0 { + return l + } + switch w := ansi.StringWidth(l); { + case w < v.width: + return l + strings.Repeat(" ", v.width-w) + case w > v.width: + return ansi.Truncate(l, v.width, "") + } + return l +} + +// RenderLines returns the lines of the rendered diff, one row for each +// of them, and the positions of the line-number and code columns. Each +// line is rendered when it is first requested, unless the width leaves no +// room for the code: then all lines are rendered at once. +func (v *View) RenderLines() (*Lines, []Row, Geometry) { + v.prepare() + if v.width > 0 && v.codeWidth < 1 { + // The lines can have different widths, so render them all to find + // the widest. + out, rows := v.renderAll() + ls := &Lines{} + if len(rows) > 0 { + ls.lines = strings.Split(out, "\n") + ls.done = make([]bool, len(ls.lines)) + for i := range ls.done { + ls.done[i] = true + } + } + return ls, rows, v.geometry() + } + f := &v.cache.folded + key := itemsKey{split: v.split, wrap: v.wrap, codeWidth: v.codeWidth, extraColOnNew: v.extraColOnNew} + if f.items == nil || f.key != key { + f.key, f.items = key, v.items() + f.start = make([]int, len(f.items)) + f.rows = make([]Row, 0, len(f.items)) + for k := range f.items { + it := &f.items[k] + f.start[k] = len(f.rows) + for i := range v.itemHeight(it) { + r := it.row + r.Continued = i > 0 + f.rows = append(f.rows, r) + } + } + } + items := f.items + ls := NewLines(len(f.rows), f.start, func(k int) ([]string, bool) { + out := v.itemLines(&items[k]) + for i, l := range out { + out[i] = v.fit(l) + } + return out, true + }) + return ls, f.rows, v.geometry() +} + +// computeDiff computes the hunks and folds of the diff. The [Cache] +// stores them for reuse by the next View with the same diff and hidden +// folds. +func (v *View) computeDiff() { + whole, keep := v.cache.unified(v.oldText, v.newText) + hidden := make([][]Fold, len(whole)) + for i, h := range whole { + hidden[i] = hiddenFolds(h, keep[i], v.expanded) + } + f := &v.cache.folded + if f.gen == v.cache.diff.gen && slices.EqualFunc(f.hunkFolds, hidden, slices.Equal) { + v.hunks, v.folds = f.hunks, f.folds + return } v.hunks, v.folds = nil, nil - for _, h := range unified.Hunks { - hs, fs := foldHunk(h, v.expanded) + for i, h := range whole { + hs, fs := foldHunk(h, keep[i], hidden[i]) v.hunks = append(v.hunks, hs...) v.folds = append(v.folds, fs...) } - return nil + *f = foldedDiff{gen: v.cache.diff.gen, hunkFolds: hidden, hunks: v.hunks, folds: v.folds} +} + +// foldedDiff is the diff of a View after folding, and its items for one +// layout. A [Cache] stores the most recent one. The hunks, items and rows +// are not modified after they are built. +type foldedDiff struct { + gen int // the generation of the Cache diff that the hunks come from + hunkFolds [][]Fold // the hidden folds of each hunk in that diff + hunks []*hunk + folds []Fold + + // The items of [View.RenderLines], for the layout described by key. + key itemsKey + items []item + rows []Row + start []int // start[k] is the first row of items[k] +} + +// itemsKey holds the values other than the hunks that the items and their +// heights depend on. +type itemsKey struct { + split, wrap bool + codeWidth int + extraColOnNew bool +} + +// unified returns the diff from oldText to newText as at most one hunk +// that holds every line, with CRs marked and tabs replaced, and the +// keptLines of each hunk. c stores the last result, which the caller must +// not modify. +func (c *Cache) unified(oldText, newText string) (hunks []*hunk, keep [][]bool) { + u := &c.diff + if u.gen > 0 && u.oldText == oldText && u.newText == newText { + return u.hunks, u.keep + } + // One hunk holds every line. foldHunk hides long runs of equal lines + // later. + u.hunks = nil + if h := lineDiff(oldText, newText); h != nil { + u.hunks = []*hunk{h} + } + // A fold hides only equal lines, so these steps have the same result + // before and after folding. + markEndings(u.hunks) + replaceTabs(u.hunks) + u.oldText, u.newText = oldText, newText + u.keep = make([][]bool, len(u.hunks)) + for i, h := range u.hunks { + u.keep[i] = keptLines(h) + } + u.gen++ + return u.hunks, u.keep } -// crMark shows a CR at the end of a line, as less and vim show it. +// crMark marks a CR at the end of a line. const crMark = "^M" -// markEndings removes the CR of each line ending, so that it does not -// show. In a block of changes, the deleted lines and the inserted lines -// pair up in their order. As in herdr-reviewr, a line of a pair that -// differs from the other line only in a CR gets crMark. A change of only -// the line ending shows. -func (v *View) markEndings() { - for _, h := range v.hunks { +// noNewlineMark follows a changed line that has no line ending, the last +// line of its file. +const noNewlineMark = " \\ No newline at end of file" + +// markEndings removes the CR from each line ending. In each block of +// changes, it pairs deleted lines with added lines in order. If the two +// lines of a pair differ only by a CR, it adds crMark to the line that had +// the CR. It adds noNewlineMark to each changed line without a line +// ending. +func markEndings(hunks []*hunk) { + for _, h := range hunks { ls := h.Lines for i := 0; i < len(ls); { - dels := i - for i < len(ls) && ls[i].Kind == udiff.Delete { + deleted := i + for i < len(ls) && ls[i].Kind == KindDeleted { i++ } ins := i - for i < len(ls) && ls[i].Kind == udiff.Insert { + for i < len(ls) && ls[i].Kind == KindAdded { i++ } - for k := 0; dels+k < ins && ins+k < i; k++ { - markPair(&ls[dels+k], &ls[ins+k]) + for k := 0; deleted+k < ins && ins+k < i; k++ { + markPair(&ls[deleted+k], &ls[ins+k]) } - if i == dels { // an equal line + if i == deleted { // an equal line i++ } } for i := range ls { ls[i].Content, _ = cutCR(ls[i].Content, "") + if ls[i].Kind != KindEqual && !strings.HasSuffix(ls[i].Content, "\n") { + ls[i].Content += noNewlineMark + } } } } -// markPair adds crMark to old or to new if the two lines differ only in -// the CR of the line ending. -func markPair(old, new *udiff.Line) { - o, oCR := cutCR(old.Content, "") - n, nCR := cutCR(new.Content, "") +// markPair adds crMark to the line that has a CR if oldLine and newLine +// differ only by the CR in the line ending. +func markPair(oldLine, newLine *diffLine) { + o, oCR := cutCR(oldLine.Content, "") + n, nCR := cutCR(newLine.Content, "") if oCR != nCR && o == n { - old.Content, _ = cutCR(old.Content, crMark) - new.Content, _ = cutCR(new.Content, crMark) + oldLine.Content, _ = cutCR(oldLine.Content, crMark) + newLine.Content, _ = cutCR(newLine.Content, crMark) } } -// cutCR returns line with mark in place of the CR of its line ending, and -// reports whether line has that CR. The last line of a file can have a CR -// and no LF. +// cutCR returns line with mark in place of the CR in its line ending, and +// reports whether line had that CR. The last line of a file can end in a +// CR without an LF. func cutCR(line, mark string) (string, bool) { body, cr := strings.CutSuffix(strings.TrimSuffix(line, "\n"), "\r") if !cr { @@ -213,34 +387,33 @@ func cutCR(line, mark string) (string, bool) { return body + mark + line[len(body)+1:], true } -// replaceTabs replaces the tabs in the lines of the diff with spaces. It runs -// after computeDiff, so that a change from a tab to spaces is a change. -func (v *View) replaceTabs() { - spaces := strings.Repeat(" ", TabWidth) - for _, h := range v.hunks { +// replaceTabs replaces tabs with spaces in the lines of the hunks. It runs +// after the diff is computed, so that replacing a tab with spaces still +// counts as a change. +func replaceTabs(hunks []*hunk) { + for _, h := range hunks { for i := range h.Lines { - h.Lines[i].Content = strings.ReplaceAll(h.Lines[i].Content, "\t", spaces) + h.Lines[i].Content = ExpandTabs(h.Lines[i].Content) } } } -// detectNumDigits finds the number of digits of the largest old and new line -// numbers. -func (v *View) detectNumDigits() { - v.beforeNumDigits = 0 - v.afterNumDigits = 0 +// computeNumDigits sets the number of digits in the largest old and new +// line numbers. +func (v *View) computeNumDigits() { + v.oldNumDigits = 0 + v.newNumDigits = 0 for _, h := range v.hunks { - before := len(strconv.Itoa(h.FromLine + len(h.Lines))) - after := len(strconv.Itoa(h.ToLine + len(h.Lines))) - v.beforeNumDigits = max(v.beforeNumDigits, before) - v.afterNumDigits = max(v.afterNumDigits, after) + oldNum := len(strconv.Itoa(h.OldStart + len(h.Lines))) + newNum := len(strconv.Itoa(h.NewStart + len(h.Lines))) + v.oldNumDigits = max(v.oldNumDigits, oldNum) + v.newNumDigits = max(v.newNumDigits, newNum) } } -// detectCodeWidth finds the width of the widest code line or hunk header. -// The split layout shows the same lines, so the width is the same for the -// two layouts. -func (v *View) detectCodeWidth() { +// computeCodeWidth sets the code width to the width of the widest code +// line or hunk header. The width is the same in both layouts. +func (v *View) computeCodeWidth() { v.codeWidth = 0 for i, h := range v.hunks { v.codeWidth = max(v.codeWidth, ansi.StringWidth(v.headerText(i))) @@ -249,48 +422,49 @@ func (v *View) detectCodeWidth() { v.codeWidth = max(v.codeWidth, w) } } - v.fullCodeWidth = v.codeWidth + leadingSymbolsSize + v.fullCodeWidth = v.codeWidth + leadingMarksWidth } -// resizeCodeWidth sets the code width so that the lines fit in the width. +// resizeCodeWidth sets the code width so that the lines fit in v.width. func (v *View) resizeCodeWidth() { - fullNumWidth := v.beforeNumDigits + v.afterNumDigits - fullNumWidth += lineNumPadding * 4 // two sides of two line numbers + fullNumWidth := v.oldNumDigits + v.newNumDigits + fullNumWidth += lineNumPadding * 4 // padding on both sides of two line numbers if v.split { - remainingWidth := v.width - fullNumWidth - leadingSymbolsSize*2 + remainingWidth := v.width - fullNumWidth - leadingMarksWidth*2 v.codeWidth = remainingWidth / 2 - v.extraColOnAfter = remainingWidth%2 != 0 + v.extraColOnNew = remainingWidth%2 != 0 } else { - v.codeWidth = v.width - fullNumWidth - leadingSymbolsSize + v.codeWidth = v.width - fullNumWidth - leadingMarksWidth } - v.fullCodeWidth = v.codeWidth + leadingSymbolsSize + v.fullCodeWidth = v.codeWidth + leadingMarksWidth } -// Geometry gives the columns of the line numbers and of the code in the -// output of [View.Render]. Use it to tell a line number from the code. +// Geometry describes the line-number and code columns in the output of +// [View.RenderLines]. Use it to tell whether a column is on a line number or on +// code. type Geometry struct { - BeforeNum int // the width of the old line-number column, in cells - AfterNum int // the width of the new line-number column, in cells - AfterNumStart int // the column where the new line numbers start, from 0 - BeforeCode int // the column where the old code starts, from 0 - AfterCode int // the column where the new code starts, from 0 + OldNum int // the width of the old line-number column, in cells + NewNum int // the width of the new line-number column, in cells + NewNumStart int // the 0-based column where the new line numbers start + OldCode int // the 0-based column where the old code starts + NewCode int // the 0-based column where the new code starts } -// geometry returns the columns of the line numbers and of the code. +// geometry returns the positions of the line-number and code columns. func (v *View) geometry() Geometry { g := Geometry{ - BeforeNum: v.beforeNumDigits + lineNumPadding*2, - AfterNum: v.afterNumDigits + lineNumPadding*2, + OldNum: v.oldNumDigits + lineNumPadding*2, + NewNum: v.newNumDigits + lineNumPadding*2, } - g.AfterNumStart = g.BeforeNum + g.NewNumStart = g.OldNum if v.split { - g.AfterNumStart = g.BeforeNum + v.fullCodeWidth - g.BeforeCode = g.BeforeNum + leadingSymbolsSize - g.AfterCode = g.AfterNumStart + g.AfterNum + leadingSymbolsSize + g.NewNumStart = g.OldNum + v.fullCodeWidth + g.OldCode = g.OldNum + leadingMarksWidth + g.NewCode = g.NewNumStart + g.NewNum + leadingMarksWidth return g } - g.BeforeCode = g.BeforeNum + g.AfterNum + leadingSymbolsSize - g.AfterCode = g.BeforeCode + g.OldCode = g.OldNum + g.NewNum + leadingMarksWidth + g.NewCode = g.OldCode return g } diff --git a/internal/editor/editor.go b/internal/editor/editor.go index 235e8aa..2620db4 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -1,6 +1,6 @@ -// Package editor picks the text editor of the user, and makes the sh -// command that opens a file at a line in it. It also reports whether the -// editor opens a window or draws in the terminal. +// Package editor chooses the user's text editor and builds the sh command +// that opens a file at a given line. It also reports whether the editor +// opens its own window or runs in the terminal. package editor import ( @@ -13,27 +13,27 @@ import ( "strings" ) -// Command is a command that opens a file at a line. +// Command is a command that opens a file at a given line. type Command struct { Script string // the command, for sh -c - Program string // the first word of the command, or "" if it has none - Window bool // the editor opens a window, not a terminal editor + Program string // the first word of the command, or "" if there is none + Window bool // the editor opens its own window instead of running in the terminal } -// Sources are the places that can name the editor, in the order that -// Resolve tries them. +// Sources holds the places that can name the editor, in the order that +// CommandFor tries them. type Sources struct { - Template string // the editor command of the settings + Template string // the editor command from the settings Visual string // $VISUAL Editor string // $EDITOR Git string // core.editor } -// Resolve returns the command that opens file at line. It uses -// s.Template if it is not empty. Else it uses the first of s.Visual, -// s.Editor, and s.Git that is not blank. Each error is a message for the -// user. -func Resolve(s Sources, file string, line int) (Command, error) { +// CommandFor returns the command that opens file at line. It uses +// s.Template if it is not empty. Otherwise it uses the first non-blank +// value of s.Visual, s.Editor and s.Git. The error messages are written +// for the user. +func CommandFor(s Sources, file string, line int) (Command, error) { var c Command if s.Template != "" { c = fromTemplate(s.Template, file, line) @@ -50,10 +50,9 @@ func Resolve(s Sources, file string, line int) (Command, error) { return c, nil } -// fromTemplate returns the command that opens file at line with template, -// a command that the user writes. The template gets file in place of -// {file}, and line in place of {line}. Without {file}, file goes at the -// end. +// fromTemplate returns the command that opens file at line using the +// user's command template. It replaces {file} and {line}. If template has +// no {file}, the file is added at the end. func fromTemplate(template, file string, line int) Command { r := strings.NewReplacer("{file}", shellQuote(file), "{line}", strconv.Itoa(line)) script := r.Replace(template) @@ -63,9 +62,9 @@ func fromTemplate(template, file string, line int) Command { return Command{Script: script, Program: program(template), Window: dialectOf(template).window} } -// fromEditor returns the command that opens file at line in an editor. -// command starts the editor, for example the value of $EDITOR. fromEditor -// adds file and line in the form that the editor expects. +// fromEditor returns the command that opens file at line using an editor +// command such as $EDITOR. It adds the file and line in the form that the +// editor expects. func fromEditor(command, file string, line int) Command { d := dialectOf(command) n, f := strconv.Itoa(line), shellQuote(file) @@ -86,10 +85,10 @@ func fromEditor(command, file string, line int) Command { return Command{Script: command + " " + args, Program: program(command), Window: d.window} } -// shellQuote quotes s as one word for sh. +// shellQuote quotes s as a single word for sh. func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } -// lineArg is how an editor takes the line to open at. +// lineArg is the way an editor accepts the line number to open at. type lineArg int const ( @@ -100,17 +99,17 @@ const ( argFlag // --line 42 file ) -// dialect tells how an editor takes the line, and if it opens a window. +// dialect describes how an editor accepts the line number, and whether it +// opens a window. type dialect struct { line lineArg window bool } -// dialects has the known editors, by program name. The table is the same -// as in herdr-reviewr. The table also lists fresh, open, and xdg-open. +// dialects maps the program names of known editors to their dialects. var dialects = knownDialects() -// knownDialects returns the table of dialects. +// knownDialects builds the dialects table. func knownDialects() map[string]dialect { d := map[string]dialect{} add := func(dl dialect, names ...string) { @@ -119,10 +118,10 @@ func knownDialects() map[string]dialect { } } add(dialect{argPlus, false}, "vi", "vim", "nvim", "lvim", "vis", "joe", "nano", "micro", "kak") - // Emacs can open a window or not. In a pane, it works in both cases. + // Emacs may or may not open a window. Both work in a pane. add(dialect{argPlus, false}, "emacs", "emacsclient") add(dialect{argSuffix, false}, "hx", "helix", "fresh") - // MacVim and gVim open a window, unlike the other vi editors. + // MacVim, gVim, BBEdit and gedit open a window. add(dialect{argPlus, true}, "mvim", "gvim", "bbedit", "gedit") add(dialect{argGoto, true}, "code", "code-insiders", "codium", "vscodium", "cursor", "windsurf", "positron") @@ -131,15 +130,14 @@ func knownDialects() map[string]dialect { "clion", "phpstorm", "rubymine", "rider", "datagrip", "rustrover", "dataspell", "fleet") add(dialect{argNone, true}, "open", "xdg-open") if runtime.GOOS == "darwin" { - add(dialect{argFlag, true}, "xed") // the opener of Xcode + add(dialect{argFlag, true}, "xed") // the Xcode command-line launcher } return d } -// dialectOf returns the dialect of the program of command. An unknown -// editor takes +line and draws in the terminal. Most terminal editors take -// +line. A window editor also works in a pane. A wait flag tells that the -// editor has a window. +// dialectOf returns the dialect for the program in command. An unknown +// editor is assumed to accept +line and run in the terminal. A --wait or +// --block flag marks an editor that opens a window. func dialectOf(command string) dialect { d := dialect{line: argPlus} if known, ok := dialects[filepath.Base(program(command))]; ok { @@ -152,7 +150,7 @@ func dialectOf(command string) dialect { return d } -// program returns the first word of command. A word in quotes can have +// program returns the first word of command. A quoted word can contain // spaces, for example "/Applications/Visual Studio Code.app/.../code". func program(command string) string { command = strings.TrimSpace(command) diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go index 4709e0f..da31b8c 100644 --- a/internal/editor/editor_test.go +++ b/internal/editor/editor_test.go @@ -6,7 +6,8 @@ import ( "github.com/rednafi/rexdiff/internal/editor" ) -// TestCommands checks the command and the kind of each editor. +// TestCommands checks the command that CommandFor builds for each editor, +// and whether it reports that the editor opens a window. func TestCommands(t *testing.T) { t.Parallel() const abs = "/r/a b.go" @@ -15,41 +16,41 @@ func TestCommands(t *testing.T) { script string window bool }{ - // The editor of the settings gets the placeholders. - {"/opt/homebrew/bin/nvim +{line} {file}", "", `/opt/homebrew/bin/nvim +12 '/r/a b.go'`, false}, - {"code -g {file}:{line}", "", `code -g '/r/a b.go':12`, true}, - {"zed {file}:{line}", "", `zed '/r/a b.go':12`, true}, - {"open -a Zed {file}", "", `open -a Zed '/r/a b.go'`, true}, - {"myeditor --at {line}", "", `myeditor --at 12 '/r/a b.go'`, false}, - // The editor of the environment gets the line as it expects it. - {"", "nvim", `nvim +12 '/r/a b.go'`, false}, - {"", "/usr/bin/vim -u NONE", `/usr/bin/vim -u NONE +12 '/r/a b.go'`, false}, - {"", "micro", `micro +12 '/r/a b.go'`, false}, - {"", "hx", `hx '/r/a b.go:12'`, false}, - {"", "fresh", `fresh '/r/a b.go:12'`, false}, - {"", "code --wait", `code --wait -g '/r/a b.go:12'`, true}, - {"", "subl", `subl '/r/a b.go:12'`, true}, - {"", "idea", `idea --line 12 '/r/a b.go'`, true}, - {"", "mvim", `mvim +12 '/r/a b.go'`, true}, - // A path in quotes can have spaces. - {"", `"/Apps/VS Code.app/bin/code" -w`, `"/Apps/VS Code.app/bin/code" -w -g '/r/a b.go:12'`, true}, - {"", `'/my apps/nvim'`, `'/my apps/nvim' +12 '/r/a b.go'`, false}, - // An unknown editor gets +line, and draws in the terminal. - {"", "vim.basic", `vim.basic +12 '/r/a b.go'`, false}, - // A wait flag tells that an unknown editor has a window. - {"", "myeditor --wait", `myeditor --wait +12 '/r/a b.go'`, true}, + // The editor command from the settings has its placeholders filled in. + {template: "/opt/homebrew/bin/nvim +{line} {file}", script: `/opt/homebrew/bin/nvim +12 '/r/a b.go'`}, + {template: "code -g {file}:{line}", script: `code -g '/r/a b.go':12`, window: true}, + {template: "zed {file}:{line}", script: `zed '/r/a b.go':12`, window: true}, + {template: "open -a Zed {file}", script: `open -a Zed '/r/a b.go'`, window: true}, + {template: "myeditor --at {line}", script: `myeditor --at 12 '/r/a b.go'`}, + // An editor from the environment gets the line in the form it expects. + {editor: "nvim", script: `nvim +12 '/r/a b.go'`}, + {editor: "/usr/bin/vim -u NONE", script: `/usr/bin/vim -u NONE +12 '/r/a b.go'`}, + {editor: "micro", script: `micro +12 '/r/a b.go'`}, + {editor: "hx", script: `hx '/r/a b.go:12'`}, + {editor: "fresh", script: `fresh '/r/a b.go:12'`}, + {editor: "code --wait", script: `code --wait -g '/r/a b.go:12'`, window: true}, + {editor: "subl", script: `subl '/r/a b.go:12'`, window: true}, + {editor: "idea", script: `idea --line 12 '/r/a b.go'`, window: true}, + {editor: "mvim", script: `mvim +12 '/r/a b.go'`, window: true}, + // A quoted path can contain spaces. + {editor: `"/Apps/VS Code.app/bin/code" -w`, script: `"/Apps/VS Code.app/bin/code" -w -g '/r/a b.go:12'`, window: true}, + {editor: `'/my apps/nvim'`, script: `'/my apps/nvim' +12 '/r/a b.go'`}, + // An unknown editor gets +line and is assumed to run in the terminal. + {editor: "vim.basic", script: `vim.basic +12 '/r/a b.go'`}, + // A --wait flag shows that an unknown editor opens a window. + {editor: "myeditor --wait", script: `myeditor --wait +12 '/r/a b.go'`, window: true}, } for _, tt := range tests { - got, err := editor.Resolve(editor.Sources{Template: tt.template, Editor: tt.editor}, abs, 12) + got, err := editor.CommandFor(editor.Sources{Template: tt.template, Editor: tt.editor}, abs, 12) if err != nil || got.Script != tt.script || got.Window != tt.window { - t.Errorf("editor %q%q = %+v, %v, want script %q, window %v", + t.Errorf("CommandFor(%q, %q) = %+v, %v, want script %q, window %v", tt.template, tt.editor, got, err, tt.script, tt.window) } } } -// TestCommandProgram checks the program of a command. An empty word in -// quotes names no program. +// TestCommandProgram checks the program name that CommandFor finds in a +// command. An empty quoted word names no program. func TestCommandProgram(t *testing.T) { t.Parallel() tests := []struct{ command, want string }{ @@ -63,20 +64,20 @@ func TestCommandProgram(t *testing.T) { const noProgram = "the editor command names no program" for _, tt := range tests { for _, c := range []struct{ template, env string }{{tt.command, ""}, {"", tt.command}} { - got, err := editor.Resolve(editor.Sources{Template: c.template, Editor: c.env}, "/a", 1) + got, err := editor.CommandFor(editor.Sources{Template: c.template, Editor: c.env}, "/a", 1) switch { case tt.want == "" && (err == nil || err.Error() != noProgram): - t.Errorf("Resolve(%q, %q) error = %v, want %q", c.template, c.env, err, noProgram) + t.Errorf("CommandFor(%q, %q) error = %v, want %q", c.template, c.env, err, noProgram) case tt.want != "" && got.Program != tt.want: - t.Errorf("Resolve(%q, %q).Program = %q, want %q", c.template, c.env, got.Program, tt.want) + t.Errorf("CommandFor(%q, %q).Program = %q, want %q", c.template, c.env, got.Program, tt.want) } } } } -// TestResolveOrder checks that the settings come first, then $VISUAL, -// $EDITOR, and core.editor. A blank value does not count. -func TestResolveOrder(t *testing.T) { +// TestCommandForOrder checks that CommandFor prefers the settings, then +// $VISUAL, $EDITOR and core.editor, and that it skips blank values. +func TestCommandForOrder(t *testing.T) { t.Parallel() tests := []struct { template, visual, env, git string @@ -88,14 +89,14 @@ func TestResolveOrder(t *testing.T) { {"", "", "\t", "hx", "hx"}, } for _, tt := range tests { - got, err := editor.Resolve(editor.Sources{Template: tt.template, Visual: tt.visual, Editor: tt.env, Git: tt.git}, "/a", 1) + got, err := editor.CommandFor(editor.Sources{Template: tt.template, Visual: tt.visual, Editor: tt.env, Git: tt.git}, "/a", 1) if err != nil || got.Program != tt.want { - t.Errorf("Resolve(%q, %q, %q, %q) = %q, %v, want %q", + t.Errorf("CommandFor(%q, %q, %q, %q) = %q, %v, want %q", tt.template, tt.visual, tt.env, tt.git, got.Program, err, tt.want) } } const noEditor = `no editor: set "editor" in rexdiff.lua, $EDITOR, or git's core.editor` - if _, err := editor.Resolve(editor.Sources{Visual: " ", Git: "\n"}, "/a", 1); err == nil || err.Error() != noEditor { - t.Errorf("Resolve with no editor: error = %v, want %q", err, noEditor) + if _, err := editor.CommandFor(editor.Sources{Visual: " ", Git: "\n"}, "/a", 1); err == nil || err.Error() != noEditor { + t.Errorf("CommandFor with no editor: error = %v, want %q", err, noEditor) } } diff --git a/internal/search/match.go b/internal/search/match.go index acf5e21..59002bb 100644 --- a/internal/search/match.go +++ b/internal/search/match.go @@ -5,42 +5,40 @@ import ( "unicode" ) -// Match reports whether text has query, with smart case as in -// herdr-reviewr. A query with an upper case letter matches only the same -// case. Else Match ignores the case of ASCII letters only. An empty query -// matches nothing. -func Match(text, query string) bool { +// Matches reports whether text contains query, using smart case. A query +// with an upper-case letter must match case exactly. Otherwise Matches +// ignores case, but only for ASCII letters. An empty query matches +// nothing. +func Matches(text, query string) bool { return Index(text, query) >= 0 } + +// Index returns the byte offset of the first match of query in text, as +// [Matches] finds it, or -1 if there is none. It takes time linear in the +// lengths of text and query. +func Index(text, query string) int { if query == "" { - return false + return -1 } if strings.ContainsFunc(query, unicode.IsUpper) { - return strings.Contains(text, query) - } - for i := 0; i+len(query) <= len(text); i++ { - if foldEqual(text[i:i+len(query)], query) { - return true - } + return strings.Index(text, query) } - return false + // query has no upper-case letter, so it matches the text with its + // ASCII letters in lower case. Lowering ASCII letters keeps the byte + // offsets. + return strings.Index(lowerASCII(text), query) } -// foldEqual reports whether a and b, which have the same length, are equal -// when ASCII letters ignore case. The bytes of a UTF-8 character other -// than ASCII never fold, so a byte compare is correct. -func foldEqual(a, b string) bool { - for i := range len(a) { - if lowerASCII(a[i]) != lowerASCII(b[i]) { - return false - } - } - return true -} - -// lowerASCII returns the lower case of the ASCII letter c. It returns +// lowerASCII returns s with its ASCII letters in lower case. It leaves the // other bytes as they are. -func lowerASCII(c byte) byte { - if 'A' <= c && c <= 'Z' { - return c + 'a' - 'A' +func lowerASCII(s string) string { + i := strings.IndexFunc(s, func(r rune) bool { return 'A' <= r && r <= 'Z' }) + if i < 0 { + return s + } + b := []byte(s) + for k := i; k < len(b); k++ { + if c := b[k]; 'A' <= c && c <= 'Z' { + b[k] = c + 'a' - 'A' + } } - return c + return string(b) } diff --git a/internal/search/search.go b/internal/search/search.go index 1eb10b3..0e2b1fb 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -1,59 +1,72 @@ -// Package search finds the files of the search screen, and the lines of -// the find band. The limits and the smart case come from herdr-reviewr. +// Package search finds the files shown on the search screen and the lines +// shown in the find bar. package search -import ( - "slices" +import "github.com/sahilm/fuzzy" - "github.com/sahilm/fuzzy" -) - -// The limits of the results. A "… more" row tells that there are others. +// Limits on the number of results. A "… more" row shows that there are +// more. const ( - // MaxFiles is the largest number of files that the search shows. + // MaxFiles is the maximum number of files that the search shows. MaxFiles = 50 - // MaxCodeLines is the largest number of matching lines that the search + // MaxGrepLines is the maximum number of matching lines that the search // shows. - MaxCodeLines = 200 + MaxGrepLines = 200 ) -// Files returns at most limit of paths that match q, the best match -// first, and the number of all the matches. For an empty q, the paths in -// recent come first, in their order, and then the other paths. -// herdr-reviewr ranks the files of an empty query by use. rexdiff keeps -// no history, so recent has the picks of this session. -func Files(paths, recent []string, q string, limit int) (hits []string, total int) { - if q == "" { - for _, p := range recent { - if slices.Contains(paths, p) && !slices.Contains(hits, p) { - hits = append(hits, p) - } - } - total = len(paths) - for _, p := range paths { - if len(hits) >= limit { - break - } - if !slices.Contains(hits, p) { - hits = append(hits, p) - } - } - return hits[:min(len(hits), limit)], total +// Files returns up to limit paths that match query, best match first, and +// the total number of matches. If query is empty, it returns the paths in +// recent first, in their order, followed by the other paths. +func Files(paths, recent []string, query string, limit int) (hits []string, total int) { + if query == "" { + return recentFirst(paths, recent, limit), len(paths) } - ms := fuzzy.Find(q, paths) + ms := fuzzy.Find(query, paths) for _, m := range ms[:min(len(ms), limit)] { hits = append(hits, m.Str) } return hits, len(ms) } -// Indices returns the indices of the labels that match q, the best match -// first. The match is the same fuzzy match as in Files. -func Indices(q string, labels []string) []int { - ms := fuzzy.Find(q, labels) - is := make([]int, len(ms)) +// Indices returns the indices of the labels that match query, best match +// first. It uses the same fuzzy matching as [Files]. +func Indices(query string, labels []string) []int { + ms := fuzzy.Find(query, labels) + indices := make([]int, len(ms)) for i, m := range ms { - is[i] = m.Index + indices[i] = m.Index + } + return indices +} + +// recentFirst returns up to limit paths: the paths in recent first, in +// their order, followed by the other paths. It goes through paths once, +// and stops once it has found every recent path and enough others. +func recentFirst(paths, recent []string, limit int) []string { + isRecent := make(map[string]bool, len(recent)) + for _, p := range recent { + isRecent[p] = true + } + found := make(map[string]bool, len(recent)) // the recent paths in paths + var others []string + for _, p := range paths { + if len(found) == len(isRecent) && len(others) >= limit { + break + } + switch { + case isRecent[p]: + found[p] = true + case len(others) < limit: + others = append(others, p) + } + } + var hits []string + for _, p := range recent { + if found[p] { + hits = append(hits, p) + delete(found, p) // a path listed twice in recent appears once + } } - return is + hits = append(hits, others...) + return hits[:min(len(hits), limit)] } diff --git a/internal/search/search_test.go b/internal/search/search_test.go index 2a6ca13..8ffc13c 100644 --- a/internal/search/search_test.go +++ b/internal/search/search_test.go @@ -3,12 +3,14 @@ package search_test import ( "fmt" "slices" + "strings" "testing" "github.com/rednafi/rexdiff/internal/search" ) -// TestFiles checks the order and the limit of the files. +// TestFiles checks the order, limit and total count of the files that +// Files returns. func TestFiles(t *testing.T) { t.Parallel() paths := []string{"a.go", "b.go", "c/d.go", "main.go"} @@ -24,15 +26,15 @@ func TestFiles(t *testing.T) { want []string wantTotal int }{ - {"empty query", "", paths, nil, 50, paths, 4}, - {"recent first", "", paths, []string{"main.go", "b.go"}, 50, []string{"main.go", "b.go", "a.go", "c/d.go"}, 4}, - {"a gone recent pick", "", paths, []string{"gone.go", "b.go"}, 50, []string{"b.go", "a.go", "c/d.go", "main.go"}, 4}, - {"empty query limit", "", paths, []string{"main.go"}, 2, []string{"main.go", "a.go"}, 4}, - {"fuzzy", "dgo", paths, nil, 50, []string{"c/d.go"}, 1}, - {"recent does not rank a query", "go", paths, []string{"main.go"}, 2, []string{"a.go", "b.go"}, 4}, - {"no match", "zzz", paths, nil, 50, nil, 0}, - // A query shows at most the limit, and counts all the matches. - {"query limit", "f", many, nil, search.MaxFiles, many[:search.MaxFiles], 60}, + {name: "empty query", paths: paths, limit: 50, want: paths, wantTotal: 4}, + {name: "recent first", paths: paths, recent: []string{"main.go", "b.go"}, limit: 50, want: []string{"main.go", "b.go", "a.go", "c/d.go"}, wantTotal: 4}, + {name: "a gone recent pick", paths: paths, recent: []string{"gone.go", "b.go"}, limit: 50, want: []string{"b.go", "a.go", "c/d.go", "main.go"}, wantTotal: 4}, + {name: "empty query limit", paths: paths, recent: []string{"main.go"}, limit: 2, want: []string{"main.go", "a.go"}, wantTotal: 4}, + {name: "fuzzy", q: "dgo", paths: paths, limit: 50, want: []string{"c/d.go"}, wantTotal: 1}, + {name: "recent does not rank a query", q: "go", paths: paths, recent: []string{"main.go"}, limit: 2, want: []string{"a.go", "b.go"}, wantTotal: 4}, + {name: "no match", q: "zzz", paths: paths, limit: 50, wantTotal: 0}, + // A query returns at most limit files, but counts every match. + {name: "query limit", q: "f", paths: many, limit: search.MaxFiles, want: many[:search.MaxFiles], wantTotal: 60}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -46,8 +48,8 @@ func TestFiles(t *testing.T) { } } -// TestMatch checks the smart case of herdr-reviewr. -func TestMatch(t *testing.T) { +// TestMatches checks smart-case matching. +func TestMatches(t *testing.T) { t.Parallel() tests := []struct { text, query string @@ -59,23 +61,76 @@ func TestMatch(t *testing.T) { {"FOO", "foo", true}, {"x := 1", "", false}, {"", "a", false}, - {"ÉCOLE", "école", false}, // only ASCII ignores case + {"ÉCOLE", "école", false}, // case is ignored only for ASCII letters {"école", "école", true}, {"Éa", "Éa", true}, {"\tx", "\tx", true}, {"ab", "abc", false}, } for _, tt := range tests { - got := search.Match(tt.text, tt.query) + got := search.Matches(tt.text, tt.query) if got != tt.want { - t.Errorf("Match(%q, %q) = %v, want %v", + t.Errorf("Matches(%q, %q) = %v, want %v", tt.text, tt.query, got, tt.want) } } } -// TestIndices checks that Indices returns the indices of the labels that -// match, the best match first. +// TestIndex checks the offset of the first smart-case match. +func TestIndex(t *testing.T) { + t.Parallel() + tests := []struct { + text, query string + want int + }{ + {"Foo bar foo", "foo", 0}, + {"xx FOO", "foo", 3}, + {"foo Foo", "Foo", 4}, + {"éA", "a", 2}, // offsets count bytes + {"abc", "", -1}, + {"abc", "d", -1}, + } + for _, tt := range tests { + if got := search.Index(tt.text, tt.query); got != tt.want { + t.Errorf("Index(%q, %q) = %d, want %d", tt.text, tt.query, got, tt.want) + } + } +} + +// longFind returns a line of about 2 MB and a 1001-byte query that +// almost matches it everywhere, the slowest input for a naive search. +func longFind() (text, query string) { + return strings.Repeat("a", 2_000_000), strings.Repeat("a", 1000) + "b" +} + +// TestIndexLong checks a query that almost matches a long line at every +// offset. +func TestIndexLong(t *testing.T) { + t.Parallel() + text, query := longFind() + if got := search.Index(text, query); got != -1 { + t.Errorf("Index = %d, want -1", got) + } + if got := search.Index(text+"A"+query, query); got != len(text)+1 { + t.Errorf("Index = %d, want %d", got, len(text)+1) + } +} + +// BenchmarkMatchesLong measures a search in a long line, as the find bar +// does in a minified file. +func BenchmarkMatchesLong(b *testing.B) { + text, query := longFind() + for _, q := range []string{query, strings.ToUpper(query)} { + b.Run(fmt.Sprintf("upper=%v", q != query), func(b *testing.B) { + for b.Loop() { + search.Matches(text, q) + } + }) + } +} + +// TestIndices checks that Indices returns the indices of the matching +// labels, best match first. func TestIndices(t *testing.T) { t.Parallel() labels := []string{"main feat", "dev", "feature x"} diff --git a/internal/tree/bench_test.go b/internal/tree/bench_test.go new file mode 100644 index 0000000..267abe0 --- /dev/null +++ b/internal/tree/bench_test.go @@ -0,0 +1,19 @@ +package tree_test + +import ( + "fmt" + "testing" + + "github.com/rednafi/rexdiff/internal/tree" +) + +// BenchmarkBuild measures building the rows for a list of 3,000 files. +func BenchmarkBuild(b *testing.B) { + es := make([]tree.Entry, 3000) + for i := range es { + es[i] = tree.Entry{Path: fmt.Sprintf("src/mod%02d/part%d/f%04d.go", i%60, i%5, i), Changed: i%300 == 0} + } + for b.Loop() { + tree.Build(es, nil, true) + } +} diff --git a/internal/turn/turn.go b/internal/turn/turn.go index d925308..c252570 100644 --- a/internal/turn/turn.go +++ b/internal/turn/turn.go @@ -1,59 +1,63 @@ -// Package turn finds the start and the end of each turn of the agents in -// a repository, from the states of the agents. A turn starts when the -// agents go from rest to work. It ends when they rest again. The rules -// come from herdr-reviewr. +// Package turn detects the start and end of each turn of the agents in a +// repository, based on the agents' states. A turn starts when the agents +// go from resting to working, and ends when they rest again. package turn -import "slices" +import ( + "slices" -// state is the work state of a repository. fold makes it from the states -// of all the agents in the repository. + "github.com/rednafi/rexdiff/rex" +) + +// state is the work state of a repository. combine computes it from the +// states of all agents in the repository. type state int const ( - // stateResting tells that each agent rests, or that there is no agent. + // stateResting means that every agent is resting, or that there are no + // agents. stateResting state = iota - // stateWorking tells that at least one agent works. + // stateWorking means that at least one agent is working. stateWorking - // stateHeld tells that no agent works, but one does not rest. For - // example, it waits at a prompt. A turn that is open stays open. + // stateHeld means that no agent is working but at least one is not + // resting, for example because it waits at a prompt. An open turn stays + // open. stateHeld ) -// rests reports whether s is a state between turns. A blocked agent and an -// unknown state are in the middle of a turn. -func rests(s string) bool { return s == "idle" || s == "done" } +// isResting reports whether s is a state between turns. A blocked or +// unknown state counts as part of a turn. +func isResting(s rex.State) bool { return s == rex.StateIdle || s == rex.StateDone } -// fold returns the state of a repository whose agents are in the states -// states. One working agent makes the repository work. -func fold(states ...string) state { - if slices.Contains(states, "working") { +// combine returns the state of a repository whose agents are in the given +// states. A single working agent puts the repository in stateWorking. +func combine(states ...rex.State) state { + if slices.Contains(states, rex.StateWorking) { return stateWorking } - if slices.ContainsFunc(states, func(s string) bool { return !rests(s) }) { + if slices.ContainsFunc(states, func(s rex.State) bool { return !isResting(s) }) { return stateHeld } return stateResting } -// Tracker finds the start and the end of each turn in a series of states. +// Tracker detects the start and end of each turn in a sequence of states. // The zero Tracker is ready to use. type Tracker struct { - // prevResting is true if the last state is resting. It is false at - // first, so the first state starts no turn. + // prevResting is true if the previous state was resting. It starts + // false, so the first state never starts a turn. prevResting bool - // worked is true if the repository works after its last rest. An end - // can come through held, but a start cannot. Thus the two edges keep - // different memories. + // worked is true if the repository has worked since its last rest. It + // stays set while held, because a turn can end after a held state. worked bool } -// Observe takes the states of all the agents in the repository at one -// time. The states are those of the OSC 7501 protocol, for example -// "idle". A turn starts when one agent works after all rest. A turn ends -// when all rest after work, also through a prompt. -func (t *Tracker) Observe(states ...string) (started, ended bool) { - s := fold(states...) +// Observe records the states of all agents in the repository at one +// moment. It reports whether a turn starts or ends. A turn starts when an +// agent starts working after all agents were resting. It ends when all +// agents rest after work, even if a held state came in between. +func (t *Tracker) Observe(states ...rex.State) (started, ended bool) { + s := combine(states...) started = s == stateWorking && t.prevResting ended = t.worked && s == stateResting t.prevResting = s == stateResting @@ -66,6 +70,6 @@ func (t *Tracker) Observe(states ...string) (started, ended bool) { return started, ended } -// Gap tells the Tracker that nobody sees the agents for some time. Then -// the next state cannot start a turn. -func (t *Tracker) Gap() { t.prevResting = false } +// MarkGap tells t that the agents were not observed for some time, so the +// next state cannot start a turn. +func (t *Tracker) MarkGap() { t.prevResting = false } diff --git a/internal/turn/turn_test.go b/internal/turn/turn_test.go index 843757e..49c49b3 100644 --- a/internal/turn/turn_test.go +++ b/internal/turn/turn_test.go @@ -4,44 +4,47 @@ import ( "testing" "github.com/rednafi/rexdiff/internal/turn" + "github.com/rednafi/rexdiff/rex" ) -// The tests come from turn.rs of herdr-reviewr. - -// The states of one agent that give each state of a repository. +// Agent states that put a repository in each of its states. var ( - resting = []string{"idle"} - working = []string{"working"} - held = []string{"blocked"} + resting = []rex.State{"idle"} + working = []rex.State{"working"} + held = []rex.State{"blocked"} ) -// step is one call to a Tracker and the edges that it gives. With gap, -// the step calls Gap and not Observe. +// step is one call to a Tracker and the turn start and end it should +// report. If gap is true, the step calls MarkGap instead of Observe. type step struct { - states []string + states []rex.State gap bool started, ended bool } -// TestFold checks how the states of the agents fold into one state. After a -// rest, only work starts a turn. After work, only a rest ends it. -func TestFold(t *testing.T) { +// TestCombine checks how the states of several agents combine into one +// repository state. After a rest, only work starts a turn. After work, +// only a rest ends it. +func TestCombine(t *testing.T) { t.Parallel() tests := []struct { name string - states []string - // starts tells that the states start a turn after a rest. ends - // tells that they end a turn after work. A held state does neither. + states []rex.State + // starts reports whether the states start a turn after a rest. ends + // reports whether they end a turn after work. A held state does + // neither. starts, ends bool }{ - // No agent rests, so a fresh pane tracks the next turn it sees. + // Having no agents counts as a rest, so a new pane tracks the next + // turn. {"no agent", nil, false, true}, - {"idle and working", []string{"idle", "working"}, true, false}, - {"blocked and working", []string{"blocked", "working"}, true, false}, - {"idle and done", []string{"idle", "done"}, false, true}, - {"blocked and idle", []string{"blocked", "idle"}, false, false}, - // An unknown state never rests, so it starts no turn. - {"unknown", []string{"compacting"}, false, false}, + {"idle and working", []rex.State{"idle", "working"}, true, false}, + {"blocked and working", []rex.State{"blocked", "working"}, true, false}, + {"idle and done", []rex.State{"idle", "done"}, false, true}, + {"blocked and idle", []rex.State{"blocked", "idle"}, false, false}, + // An unknown state never counts as a rest, and it does not start a + // turn. + {"unknown", []rex.State{"compacting"}, false, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -49,19 +52,20 @@ func TestFold(t *testing.T) { var rest turn.Tracker rest.Observe(resting...) if started, _ := rest.Observe(tt.states...); started != tt.starts { - t.Errorf("after a rest: started %v, want %v", started, tt.starts) + t.Errorf("after a rest: Observe(%q) started = %v, want %v", tt.states, started, tt.starts) } var work turn.Tracker work.Observe(resting...) work.Observe(working...) if _, ended := work.Observe(tt.states...); ended != tt.ends { - t.Errorf("after work: ended %v, want %v", ended, tt.ends) + t.Errorf("after work: Observe(%q) ended = %v, want %v", tt.states, ended, tt.ends) } }) } } -// TestTracker checks the edges of a series of states. +// TestTracker checks the turn starts and ends that a Tracker reports for +// sequences of states. func TestTracker(t *testing.T) { t.Parallel() tests := []struct { @@ -72,12 +76,12 @@ func TestTracker(t *testing.T) { {states: nil}, {states: working, started: true}, }}, - // The first state never starts a turn. + // The first state observed never starts a turn. {"start after a rest", []step{ {states: resting}, {states: working, started: true}, }}, - // A prompt in the middle of a turn does not start another turn. + // A prompt in the middle of a turn does not start a new turn. {"held back to work continues", []step{ {states: resting}, {states: working, started: true}, @@ -85,13 +89,14 @@ func TestTracker(t *testing.T) { {states: working}, }}, {"end only after work", []step{ - {states: resting}, // no work before, so no end - {states: working, started: true}, // a start is never an end + {states: resting}, // no earlier work, so no end + {states: working, started: true}, // a start is never also an end {states: held}, // a pause, not an end {states: working}, {states: resting, ended: true}, }}, - // The prompt keeps the turn open. A rest after it ends the turn once. + // The prompt keeps the turn open. A rest after it ends the turn, + // and only once. {"end through held", []step{ {states: resting}, {states: working, started: true}, @@ -99,11 +104,12 @@ func TestTracker(t *testing.T) { {states: resting, ended: true}, {states: resting}, }}, - // The start of this turn is not seen. + // The start of this turn was not observed. {"lone first working starts nothing", []step{ {states: working}, }}, - // Nobody sees the agents in a gap, so the next work starts no turn. + // The agents are not observed during a gap, so the next work does + // not start a turn. {"gap", []step{ {states: resting}, {gap: true}, @@ -116,7 +122,7 @@ func TestTracker(t *testing.T) { var tr turn.Tracker for i, st := range tt.steps { if st.gap { - tr.Gap() + tr.MarkGap() continue } started, ended := tr.Observe(st.states...) diff --git a/rex/rex.go b/rex/rex.go index 8a31eb1..90e59b3 100644 --- a/rex/rex.go +++ b/rex/rex.go @@ -1,22 +1,16 @@ -// Package rex controls the Rex terminal through the rex command. In Rex, a -// session has windows, and a window has blocks. A block is one pane, for -// example a terminal that runs a shell or a coding agent. +// Package rex controls the Rex terminal through the rex command. // -// An agent sends its name and state to Rex with the OSC 7501 escape code, -// for example "pi" and "working". This is the status of the program. A -// [Client] finds the agents in a session, pastes text into an agent, and -// gets each status change. -// -// The caller gives the environment as a value, as in [exec.Cmd.Env]. The -// package finds rex with it and runs rex with it. It does not read the -// environment of the process. +// A Rex session has windows, and a window has blocks, which are panes +// such as a terminal that runs a coding agent. An agent reports its app +// name and state with the OSC 7501 escape sequence. A [Client] lists the +// agents in a session, pastes text into them and watches their status. package rex import ( "bufio" - "bytes" "cmp" "context" + "encoding/json/jsontext" "encoding/json/v2" "fmt" "os" @@ -27,44 +21,46 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/rednafi/rexdiff/internal/proc" ) // Client runs rex commands in one Rex session. type Client struct { - block string // the block of this process, or "" - session string // the ID of the session - bin string // the path of the rex command, or "" for "rex" in PATH - env []string // the environment of rex + block string // the ID of the block that runs this process, or "" + session string // the session ID + bin string // the path of the rex binary, or "" to use "rex" from PATH + env []string // the environment for rex } -// New returns a client for the session in the environment env, for -// example the environment of the process from [os.Environ]. Rex sets -// REX_SESSION and REX_BLOCK in each block. New returns nil if env is not -// the environment of a Rex block. The client runs rex with env. -func New(env []string) *Client { +// New returns a client for the Rex session described by env, such as the +// result of [os.Environ]. It reads REX_SESSION and REX_BLOCK, which Rex +// sets in every block. If REX_SESSION is not set, the process does not run +// in Rex, and New returns nil and false. The client runs rex with env. +func New(env []string) (*Client, bool) { session := proc.Getenv(env, "REX_SESSION") if session == "" { - return nil + return nil, false } - return &Client{ + c := &Client{ block: proc.Getenv(env, "REX_BLOCK"), session: session, - bin: binFromEnv(env), + bin: binPath(env), env: env, } + return c, true } -// Block returns the block of this process, or "" if Rex does not tell it. +// Block returns the ID of the block that runs this process, or "" if it is +// not known. func (c *Client) Block() string { return c.block } -// binFromEnv returns the path of the rex command in env, or "" for the rex -// in PATH. Rex sets REX_BIN_DIR in its terminals. Other programs, for -// example brew, do not have it. If REX_BIN_DIR is empty and PATH has no -// rex, binFromEnv looks in HOME on macOS. It returns the rex of the Rex -// server app, if it is there. -func binFromEnv(env []string) string { +// binPath returns the path of the rex binary to use for env, or "" to use +// rex from PATH. It prefers REX_BIN_DIR, which Rex sets in its terminals. +// If that is not set and PATH has no rex, on macOS it returns the rex +// binary inside the Rex Server app in HOME, if it exists. +func binPath(env []string) string { if dir := proc.Getenv(env, "REX_BIN_DIR"); dir != "" { return filepath.Join(dir, "rex") } @@ -75,7 +71,7 @@ func binFromEnv(env []string) string { if runtime.GOOS != "darwin" || home == "" { return "" } - // The app is in the directory of os.UserConfigDir on macOS. + // On macOS, this is the directory that os.UserConfigDir returns. dir := filepath.Join(home, "Library", "Application Support") bin := filepath.Join(dir, "com.superlogical.rex", "RexServer", "Versions", "Current", "Rex Server.app", "Contents", "MacOS", "rex") if _, err := os.Stat(bin); err != nil { @@ -84,16 +80,16 @@ func binFromEnv(env []string) string { return bin } -// command returns the command "rex args...". +// command returns a command that runs rex with args. func (c *Client) command(ctx context.Context, args ...string) *exec.Cmd { return proc.Command(ctx, c.env, cmp.Or(c.bin, "rex"), args...) } -// runRex runs "rex args..." and returns the output. If rex fails, the -// error is the first line of the error output of rex. -func (c *Client) runRex(ctx context.Context, args ...string) ([]byte, error) { +// run runs rex with args and returns its standard output. If rex fails, +// the error includes the first line of its standard error. +func (c *Client) run(ctx context.Context, args ...string) ([]byte, error) { cmd := c.command(ctx, args...) - var stderr bytes.Buffer + var stderr proc.Head cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { @@ -102,10 +98,10 @@ func (c *Client) runRex(ctx context.Context, args ...string) ([]byte, error) { return out, nil } -// runRexJSON runs "rex args..." and decodes the JSON output into v. An -// error of the decoder names the rex command. -func (c *Client) runRexJSON(ctx context.Context, v any, args ...string) error { - out, err := c.runRex(ctx, args...) +// runJSON runs rex with args and decodes its JSON output into v. A +// decoding error names the rex subcommand. +func (c *Client) runJSON(ctx context.Context, v any, args ...string) error { + out, err := c.run(ctx, args...) if err != nil { return err } @@ -115,15 +111,17 @@ func (c *Client) runRexJSON(ctx context.Context, v any, args ...string) error { return nil } -// request sends a request to the API of the session. It runs -// "rex api call -s SESSION METHOD ARGS", with args as JSON. If reply is -// not nil, request decodes the reply of Rex into it. +// request calls the session API method with args encoded as JSON. If +// reply is not nil, request decodes the response into it. func (c *Client) request(ctx context.Context, method string, args, reply any) error { - body, err := json.Marshal(args) + // Rex encodes the request again, with its own escaping, so whether + // this escapes HTML characters does not change the size Rex measures. + // Leaving them unescaped keeps the command line short and readable. + body, err := json.Marshal(args, jsontext.EscapeForHTML(false)) if err != nil { return err } - out, err := c.runRex(ctx, "api", "call", "-s", c.session, method, string(body)) + out, err := c.run(ctx, "api", "call", "-s", c.session, method, string(body)) if err != nil || reply == nil { return err } @@ -133,41 +131,40 @@ func (c *Client) request(ctx context.Context, method string, args, reply any) er return nil } -// Status is the status that a program sends to Rex with OSC 7501. +// Status is the status that a program reports to Rex with OSC 7501. type Status struct { - App string // the name of the program, for example "pi" or "claude-code" + App string // the program name, for example "pi" or "claude-code" State State - At time.Time // when Rex got the status, or zero if Rex does not tell + At time.Time // when Rex received the status, or zero if Rex does not say } -// State tells what a program does. The zero State means that the program -// sends no status. +// State describes what a program is doing. The zero State means that the +// program reports no status. type State string -// The known states of the OSC 7501 protocol. A State can have other -// values, for example "error". +// The states that the OSC 7501 protocol defines. A State can also have +// other values, such as "error". const ( - StateIdle State = "idle" // waits for the next instruction of the user - StateWorking State = "working" // does a task - StateDone State = "done" // waits for the user to see the done task - StateBlocked State = "blocked" // waits for an action of the user + StateIdle State = "idle" // waiting for the user's next instruction + StateWorking State = "working" // working on a task + StateDone State = "done" // finished a task that the user has not seen yet + StateBlocked State = "blocked" // waiting for the user to act ) -// statusEntry is a status as Rex sends it. A program can send one status -// for itself and one for each of its sub-agents. The status of the -// program itself has no ID. +// statusEntry is a status as Rex returns it. A program can report one +// status for itself, with an empty ID, and one for each of its sub-agents. type statusEntry struct { ID string `json:"id"` State State `json:"state"` App string `json:"app"` - // EffectiveApp is the name that Rex shows. It can differ from App. + // EffectiveApp is the name that Rex displays. It can differ from App. EffectiveApp string `json:"effective_app"` UpdatedAt string `json:"updated_at"` // RFC 3339 } -// status returns the entry as a Status, with the name that Rex shows. +// status converts e to a Status, using the name that Rex displays. func (e statusEntry) status() Status { - at, _ := time.Parse(time.RFC3339Nano, e.UpdatedAt) // zero if bad + at, _ := time.Parse(time.RFC3339Nano, e.UpdatedAt) // zero if the time does not parse return Status{ App: cmp.Or(e.EffectiveApp, e.App), State: e.State, @@ -175,15 +172,15 @@ func (e statusEntry) status() Status { } } -// Status returns the status of the program in block. If the program sends -// no status, Status returns the zero Status. The statuses of the -// sub-agents of the program do not count, as in [Client.Watch]. +// Status returns the status of the program in block, or the zero Status if +// the program reports none. Like [Client.Events], it ignores the statuses +// of sub-agents. func (c *Client) Status(ctx context.Context, block string) (Status, error) { var reply struct { Entries []statusEntry `json:"records"` } args := []string{"block", "call", "-s", c.session, "-b", block, "program_status"} - if err := c.runRexJSON(ctx, &reply, args...); err != nil { + if err := c.runJSON(ctx, &reply, args...); err != nil { return Status{}, err } if i := slices.IndexFunc(reply.Entries, func(e statusEntry) bool { return e.ID == "" }); i >= 0 { @@ -197,38 +194,37 @@ func (c *Client) Cwd(ctx context.Context, block string) (string, error) { type process struct { Cwd string `json:"cwd"` // a path, or a file:// URL } - // The foreground process is the program that holds the terminal, for - // example an agent that runs in a shell. The child is the shell. + // Foreground is the process that owns the terminal, for example an + // agent started from a shell. Child is the shell itself. var reply struct { Foreground process `json:"foreground"` Child process `json:"child"` } args := []string{"block", "call", "-s", c.session, "-b", block, "process"} - if err := c.runRexJSON(ctx, &reply, args...); err != nil { + if err := c.runJSON(ctx, &reply, args...); err != nil { return "", err } cwd := cmp.Or(reply.Foreground.Cwd, reply.Child.Cwd) return strings.TrimPrefix(cwd, "file://"), nil } -// Agent is a block whose program sends a status with an app name. +// Agent is a block whose program reports a status with an app name. type Agent struct { - Block string // the ID of the block - Name string // the label of the block, or else the app name - Window string // the label of the window of the block, or "" + Block string // the block ID + Name string // the block label, or the app name if the block has no label + Window string // the label of the block's window, or "" Cwd string // the working directory of the program Status Status } -// terminalCreator is the creator that Rex gives to a terminal block. -// Other blocks, for example a browser, cannot run an agent. +// terminalCreator is the creator name of terminal blocks. Only a terminal +// block can run an agent. const terminalCreator = "com.superlogical.terminal" -// Agents returns the agents in the session, in the order of the layout. -// It does not return the block of the client. If ctx ends, Agents returns -// the cause of ctx. +// Agents returns the agents in the session in layout order, leaving out +// the client's own block. If ctx ends, Agents returns the cause of ctx. func (c *Client) Agents(ctx context.Context) ([]Agent, error) { - // Get the windows at the same time as the blocks. + // List the windows while the blocks are being listed. var windows []window var wg sync.WaitGroup wg.Go(func() { windows = c.windows(ctx) }) @@ -246,8 +242,8 @@ func (c *Client) Agents(ctx context.Context) ([]Agent, error) { return nil, err } - // Ask each terminal block for its status at the same time. A block is - // an agent if its program sends an app name. + // Fetch the status of each terminal block in parallel. A block is an + // agent if its program reports an app name. agents := make([]Agent, len(reply.Blocks)) for i, b := range reply.Blocks { if b.ID == c.block || b.Creator != terminalCreator { @@ -258,8 +254,7 @@ func (c *Client) Agents(ctx context.Context) ([]Agent, error) { if err != nil || status.App == "" { return } - // If Rex cannot tell the directory, Cwd is "". The agent stays - // in the list. + // If Cwd fails, keep the agent with an empty working directory. cwd, _ := c.Cwd(ctx, b.ID) agents[i] = Agent{ Block: b.ID, @@ -271,8 +266,8 @@ func (c *Client) Agents(ctx context.Context) ([]Agent, error) { } wg.Wait() if ctx.Err() != nil { - // A status call that fails at the deadline does not tell that the - // block is not an agent. + // A status call that failed because ctx ended does not mean that + // the block is not an agent. return nil, context.Cause(ctx) } @@ -289,20 +284,19 @@ type window struct { Label string `json:"label"` } -// windows returns the windows of the session. If rex fails, windows returns -// nil. +// windows returns the windows of the session, or nil if rex fails. func (c *Client) windows(ctx context.Context) []window { var reply struct { Windows []window `json:"windows"` } - if c.runRexJSON(ctx, &reply, "session", "inspect", c.session, "--json") != nil { + if c.runJSON(ctx, &reply, "session", "inspect", c.session, "--json") != nil { return nil } return reply.Windows } -// windowLabel returns the label of the window with the ID id. If no window -// has this ID, it returns "". +// windowLabel returns the label of the window with the given id, or "" if +// there is no such window. func windowLabel(windows []window, id string) string { if i := slices.IndexFunc(windows, func(w window) bool { return w.ID == id }); i >= 0 { return windows[i].Label @@ -310,9 +304,9 @@ func windowLabel(windows []window, id string) string { return "" } -// Resolve returns the ID of the block that target names. The target is a -// block ID, a block label, or the end of a block ID. -func (c *Client) Resolve(ctx context.Context, target string) (string, error) { +// ResolveBlock returns the ID of the block that target refers to. target +// can be a block ID, a block label or the end of a block ID. +func (c *Client) ResolveBlock(ctx context.Context, target string) (string, error) { if strings.HasPrefix(target, "block:") { return target, nil } @@ -321,16 +315,56 @@ func (c *Client) Resolve(ctx context.Context, target string) (string, error) { ID string `json:"block_id"` } `json:"block"` } - err := c.runRexJSON(ctx, &reply, "block", "inspect", target, "-s", c.session, "--json") + err := c.runJSON(ctx, &reply, "block", "inspect", target, "-s", c.session, "--json") if err != nil { return "", err } return reply.Block.ID, nil } -// Paste pastes text into the program in block, as if the user pasted it. -// Paste does not press Enter, so the program does not submit the text. +// maxPastePart is the most bytes that the text of one paste event may take +// once Rex encodes it as JSON. Rex refuses a control message larger than +// 65,536 bytes, and the rest of the message takes a few hundred bytes, so +// this leaves a wide margin. +const maxPastePart = 48 << 10 + +// PasteError is the error from a [Client.Paste] that failed after it had +// pasted some parts of the text. +type PasteError struct { + Pasted, Parts int // the number of parts pasted, and of all parts + Err error // the error from the part that failed +} + +// Error returns the counts of parts and the error of the part that failed. +func (e *PasteError) Error() string { + return fmt.Sprintf("pasted only %d of %d parts: %v", e.Pasted, e.Parts, e.Err) +} + +// Unwrap returns e.Err. +func (e *PasteError) Unwrap() error { return e.Err } + +// Paste pastes text into the program in block as if the user had pasted +// it. Paste does not press Enter, so the program does not submit the text. +// +// Rex limits the size of one paste, so Paste splits a long text into parts +// and pastes them in order, which the program sees as several pastes. It +// splits only between characters, after a newline where it can. If a part +// fails after others were pasted, the error is a [*PasteError]. func (c *Client) Paste(ctx context.Context, block, text string) error { + parts := pasteParts(text, maxPastePart) + for i, part := range parts { + if err := c.pastePart(ctx, block, part); err != nil { + if i == 0 { + return err + } + return &PasteError{Pasted: i, Parts: len(parts), Err: err} + } + } + return nil +} + +// pastePart pastes text with one paste event. +func (c *Client) pastePart(ctx context.Context, block, text string) error { type paste struct { Text string `json:"text"` } @@ -348,6 +382,52 @@ func (c *Client) Paste(ctx context.Context, block, text string) error { return c.request(ctx, "block.event", args, nil) } +// pasteParts splits text into parts whose JSON encoding, as Rex measures +// it, takes at most limit bytes. Each part ends between two characters, +// after the last newline that fits if it is in the second half of the +// part. It returns one part for a text that fits, including the empty text. +func pasteParts(text string, limit int) []string { + var parts []string + for { + end, newline, size := 0, 0, 0 + for end < len(text) { + r, n := utf8.DecodeRuneInString(text[end:]) + size += encodedLen(r, n) + if size > limit && end > 0 { + break + } + end += n + if r == '\n' { + newline = end + } + } + if end == len(text) { + return append(parts, text) + } + if newline > end/2 { + end = newline + } + parts = append(parts, text[:end]) + text = text[end:] + } +} + +// encodedLen returns the most bytes that the rune r, which takes n bytes +// of the text, can take in a JSON string. Rex encodes like Go's +// encoding/json package, which escapes the HTML characters <, > and &, the +// line and paragraph separators and most control characters as \uXXXX, +// and replaces an invalid byte with \ufffd. +func encodedLen(r rune, n int) int { + switch { + case r == '"' || r == '\\' || r == '\n' || r == '\r' || r == '\t': + return 2 + case r < 0x20 || r == '<' || r == '>' || r == '&' || + r == '\u2028' || r == '\u2029' || r == utf8.RuneError && n == 1: + return 6 + } + return n +} + // Focus moves the keyboard focus to block. func (c *Client) Focus(ctx context.Context, block string) error { args := struct { @@ -365,17 +445,17 @@ func (c *Client) SetLabel(ctx context.Context, block, label string) error { return c.request(ctx, "session.set_block_label", args, nil) } -// Split opens a new terminal block next to the block of the client. The -// new block runs the command argv in the directory cwd. +// Split opens a new terminal block next to the client's block and runs +// the command argv in it, in the directory cwd. func (c *Client) Split(ctx context.Context, cwd string, argv ...string) error { args := []string{"split", "-s", c.session, "-b", c.block, "--cwd", cwd, "--"} - _, err := c.runRex(ctx, append(args, argv...)...) + _, err := c.run(ctx, append(args, argv...)...) return err } -// ConfigPath returns the path of the configuration file of Rex in the -// environment env. This is $REX_CONFIG if it is set. If not, it is -// init.lua in the rex directory of $XDG_CONFIG_HOME, or of $HOME/.config. +// ConfigPath returns the path of the Rex configuration file for env. It is +// $REX_CONFIG if set, or else rex/init.lua in $XDG_CONFIG_HOME or +// $HOME/.config. func ConfigPath(env []string) string { if path := proc.Getenv(env, "REX_CONFIG"); path != "" { return path @@ -387,45 +467,46 @@ func ConfigPath(env []string) string { return filepath.Join(dir, "rex", "init.lua") } -// ReloadConfig tells Rex to read its configuration file again. It runs -// rex with the environment env. It also works outside a Rex session. +// ReloadConfig tells Rex to read its configuration file again. It runs rex +// with env and also works outside a Rex session. func ReloadConfig(ctx context.Context, env []string) error { - c := &Client{bin: binFromEnv(env), env: env} - _, err := c.runRex(ctx, "config", "reload") + c := &Client{bin: binPath(env), env: env} + _, err := c.run(ctx, "config", "reload") return err } -// Event is a change to a block in the session, or a reconnect. +// Event is a change to a block in the session, or a notice that +// [Client.Events] has restarted rex. type Event struct { - Block string // the ID of the block + Block string // the block ID Closed bool // true if the block closed - // Status is the new status of the program in the block. If the program - // removes its status, Status is the zero Status. + // Status is the new status of the program in the block, or the zero + // Status if the program removed its status. Status Status - // Reconnect is true if Watch starts rex again. The events between the - // two runs of rex are lost. A Reconnect event has no block. + // Reconnect is true if Events restarted rex. Events that happened while + // rex was not running are lost. A Reconnect event has no block. Reconnect bool - // Err is the error of a run of rex that fails, with the first line - // of its stderr. An event with Err has no block. Watch starts rex - // again after the event. + // Err is the error from a failed rex run, including the first line of + // its standard error. An event with Err has no block, and Events + // restarts rex after it. Err error } const ( - // reconnectDelay is the time that Watch waits before it starts rex again. + // reconnectDelay is how long Events waits before it restarts rex. reconnectDelay = 2 * time.Second - // maxEventLine is the length of the longest event line that Watch reads. + // maxEventLine is the longest event line, in bytes, that Events accepts. maxEventLine = 1 << 20 ) -// Watch calls fn for each event in the session, from one goroutine. It -// runs "rex events" to get the events. If rex fails, Watch calls fn with -// an event that has the error. If rex stops, Watch starts it again after -// 2 seconds. Then it calls fn with a Reconnect event before the next -// events. Watch returns the cause of ctx when ctx is done. -func (c *Client) Watch(ctx context.Context, fn func(Event)) error { +// Events runs "rex events" and calls fn for each event in the session, +// from a single goroutine. If rex fails, Events calls fn with an event +// that holds the error. When rex stops, Events restarts it after +// 2 seconds and reports a Reconnect event before any new events. +// Events returns the cause of ctx when ctx is done. +func (c *Client) Events(ctx context.Context, fn func(Event)) error { for again := false; ; again = true { - if err := c.watchOnce(ctx, again, fn); err != nil && ctx.Err() == nil { + if err := c.eventsOnce(ctx, again, fn); err != nil && ctx.Err() == nil { fn(Event{Err: err}) } select { @@ -436,12 +517,12 @@ func (c *Client) Watch(ctx context.Context, fn func(Event)) error { } } -// watchOnce runs "rex events" and calls fn for each event until rex stops. -// If again is true, the first event is a Reconnect event. It returns the -// error of rex, with the first line of its stderr. -func (c *Client) watchOnce(ctx context.Context, again bool, fn func(Event)) error { +// eventsOnce runs "rex events" once and calls fn for each event until rex +// stops. If again is true, it first reports a Reconnect event. The +// returned error includes the first line of rex's standard error. +func (c *Client) eventsOnce(ctx context.Context, again bool, fn func(Event)) error { cmd := c.command(ctx, "events", "-s", c.session, "--json") - var stderr bytes.Buffer + var stderr proc.Head cmd.Stderr = &stderr out, err := cmd.StdoutPipe() if err != nil { @@ -461,8 +542,8 @@ func (c *Client) watchOnce(ctx context.Context, again bool, fn func(Event)) erro } } if err := lines.Err(); err != nil { - // A line is too long. Stop rex, because rex can wait until Watch - // reads the rest of its output. + // A line is too long. Kill rex, because it can block until someone + // reads its output. _ = cmd.Process.Kill() _ = cmd.Wait() return fmt.Errorf("rex events: %w", err) @@ -473,23 +554,24 @@ func (c *Client) watchOnce(ctx context.Context, again bool, fn func(Event)) erro return nil } -// blockEvent is an event of one block in "rex events --json". +// blockEvent is an event about one block in the output of +// "rex events --json". type blockEvent struct { Block string `json:"block_id"` Name string `json:"name"` // for example "program_status_changed" Data statusData `json:"payload"` } -// statusData is the data of a status event. Changed has the new status. -// Removed has the IDs of the removed statuses. +// statusData is the payload of a status event. Changed holds the new +// status and Removed holds the IDs of the removed statuses. type statusData struct { ID string `json:"id"` Changed statusEntry `json:"record"` Removed []string `json:"ids"` } -// parseEvent decodes one line of "rex events --json". It returns false if -// the line is not an event that Watch reports. +// parseEvent decodes one line of "rex events --json" output. It returns +// false if the line is not an event that Events reports. func parseEvent(line []byte) (Event, bool) { var msg struct { Payload struct { @@ -513,9 +595,9 @@ func parseEvent(line []byte) (Event, bool) { return Event{}, false } -// statusEvent returns the change of status in b. It reports only the -// status of the program itself, which has no ID. It ignores the statuses -// of its sub-agents. It returns false if b changes no such status. +// statusEvent returns the status change in b. It reports only changes to +// the program's own status, which has an empty ID, and ignores sub-agents. +// It returns false if b does not change that status. func statusEvent(b blockEvent) (Event, bool) { ev := Event{Block: b.Block} switch b.Name { diff --git a/rex/rex_test.go b/rex/rex_test.go index 93db25a..eb2a951 100644 --- a/rex/rex_test.go +++ b/rex/rex_test.go @@ -2,6 +2,7 @@ package rex_test import ( "context" + jsonv1 "encoding/json" "errors" "fmt" "os" @@ -13,14 +14,15 @@ import ( "testing" "testing/synctest" "time" + "unicode/utf8" "github.com/rednafi/rexdiff/internal/testgit" "github.com/rednafi/rexdiff/rex" ) -// client writes a rex script with body in a new directory. It returns the -// client of New in the environment of the block block:self in the session -// S, and the directory. +// client writes a fake rex script with the given body to a new directory. +// It returns a client for the block block:self in the session S that runs +// the script, and the directory. func client(t *testing.T, body string) (*rex.Client, string) { t.Helper() dir := t.TempDir() @@ -31,16 +33,16 @@ func client(t *testing.T, body string) (*rex.Client, string) { "REX_BLOCK=block:self", "REX_BIN_DIR=" + dir, } - c := rex.New(env) - if c == nil || c.Block() != "block:self" { + c, ok := rex.New(env) + if !ok || c.Block() != "block:self" { t.Fatalf("New(%q) = %+v, want the block block:self", env, c) } return c, dir } -// fakeRex returns a client of a rex script that answers with recorded -// output. The script logs each call to the file "calls" in its directory, -// and prints the file "events" for "rex events". +// fakeRex returns a client that runs a fake rex script with canned +// responses. The script appends each call to the file "calls" in its +// directory, and for "rex events" it prints the file "events". func fakeRex(t *testing.T) (*rex.Client, string) { t.Helper() script := `printf "%s\n" "$*" >> "${0%/*}/calls" @@ -84,8 +86,9 @@ esac return client(t, script) } -// TestAgents checks that Agents returns only the terminal blocks that send -// an app name, without the block of the client. +// TestAgents checks that Agents returns only the terminal blocks whose +// program reports an app name, leaves out the client's own block, and +// fills in the window label and working directory. func TestAgents(t *testing.T) { t.Parallel() c, _ := fakeRex(t) @@ -114,21 +117,22 @@ func TestAgents(t *testing.T) { } } -// TestCommands checks the rex calls of Resolve, Paste, Focus, Split, and -// SetLabel. +// TestCommands checks the rex commands that ResolveBlock, Paste, Focus, +// Split and SetLabel run, and the error that ResolveBlock returns for an +// unknown block. func TestCommands(t *testing.T) { t.Parallel() c, dir := fakeRex(t) ctx := t.Context() - if id, err := c.Resolve(ctx, "agent"); err != nil || id != "block:pi" { - t.Errorf("Resolve(agent) = %q, %v, want block:pi, nil", id, err) + if id, err := c.ResolveBlock(ctx, "agent"); err != nil || id != "block:pi" { + t.Errorf("ResolveBlock(agent) = %q, %v, want block:pi, nil", id, err) } - if id, _ := c.Resolve(ctx, "block:x"); id != "block:x" { - t.Errorf("Resolve(block:x) = %q, want block:x", id) + if id, _ := c.ResolveBlock(ctx, "block:x"); id != "block:x" { + t.Errorf("ResolveBlock(block:x) = %q, want block:x", id) } - _, err := c.Resolve(ctx, "nosuch") + _, err := c.ResolveBlock(ctx, "nosuch") if err == nil || err.Error() != "no block matches nosuch" { - t.Errorf("Resolve(nosuch): err = %v, want %q", + t.Errorf("ResolveBlock(nosuch): err = %v, want %q", err, "no block matches nosuch") } if err := c.Paste(ctx, "block:pi", "a\n\tb"); err != nil { @@ -162,22 +166,22 @@ func TestCommands(t *testing.T) { } } -// blockEvent returns a line of "rex events --json" for an event of block -// with the name and the payload. +// blockEvent returns a "rex events --json" line for an event with the +// given name and payload in block. func blockEvent(block, name, payload string) string { return `{"type":"session_event","payload":{"session_id":"S",` + `"event":{"type":"block_event","payload":{"block_id":"` + block + `","name":"` + name + `","payload":` + payload + `}}}}` } -// TestWatch checks that Watch reports only the changes of the status of -// the program, with the time of Rex, and closed blocks. The fake rex stops -// after the events, so Watch reports a reconnect and then the events -// again. -func TestWatch(t *testing.T) { +// TestEvents checks that Events reports changes to the program's own +// status, with the time from Rex, and closed blocks, and ignores sub-agent +// statuses and lines that are not JSON. It also checks that Events sends a +// Reconnect event after rex stops and then reports the events again. +func TestEvents(t *testing.T) { t.Parallel() - // synctest has a fake clock. Thus the test does not wait 2 seconds - // before rex starts again. + // synctest uses a fake clock, so the test does not wait 2 seconds for + // rex to restart. synctest.Test(t, func(t *testing.T) { c, dir := fakeRex(t) events := strings.Join([]string{ @@ -188,7 +192,7 @@ func TestWatch(t *testing.T) { blockEvent("block:pi", "program_status_changed", `{"id":"sub","record":{"state":"idle"}}`), `not json`, - // The removal of a sub record is not a change of the agent. + // Removing a sub-agent status is not reported. blockEvent("block:pi", "program_status_removed", `{"ids":["sub"],"reason":"clear"}`), blockEvent("block:pi", "program_status_removed", @@ -205,18 +209,18 @@ func TestWatch(t *testing.T) { {Block: "block:pi", Closed: true}, } want := slices.Concat(once, []rex.Event{{Reconnect: true}}, once) - // Stop Watch when it reports all the events. + // Stop Events once it has reported every expected event. ctx, cancel := context.WithCancelCause(t.Context()) defer cancel(fmt.Errorf("test: done: %w", context.Canceled)) errAll := errors.New("the test has all the events") var got []rex.Event - err := c.Watch(ctx, func(e rex.Event) { + err := c.Events(ctx, func(e rex.Event) { if got = append(got, e); len(got) == len(want) { cancel(errAll) } }) if !errors.Is(err, errAll) { - t.Errorf("Watch = %v, want the cause %v", err, errAll) + t.Errorf("Events = %v, want the cause %v", err, errAll) } if !slices.Equal(got, want) { t.Errorf("events = \n%+v\nwant:\n%+v", got, want) @@ -224,18 +228,19 @@ func TestWatch(t *testing.T) { }) } -// TestNew checks that New returns nil outside Rex. The client of the -// other tests comes from New in a fake block. +// TestNew checks that New returns nil and false when REX_SESSION is +// missing or empty. func TestNew(t *testing.T) { t.Parallel() for _, env := range [][]string{nil, {"REX_SESSION=S", "REX_SESSION="}} { - if c := rex.New(env); c != nil { - t.Errorf("New(%q) outside Rex = %+v, want nil", env, c) + if c, ok := rex.New(env); c != nil || ok { + t.Errorf("New(%q) outside Rex = %+v, %t, want nil, false", env, c, ok) } } } -// TestConfigPath checks the places of the configuration file of Rex. +// TestConfigPath checks that ConfigPath prefers REX_CONFIG, then +// XDG_CONFIG_HOME, then HOME. func TestConfigPath(t *testing.T) { t.Parallel() tests := []struct { @@ -253,15 +258,15 @@ func TestConfigPath(t *testing.T) { } } -// TestReloadConfigOutsideRex checks that ReloadConfig runs the rex of the -// Rex server app outside a Rex terminal. +// TestReloadConfigOutsideRex checks that, outside a Rex terminal, +// ReloadConfig runs the rex binary inside the Rex Server app. func TestReloadConfigOutsideRex(t *testing.T) { t.Parallel() if runtime.GOOS != "darwin" { t.Skip("the Rex server app is on macOS") } - // Outside a Rex terminal, for example under brew, REX_BIN_DIR is not set - // and rex is not in PATH. + // Outside a Rex terminal, for example when brew runs rexdiff, + // REX_BIN_DIR is not set and rex is not in PATH. home := t.TempDir() env := []string{"HOME=" + home, "REX_BIN_DIR=", "PATH=" + t.TempDir()} dir := filepath.Join(home, "Library", "Application Support", "com.superlogical.rex", "RexServer", "Versions", "Current", "Rex Server.app", "Contents", "MacOS") @@ -282,8 +287,8 @@ func TestReloadConfigOutsideRex(t *testing.T) { } } -// TestErrorKeepsCause checks that the error of a failed rex is the message -// of rex. The error also wraps the exit error of the process. +// TestErrorKeepsCause checks that when rex fails, the error has rex's +// message and wraps the [*exec.ExitError]. func TestErrorKeepsCause(t *testing.T) { t.Parallel() c, _ := client(t, "echo 'Error: no Rex server' >&2\nexit 3\n") @@ -291,6 +296,81 @@ func TestErrorKeepsCause(t *testing.T) { ee, ok := errors.AsType[*exec.ExitError](err) if err == nil || err.Error() != "no Rex server" || !ok || ee.ExitCode() != 3 { t.Errorf("Focus error = %v, exit error %v, want %q wrapping exit 3", - err, ok, "no Rex server") + err, ee, "no Rex server") + } +} + +// TestPasteLong checks that Paste sends a text that Rex would refuse in one +// message as several paste events, in order, each well under Rex's limit +// of 65,536 bytes once encoded, and that the parts join to the text. Each +// part holds whole characters and, as the lines are short, ends after a +// newline. +func TestPasteLong(t *testing.T) { + t.Parallel() + // Each call saves its last argument, the JSON arguments, to a file. + c, dir := client(t, `d="${0%/*}" +n=$(ls "$d" | grep -c '^call') +for last; do :; done +printf '%s' "$last" > "$d/call$n" +`) + // Rex escapes each of <, & and > as 6 bytes, so this text of 150 KB + // takes 600 KB once encoded. The other characters take 1 to 6 bytes. + text := strings.Repeat("<&>\n", 12_500) + strings.Repeat("日本é\u2028\x01\"\\\t\n", 5000) + "日本" + if err := c.Paste(t.Context(), "block:pi", text); err != nil { + t.Fatal(err) + } + var parts []string + for i := 0; ; i++ { + b, err := os.ReadFile(filepath.Join(dir, fmt.Sprintf("call%d", i))) + if errors.Is(err, os.ErrNotExist) { + if i < 2 { + t.Fatalf("Paste made %d calls, want several", i) + } + break + } + if err != nil { + t.Fatal(err) + } + var args struct { + Event struct { + Type string + Payload struct{ Text string } + } + } + if err := jsonv1.Unmarshal(b, &args); err != nil { + t.Fatalf("call %d: %v", i, err) + } + // Rex encodes the text again, escaping HTML characters. + encoded, _ := jsonv1.Marshal(args.Event.Payload.Text) + if n := len(encoded); n > 60_000 { + t.Errorf("call %d holds %d bytes once encoded, want at most 60,000", i, n) + } + parts = append(parts, args.Event.Payload.Text) + } + for i, p := range parts[:len(parts)-1] { + if !utf8.ValidString(p) || !strings.HasSuffix(p, "\n") { + t.Errorf("part %d ends with %q, want whole characters and a newline", i, p[max(0, len(p)-8):]) + } + } + if got := strings.Join(parts, ""); got != text { + t.Errorf("the pasted parts join to %d bytes that differ from the %d of the text", + len(got), len(text)) + } +} + +// TestPastePartFails checks that when a part after the first fails, Paste +// returns a PasteError that says how many parts were pasted. +func TestPastePartFails(t *testing.T) { + t.Parallel() + c, _ := client(t, `d="${0%/*}" +echo >> "$d/calls" +if [ "$(wc -l < "$d/calls")" -ge 2 ]; then echo "Error: block closed" >&2; exit 1; fi +`) + err := c.Paste(t.Context(), "block:pi", strings.Repeat("x", 200_000)) + if pe, ok := errors.AsType[*rex.PasteError](err); !ok || pe.Pasted != 1 || pe.Parts < 3 { + t.Fatalf("Paste: err = %v, want a PasteError after 1 part of several", err) + } + if !strings.Contains(err.Error(), "block closed") { + t.Errorf("Paste: err = %q, want rex's message in it", err) } } diff --git a/test/caps_test.go b/test/caps_test.go new file mode 100644 index 0000000..22ebb66 --- /dev/null +++ b/test/caps_test.go @@ -0,0 +1,315 @@ +package test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// markdownRepo returns a repository whose only change is README.md, set +// to text. +func markdownRepo(t *testing.T, text string) string { + t.Helper() + repo := newRepo(t) + testgit.Git(t, repo, "checkout", "--", "a.go", "long.txt") + testgit.WriteFile(t, repo, "README.md", text) + return repo +} + +// rendered is the flag that shows Markdown rendered from the start. +var rendered = []string{"-config", `{"markdown_view": "rendered"}`} + +// TestMarkdownTooLarge checks that a Markdown file too large to render +// shows as its source diff, with a note in the title. +func TestMarkdownTooLarge(t *testing.T) { + text := "# Big title\n\n" + strings.Repeat("Some *text* in a paragraph.\n\n", 20000) // about 600 KB + tm := start(t, markdownRepo(t, text), opts{args: rendered}) + tm.waitFor("README.md", "markdown too large to render", "# Big title", "Some *text*") +} + +// TestMarkdownBlockTooLarge checks that a rendered Markdown file shows a +// block too large to render as its source, and that reference links +// still resolve in the other blocks. +func TestMarkdownBlockTooLarge(t *testing.T) { + table := "| a | b |\n|---|---|\n" + strings.Repeat("| *x* | y |\n", 7000) // about 80 KB + text := "# Title\n\nSee [the docs][Docs].\n\n[docs]: https://example.com/docs\n\n" + table + tm := start(t, markdownRepo(t, text), opts{args: rendered}) + s := tm.waitFor("See the docs", "https://example.com/docs", "block too large to render", "| *x* | y |") + if strings.Contains(s, "# Title") || strings.Contains(s, "[docs]:") { + t.Errorf("the small blocks show as source. The screen shows:\n%s", s) + } +} + +// TestMarkdownTooDeep checks that rendered Markdown shows blocks whose +// quotes or lists nest very deeply as source, at start-up and at any +// width, instead of freezing while glamour renders them. +func TestMarkdownTooDeep(t *testing.T) { + text := "# Title\n\n" + strings.Repeat("> ", 100) + "deep quote\n\n" + + strings.Repeat("- ", 300) + "deep list\n\nAfter the *lists*.\n" + for _, width := range []uint16{40, 120, 300} { + t.Run(fmt.Sprintf("%d columns", width), func(t *testing.T) { + tm := start(t, markdownRepo(t, text), opts{args: rendered, width: width}) + s := tm.waitFor("Title", "nested too deeply", "> > >") + if strings.Contains(s, "# Title") { + t.Errorf("the heading shows as source. The screen shows:\n%s", s) + } + // At 40 columns, the source of the blocks fills more than the + // screen. + tm.keys(":", "$", "enter") + tm.waitFor("After the lists.", "- - -") + }) + } +} + +// nestedList returns a list nested depth levels deep, with each level +// indented to the text of the item above, which starts with marker. +func nestedList(marker string, depth int) string { + var b strings.Builder + for d := range depth { + fmt.Fprintf(&b, "%s%s item %d\n", strings.Repeat(" ", d*(len(marker)+1)), marker, d) + } + return b.String() +} + +// TestMarkdownDepth checks where rendered Markdown draws the line on +// nesting: deep but ordinary lists, quotes and code render, while blocks +// nested more than 12 deep, however they hide it, show as source with a +// note. +func TestMarkdownDepth(t *testing.T) { + const note = "nested too deeply" + fence := "```\n" + strings.Repeat(" ", 26) + "deep code 26\n" + + strings.Repeat(" ", 40) + "deep code 40\n```\n" + for _, c := range []struct { + name string + block string + deep bool // whether the block shows as source with the note + whole bool // whether the block takes in the whole file + shows []string // text the screen shows + hides []string // text the screen does not show + }{ + {name: "ordered list 9 deep", block: nestedList("1.", 9), shows: []string{"item 8"}}, + {name: "ordered list 12 deep", block: nestedList("1.", 12), shows: []string{"item 11"}}, + {name: "bullet list 12 deep", block: nestedList("-", 12), shows: []string{"item 11"}, hides: []string{"- item"}}, + {name: "quote 12 deep", block: strings.Repeat("> ", 12) + "deep quote\n", shows: []string{"deep quote"}, hides: []string{"> >"}}, + {name: "indented fenced code", block: fence, shows: []string{"deep code 26", "deep code 40"}, hides: []string{"```"}}, + {name: "fenced code in a list", block: "- item\n\n " + strings.ReplaceAll(fence, "\n", "\n "), shows: []string{"deep code 40"}, hides: []string{"```"}}, + {name: "indented code", block: " code\n" + strings.Repeat(" ", 34) + "deep code 30\n", shows: []string{"deep code 30"}}, + // Within a quote, a deep quote is code inside the fence. + {name: "quote in a fence in a quote", block: "> ```\n" + strings.Repeat("> ", 50) + "x\n", shows: []string{"> > >"}}, + + {name: "quote 13 deep", block: strings.Repeat("> ", 13) + "deep\n", deep: true, shows: []string{"> > >"}}, + {name: "quote 100 deep", block: strings.Repeat("> ", 100) + "deep\n", deep: true, shows: []string{"> > >"}}, + {name: "list 13 deep", block: strings.Repeat("- ", 13) + "deep\n", deep: true, shows: []string{"- - -"}}, + {name: "list 300 deep", block: strings.Repeat("- ", 300) + "deep\n", deep: true, shows: []string{"- - -"}}, + {name: "ordered list 13 deep", block: nestedList("1.", 13), deep: true, shows: []string{"1. item 12"}}, + {name: "mixed", block: strings.Repeat("> - 1. ", 30) + "deep\n", deep: true, shows: []string{"> - 1. > - 1."}}, + // A list item ends the code block of the item above, so the list + // that follows is not hidden in the fence. + {name: "list after an unclosed fence", block: "- ```\n" + nestedList("-", 13), deep: true, shows: []string{"- item 12"}}, + // Indented four columns, the fence is code and opens no block. + // Being indented, it joins the heading's block, and the fence takes + // in the rest of the file. + {name: "indented code is not a fence", block: " ```\n" + strings.Repeat("- ", 20) + "x\n", deep: true, whole: true, shows: []string{"- - -"}}, + } { + t.Run(c.name, func(t *testing.T) { + text := "# Title\n\n" + c.block + "\nAfter the *end*.\n" + tm := start(t, markdownRepo(t, text), opts{args: rendered}) + after := "After the end." + if c.whole { + after = "After the *end*." + } + s := tm.waitFor(append([]string{"Title", after}, c.shows...)...) + if !c.whole && strings.Contains(s, "# Title") { + t.Errorf("the heading shows as source. The screen shows:\n%s", s) + } + if got := strings.Contains(s, note); got != c.deep { + t.Errorf("the screen shows %q: %v, want %v. The screen shows:\n%s", note, got, c.deep, s) + } + if hasAny(s, c.hides...) { + t.Errorf("the screen shows one of %q. It shows:\n%s", c.hides, s) + } + }) + } +} + +// TestMarkdownLinkDefsTooLarge checks that link definitions that many +// references repeat do not freeze rendered Markdown. A file that would +// take too much work shows as source with a note, and a long definition is +// not added to the blocks that refer to it. +func TestMarkdownLinkDefsTooLarge(t *testing.T) { + t.Run("many references", func(t *testing.T) { + var b strings.Builder + b.WriteString("# Refs\n\n") + for i := range 3000 { + fmt.Fprintf(&b, "[d%d]\n\n", i%3) + } + for i := range 3 { + fmt.Fprintf(&b, "[d%d]: https://example.com/%s\n", i, strings.Repeat("a", 1900)) + } + tm := start(t, markdownRepo(t, b.String()), opts{args: rendered}) + tm.waitFor("README.md", "markdown too large to render", "# Refs") + }) + t.Run("long definition", func(t *testing.T) { + text := "# Refs\n\n" + strings.Repeat("See [d].\n\n", 1000) + + "[d]: https://example.com/" + strings.Repeat("a", 60000) + "\n" + tm := start(t, markdownRepo(t, text), opts{args: rendered}) + s := tm.waitFor("Refs", "See [d].") + if strings.Contains(s, "# Refs") || strings.Contains(s, "aaaa") { + t.Errorf("the file shows as source or with the long link. The screen shows:\n%s", s) + } + }) +} + +// prRepo returns a repository with a GitHub remote and a directory of +// fake tools whose gh prints a pull request with the given body. +func prRepo(t *testing.T, body string) (repo, bin string) { + t.Helper() + repo = newRepo(t) + testgit.Git(t, repo, "remote", "add", "origin", "https://github.com/o/r.git") + data, err := json.Marshal(map[string]any{ + "number": 7, "title": "Hostile PR", "state": "OPEN", "url": "https://github.com/o/r/pull/7", + "body": body, "author": map[string]string{"login": "ann"}, + "headRefName": "feat", "baseRefName": "main", + }) + if err != nil { + t.Fatal(err) + } + bin = tools(t, map[string]string{"gh": `cat "${0%/*}/pr.json"` + "\n"}) + if err := os.WriteFile(filepath.Join(bin, "pr.json"), data, 0o600); err != nil { + t.Fatal(err) + } + return repo, bin +} + +// TestPRMarkdownTooHard checks that the PR tab shows a body that nests too +// deeply, or that repeats a long link definition too often, as source +// instead of freezing. +func TestPRMarkdownTooHard(t *testing.T) { + for name, c := range map[string]struct{ body, note string }{ + "deep quote": {strings.Repeat("> ", 100) + "deep", "text nested too deeply to render"}, + "deep list": {strings.Repeat("- ", 300) + "deep", "text nested too deeply to render"}, + "references": { + strings.Repeat("[d] ", 2000) + "\n\n[d]: https://example.com/" + strings.Repeat("a", 5000), + "text too large to render", + }, + } { + t.Run(name, func(t *testing.T) { + repo, bin := prRepo(t, c.body) + tm := start(t, repo, opts{path: bin}) + tm.keys("3") + tm.waitFor("Hostile PR #7", c.note) + }) + } +} + +// TestPRTooLarge checks that the PR tab cuts long comments, with a note +// to open the PR, and that thousands of
tags in them do not +// stop the tab from showing. +func TestPRTooLarge(t *testing.T) { + repo := newRepo(t) + testgit.Git(t, repo, "remote", "add", "origin", "https://github.com/o/r.git") + hostile := strings.Repeat("
", 3600) // 65 KB + var comments []map[string]any + for i := range 20 { + comments = append(comments, map[string]any{ + "author": map[string]string{"login": fmt.Sprint("user", i)}, "body": hostile, + "createdAt": "2024-01-01T00:00:00Z", + }) + } + data, err := json.Marshal(map[string]any{ + "number": 7, "title": "Big PR", "state": "OPEN", "url": "https://github.com/o/r/pull/7", + "body": "The body.", "author": map[string]string{"login": "ann"}, + "headRefName": "feat", "baseRefName": "main", "comments": comments, + }) + if err != nil { + t.Fatal(err) + } + bin := tools(t, map[string]string{"gh": `cat "${0%/*}/pr.json"` + "\n"}) + if err := os.WriteFile(filepath.Join(bin, "pr.json"), data, 0o600); err != nil { + t.Fatal(err) + } + tm := start(t, repo, opts{path: bin}) + tm.keys("3") + s := tm.waitFor("Big PR #7", "The body.", "user0", "… truncated · o to open in browser") + if strings.Contains(s, "
") { + t.Errorf("the comments show their tags. The screen shows:\n%s", s) + } +} + +// TestTallLine checks that page down, half page down and down move +// through the rows of a wrapped line taller than the screen, and then on +// to the next line. +func TestTallLine(t *testing.T) { + repo := newRepo(t) + testgit.Git(t, repo, "checkout", "--", "long.txt") + var b strings.Builder + b.WriteString("package a\n\n// START") + for i := range 4000 { + fmt.Fprintf(&b, " w%d", i) + } + b.WriteString(" THE-END\nvar next = 1\n") + testgit.WriteFile(t, repo, "a.go", b.String()) + log := filepath.Join(t.TempDir(), "log") + tm := start(t, repo, opts{args: []string{"-log", log}}) + // A frame that is partly drawn has no footer, a gap in the words, or a + // blank last line where the terminal scrolled the old lines up. + whole := func(s string) bool { + last, _, _ := strings.Cut(lastBodyLine(s), "│") + return strings.Contains(s, "q quit") && wordsInOrder(s) && strings.TrimSpace(last) != "" + } + // The file list gets wider when the line counts arrive, which wraps + // the line again, so wait for them first. + s := tm.waitUntil(func(s string) bool { + return strings.Contains(s, "// START") && sameLine(s, "M a.go", "+2 -3") && whole(s) + }, "show the start of the line and its counts") + // The line takes about 200 rows. Each half page moves the view. + for i := 1; !strings.Contains(s, "THE-END"); i++ { + if i == 40 { + t.Fatalf("40 half pages do not reach the end of the line. The screen shows:\n%s", s) + } + tm.keys("ctrl+d") + // Wait until rexdiff has read the key, and then for its frame. + waitFile(t, log, fmt.Sprintf("%d keys", i), func(l string) bool { return strings.Count(l, "msg=key") == i }) + prev := s + s = tm.waitUntil(func(s string) bool { return s != prev && whole(s) }, "move the view") + } + if strings.Contains(s, "// START") || strings.Contains(s, "var next") { + t.Errorf("the screen shows more than the end of the line:\n%s", s) + } + tm.keys("pgup") + tm.waitGone("THE-END") + tm.keys("pgdown") + tm.waitFor("THE-END") + tm.keys("j") + tm.waitCursor("var next") +} + +// lastBodyLine returns the line above the footer of screen s. +func lastBodyLine(s string) string { + lines := strings.Split(strings.TrimRight(s, "\n "), "\n") + if len(lines) < 2 { + return "" + } + return lines[len(lines)-2] +} + +// wordsInOrder reports whether the words w0, w1, … on screen s follow +// each other with no gap. +func wordsInOrder(s string) bool { + words := regexp.MustCompile(`\bw(\d+)\b`).FindAllStringSubmatch(s, -1) + for i := 1; i < len(words); i++ { + prev, _ := strconv.Atoi(words[i-1][1]) + n, _ := strconv.Atoi(words[i][1]) + if n != prev+1 { + return false + } + } + return len(words) > 0 +} diff --git a/test/changes_test.go b/test/changes_test.go index 49ebc43..6a38408 100644 --- a/test/changes_test.go +++ b/test/changes_test.go @@ -5,34 +5,57 @@ import ( "path/filepath" "slices" "strings" + "syscall" "testing" + "github.com/charmbracelet/x/ansi" + "github.com/rednafi/rexdiff/internal/testgit" ) -// TestChanges checks the Changes tab. It checks the file list with its -// counts, the diff of a file, and the next and the previous file. It also -// checks a fold that opens. +// TestChanges checks that the Changes tab lists the changed files with +// their added and removed line counts and shows the diff of the selected +// file, that f and F move to the next and previous file, and that right +// and left expand and collapse a fold of unchanged lines. func TestChanges(t *testing.T) { tm := start(t, newRepo(t), opts{}) tm.waitFor("Changes (2)", "M a.go", "+2 -1", "M long.txt", "+2 -2", - "return 1", "x := 2", "not in Rex") + "return 1", "x := 2", "not in Rex", "a.go +2 -1") tm.keys("f") - tm.waitFor("first", "last", "⋯ 32 unmodified lines") + tm.waitFor("first", "last", "⋯ 32 unmodified lines", "long.txt +2 -2") tm.keys("j", "j", "j", "j", "j", "j") tm.waitFor("→ expand fold") tm.keys("right") - tm.waitFor("line 20") + tm.waitFor("line 20", "← collapse fold") tm.waitGone("unmodified lines") + tm.keys("left") + tm.waitFor("⋯ 32 unmodified lines", "→ expand fold") + tm.waitGone("line 20") + tm.keys("right") + tm.waitFor("line 20") tm.keys("F") tm.waitFor("x := 2") } -// TestMove checks the keys that move the cursor. They go to the next and -// the previous change, a page, half a page, a line, and a line number. +// TestCollapseSelection checks that collapsing a fold that contains the +// selected lines clears the selection. +func TestCollapseSelection(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.keys("f", "j", "j", "j", "j", "j", "j", "right") + tm.waitFor("line 20", "← collapse fold") + tm.keys("j", "v", "j", "j") + tm.waitFor("extend", "clear") + tm.keys("left") + tm.waitFor("⋯ 32 unmodified lines", "→ expand fold") + tm.waitGone("extend") +} + +// TestMove checks the keys that move the cursor: ] and [ to the next and +// previous change, page and half-page keys, : to go to a line number, and +// the arrow keys. func TestMove(t *testing.T) { tm := start(t, newRepo(t), opts{height: 16}) - tm.keys("f", "j", "j", "j", "j", "j", "j", "right") // all of long.txt + tm.keys("f", "j", "j", "j", "j", "j", "j", "right") // expand the fold to show all of long.txt tm.waitFor("line 10") tm.keys("]", "]") tm.waitCursor("line 40") @@ -56,11 +79,13 @@ func TestMove(t *testing.T) { tm.waitCursor("first") } -// TestScopes checks that each scope shows its changes, and the pickers of -// the commits and the base. +// TestScopes checks that the branch, uncommitted and last-turn scopes +// show the right changes, that the commit picker shows a single commit, +// and that the base picker marks the current base and switches to +// another one. func TestScopes(t *testing.T) { repo := newRepo(t) - // origin/main hides main in the base picker. + // In the base picker, origin/main replaces main. testgit.Git(t, repo, "update-ref", "refs/remotes/origin/main", "main") tm := start(t, repo, opts{}) tm.keys("b") @@ -87,9 +112,58 @@ func TestScopes(t *testing.T) { tm.waitFor("vs feat", "Changes (2)") } -// TestFlags checks that -scope, -base, -layout, and -wrap set the start, -// and that -log writes a log. As in herdr-reviewr, -wrap off cuts a long -// line at the start. +// TestSlowCommitPicker checks that while the commit list loads, the +// commit picker shows that it is loading, q does not quit, and esc closes +// the picker for good: the end of the cancelled list does not open it +// again. +func TestSlowCommitPicker(t *testing.T) { + // The fake git lists no commits until the test lets it. The first list + // notes in the file ready that it can be stopped. It waits until rexdiff + // stops it and then notes that in the file stopped. + // The second waits for the FIFO gate. Then it runs the real git, which + // is next on PATH. + bin := tools(t, map[string]string{"git": `d="${0%/*}" +case "$*" in +*--first-parent*) + if mkdir "$d/first" 2>/dev/null; then + trap 'kill $!; echo stopped > "$d/stopped"; exit 1' TERM + sleep 60 & + echo ready > "$d/ready" + wait + fi + cat "$d/gate" >/dev/null ;; +esac +PATH=${PATH#*:} exec git "$@" +`}) + gate := filepath.Join(bin, "gate") + if err := syscall.Mkfifo(gate, 0o600); err != nil { + t.Fatal(err) + } + log := filepath.Join(t.TempDir(), "log") + tm := start(t, newRepo(t), opts{path: bin, args: []string{"-log", log}}) + tm.keys("G") + tm.waitFor("loading commits…", "esc cancel") + waitFile(t, filepath.Join(bin, "ready"), "ready", func(s string) bool { return s == "ready\n" }) + tm.keys("esc") + tm.waitGone("loading commits…") + waitFile(t, filepath.Join(bin, "stopped"), "stopped", func(s string) bool { return s == "stopped\n" }) + tm.keys("G") + tm.waitFor("loading commits…") + tm.keys("q", "2") + // Let the list end once rexdiff has read G, esc, G, q and 2. + waitFile(t, log, "five keys", func(s string) bool { return strings.Count(s, "msg=key") == 5 }) + if err := os.WriteFile(gate, nil, 0o600); err != nil { + t.Fatal(err) + } + tm.waitFor("commits · 1 over main", "add b") + tm.keys("esc") + tm.waitGone("commits · 1 over main") + tm.waitFor("1 Changes", "Changes (") +} + +// TestFlags checks that -scope, -base, -layout and -wrap set the initial +// state, that -wrap off does not wrap a long line, and that -log writes a +// log file. func TestFlags(t *testing.T) { repo := newRepo(t) long := "var long = \"" + strings.Repeat("ab", 100) + "end\"\n" @@ -98,10 +172,11 @@ func TestFlags(t *testing.T) { tm := start(t, repo, opts{width: 220, args: []string{ "-scope", "branch", "-base", "main", "-layout", "split", "-wrap", "off", "-log", log, }}) - s := tm.waitFor("vs main", "Changes (3)") - if !sameLine(s, "return 1", "x := 2") { - t.Errorf("the screen does not show return 1 beside x := 2. It shows:\n%s", s) - } + // Wait for the whole frame, because the top lines can appear before the + // rest. + tm.waitUntil(func(s string) bool { + return hasAll(s, "vs main", "Changes (3)") && sameLine(s, "return 1", "x := 2") + }, "show return 1 beside x := 2 vs main") tm.keys("f") if s := tm.waitFor("var long"); strings.Contains(s, "end\"") { t.Errorf("-wrap off wraps the long line. The screen shows:\n%s", s) @@ -115,8 +190,8 @@ func TestFlags(t *testing.T) { } } -// TestSplit checks that | shows the old and the new lines side by side -// on a wide screen. On a narrow screen, it tells why it cannot. +// TestSplit checks that | shows the old and new lines side by side on a +// wide screen, and shows a message instead on a narrow screen. func TestSplit(t *testing.T) { tm := start(t, newRepo(t), opts{width: 220}) tm.keys("|") @@ -128,8 +203,9 @@ func TestSplit(t *testing.T) { narrow.waitFor("too narrow for split") } -// TestWrap checks that rexdiff wraps a long line, that w cuts it, and -// that → scrolls to its end. +// TestWrap checks that rexdiff wraps a long line by default, that w turns +// wrapping off, and that the right arrow then scrolls to the end of the +// line. func TestWrap(t *testing.T) { repo := newRepo(t) long := "var long = \"" + strings.Repeat("ab", 60) + "end\"\n" @@ -139,14 +215,14 @@ func TestWrap(t *testing.T) { tm.waitFor("end\"") tm.keys("w") tm.waitGone("end\"") - // Each → scrolls 8 columns. 12 of them show the end of the line, and - // keep its start in the view. + // Each right arrow scrolls 8 columns, so 12 presses reach the end of + // the line. tm.keys(slices.Repeat([]string{"right"}, 12)...) tm.waitFor("end\"") } -// column returns the column of text in the first line of s that has it, -// or -1. +// column returns the column of the first occurrence of text in s, or -1 +// if s does not contain text. func column(s, text string) int { if x, _, ok := position(s, text); ok { return x @@ -155,7 +231,7 @@ func column(s, text string) int { } // TestFileList checks that < and > change the width of the file list, p -// moves it, z hides it, and tab shows it again. +// moves it to the bottom, z hides it, and tab shows it again. func TestFileList(t *testing.T) { tm := start(t, newRepo(t), opts{}) right := column(tm.waitFor("Changes (2)"), "Changes (2)") @@ -163,18 +239,19 @@ func TestFileList(t *testing.T) { tm.waitUntil(func(s string) bool { return column(s, "Changes (2)") < right }, "widen the list") tm.keys(">", ">") tm.waitUntil(func(s string) bool { return column(s, "Changes (2)") > right }, "narrow the list") - tm.keys("p") // bottom + tm.keys("p") // move the list to the bottom tm.waitUntil(func(s string) bool { _, y, ok := position(s, "Changes (2)") return ok && y > 10 }, "show the list at the bottom") tm.keys("z") tm.waitGone("Changes (2)") - tm.keys("tab") // the focus shows a hidden list + tm.keys("tab") // moving the focus to a hidden list shows it tm.waitFor("Changes (2)") } -// TestKeys checks that ? shows all the keys. +// TestKeys checks that ? opens the help with all the keys and esc closes +// it. func TestKeys(t *testing.T) { tm := start(t, newRepo(t), opts{}) tm.keys("?") @@ -183,22 +260,87 @@ func TestKeys(t *testing.T) { tm.waitGone("pick commits") } -// TestFind checks that ctrl+f counts the lines with a text, and that -// enter moves to a hit. +// TestFind checks that ctrl+f counts the lines that contain the typed +// text, that enter moves the cursor to the first match, and that a pasted +// \r\n becomes a single space. func TestFind(t *testing.T) { tm := start(t, newRepo(t), opts{}) tm.keys("f", "ctrl+f") tm.typed("line 3") - // line 3, and line 30 to line 39 + // "line 3" matches line 3 and lines 30 to 39. tm.waitUntil(func(s string) bool { return sameLine(s, "find line 3", " 11") }, "count 11 lines") tm.keys("enter") tm.waitCursor("line 3") + tm.keys("esc") + tm.waitGone("find line 3") + tm.keys("ctrl+f") + tm.paste("line\r\n3") + tm.waitUntil(func(s string) bool { return sameLine(s, "find line 3", "1/11") }, + "count 11 lines for the pasted query") +} + +// TestFindInFiles checks that ctrl+f finds text in a file of the Files +// tab, and in a Markdown file shown rendered. +func TestFindInFiles(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.keys("2") + tm.waitFor("Files (6)") + tm.click("long.txt", 1) + tm.waitFor("line 37") + tm.keys("ctrl+f") + tm.typed("line 3") + tm.keys("enter") + tm.waitCursor("line 3") + tm.keys("esc") + tm.waitGone("find line 3") + tm.click("README.md", 1) + tm.keys("m") + tm.waitGone("# Title") + tm.keys("ctrl+f") + tm.typed("text") + tm.keys("enter") + tm.waitCursor("Some text.") +} + +// TestFindNothing checks that ctrl+f opens the find bar on a binary file, +// which has no text to find, so that the typed text goes into the bar and +// does not run commands such as t, which switches the scope. +func TestFindNothing(t *testing.T) { + repo := newRepo(t) + testgit.WriteFile(t, repo, "bin.dat", "a\x00b\n") + tm := start(t, repo, opts{}) + tm.keys("f") + tm.waitFor("bin.dat binary") + tm.keys("ctrl+f") + tm.typed("test") + tm.waitUntil(func(s string) bool { return sameLine(s, "find test", "nothing to find here") }, + "the query in the find bar with a note") +} + +// TestBranchNoCommits checks that the branch scope in a repository with +// no commits says so in plain words, as the commit picker does, and that +// a long error keeps its indent on every line when it wraps. +func TestBranchNoCommits(t *testing.T) { + repo := filepath.Join(t.TempDir(), "repo") + testgit.InitRepo(t, repo) + testgit.WriteFile(t, repo, "a.txt", "a\n") + tm := start(t, repo, opts{args: []string{"-scope", "branch"}}) + tm.waitFor("no commits yet") + tm.waitGone("merge-base") + + tm = start(t, newRepo(t), opts{width: 50, args: []string{"-scope", "branch", "-base", "nosuchbranch"}}) + s := tm.waitFor("no merge base with") + _, y, _ := position(s, "no merge base with") + ls := strings.Split(s, "\n") + if len(ls) <= y+1 || !strings.HasPrefix(ls[y+1], " ") || strings.TrimSpace(ls[y+1]) == "" { + t.Errorf("the error does not wrap with its indent. The screen shows:\n%s", s) + } } -// TestSettings checks that the settings change the layout and the marks -// of the changed lines. They also change the place and the width of the -// file list, the scope, and the keys. +// TestSettings checks that -config settings change the layout, the +/- +// signs, the position and width of the file list, the default scope, and +// the key bindings. func TestSettings(t *testing.T) { tm := start(t, newRepo(t), opts{width: 220, args: []string{ "-config", @@ -220,7 +362,8 @@ func TestSettings(t *testing.T) { tm.waitFor("var B = 2") } -// TestReviewPrefix checks that review_prefix comes before the review. +// TestReviewPrefix checks that the review_prefix setting is added before +// the copied review. func TestReviewPrefix(t *testing.T) { bin := clipboard(t) tm := start(t, newRepo(t), opts{path: bin, env: clipEnv, args: []string{ @@ -229,13 +372,13 @@ func TestReviewPrefix(t *testing.T) { }}) comment(tm, "hi") tm.keys("y") - waitFile(t, filepath.Join(bin, "clip"), func(s string) bool { + waitFile(t, filepath.Join(bin, "clip"), "the review after \"Please fix:\"", func(s string) bool { return s == "Please fix:\n\n"+review }) } -// TestTinyScreen checks that rexdiff draws and takes keys in a small -// terminal. +// TestTinyScreen checks that rexdiff draws its screen and responds to keys +// in a 40 by 8 terminal. func TestTinyScreen(t *testing.T) { tm := start(t, newRepo(t), opts{width: 40, height: 8}) tm.keys("f", "j", "2", "/") @@ -250,8 +393,8 @@ func TestTinyScreen(t *testing.T) { tm.waitFor("1 Changes") } -// TestHostileText checks that the escape codes in a file show as text, so -// a file cannot change the terminal. +// TestHostileText checks that escape sequences in a file are shown as +// visible symbols and do not affect the terminal. func TestHostileText(t *testing.T) { repo := newRepo(t) testgit.WriteFile(t, repo, "evil.txt", "a\x1b[2Jb\x1b]0;title\x07c\n") @@ -259,3 +402,49 @@ func TestHostileText(t *testing.T) { tm.keys("f") tm.waitFor("a␛[2Jb␛]0;title␇c") } + +// TestTinyStackedNavigator checks that a file list placed above or below +// the main pane is hidden when the terminal is too short for it, and that +// no line is wider than the terminal. +func TestTinyStackedNavigator(t *testing.T) { + for _, pos := range []string{"top", "bottom"} { + t.Run(pos, func(t *testing.T) { + const width = 60 + tm := start(t, newRepo(t), opts{width: width, height: 6, args: []string{ + "-config", `{"file_list": "` + pos + `"}`, + }}) + s := tm.waitFor("a.go", "package a") + if strings.Contains(s, "Changes (2)") { + t.Errorf("the list shows with no rows for it:\n%s", s) + } + for i, l := range strings.Split(s, "\n") { + if w := ansi.StringWidth(l); w > width { + t.Errorf("line %d is %d columns wide, want %d or less: %q", i, w, width, l) + } + } + }) + } +} + +// TestSkippedCounts checks that the file list and the title say why a +// binary or too large file has no counts. +func TestSkippedCounts(t *testing.T) { + repo := newRepo(t) + testgit.WriteFile(t, repo, "bin.dat", "a\x00b\n") + testgit.WriteFile(t, repo, "huge.txt", strings.Repeat("x\n", 60_000)) + tm := start(t, repo, opts{}) + tm.waitUntil(func(s string) bool { + return sameLine(s, "bin.dat", "binary") && sameLine(s, "huge.txt", "too large") + }, "show binary and too large in the list") +} + +// TestEmptyFile checks that a new empty file shows in the list with no +// counts, and that its diff says that the content did not change. +func TestEmptyFile(t *testing.T) { + repo := newRepo(t) + testgit.WriteFile(t, repo, "empty.txt", "") + tm := start(t, repo, opts{}) + tm.waitUntil(func(s string) bool { return sameLine(s, "A empty.txt", "+0 -0") }, "list empty.txt with +0 -0") + tm.keys("f") + tm.waitFor("empty.txt +0 -0", "no change to the content") +} diff --git a/test/checks_test.go b/test/checks_test.go new file mode 100644 index 0000000..3b9c839 --- /dev/null +++ b/test/checks_test.go @@ -0,0 +1,37 @@ +package test + +import ( + "regexp" + "testing" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// TestPRManyChecks checks that the PR tab counts every check, not only the +// 100 that it keeps, and names the failures past the first 100 checks: +// gh reports 100 passing checks and then 150 failing ones. +func TestPRManyChecks(t *testing.T) { + repo := newRepo(t) + testgit.Git(t, repo, "remote", "add", "origin", "https://github.com/o/r.git") + bin := tools(t, map[string]string{ + "gh": `printf '{"number": 7, "title": "Many checks", "state": "OPEN", "statusCheckRollup": [' +i=0 +while [ $i -lt 250 ]; do + c=SUCCESS + [ $i -ge 100 ] && c=FAILURE + [ $i -gt 0 ] && printf ',' + printf '{"name": "check%d", "conclusion": "%s"}' $i $c + i=$((i + 1)) +done +printf ']}' +`, + }) + // The terminal is tall enough for the whole list. + tm := start(t, repo, opts{path: bin, height: 120}) + tm.keys("3") + s := tm.waitFor("Many checks #7", "checks: ✓ 100 ✗ 150 ◌ 0", + "✗ check100", "✗ check199", "… 50 more failed checks") + if regexp.MustCompile(`check(99|200)\b`).MatchString(s) { + t.Errorf("the PR tab lists a passing check or more than 100 checks. The screen shows:\n%s", s) + } +} diff --git a/test/cli_test.go b/test/cli_test.go index 9e11f59..c5043ef 100644 --- a/test/cli_test.go +++ b/test/cli_test.go @@ -12,18 +12,17 @@ import ( "github.com/rednafi/rexdiff/internal/testgit" ) -// fakeRexCLI is the rex of the scripts. +// fakeRexCLI is the fake rex command that the test scripts use. const fakeRexCLI = `#!/bin/sh printf '%s\n' "$*" >> "$WORK/rex.log" if [ -n "$REX_FAIL" ]; then echo 'no Rex server' >&2; exit 1; fi echo 'config reloaded' ` -// TestScripts runs the scripts in testdata/script. A script runs the -// rexdiff binary and a fake rex. The fake writes its arguments to -// $WORK/rex.log, and fails if $REX_FAIL is set. If REXDIFF_REX is the -// path of a real rex, $REALREX is that rex, and the condition [realrex] is -// true. +// TestScripts runs the scripts in testdata/script with the rexdiff binary +// and a fake rex. The fake rex appends its arguments to $WORK/rex.log and +// fails if $REX_FAIL is set. If REXDIFF_REX is the path of a real rex, +// $REALREX is set to that path and the condition [realrex] is true. func TestScripts(t *testing.T) { bin := filepath.Dir(binary(t)) realRex := os.Getenv("REXDIFF_REX") @@ -40,8 +39,8 @@ func TestScripts(t *testing.T) { if err != nil { return err } - // $WORK is also $HOME, so rexdiff and git see no file of the - // user. + // Use $WORK as $HOME too, so rexdiff and git see none of the + // user's files. env.Setenv("HOME", env.WorkDir) env.Setenv("XDG_CONFIG_HOME", "") env.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) diff --git a/test/comment_test.go b/test/comment_test.go index 263999a..2b67c3c 100644 --- a/test/comment_test.go +++ b/test/comment_test.go @@ -1,6 +1,8 @@ package test import ( + "encoding/json/v2" + "fmt" "os" "path/filepath" "slices" @@ -11,10 +13,13 @@ import ( "github.com/rednafi/rexdiff/internal/testgit" ) -// review is the text of the comment "hi" on the first change of a.go. +// review is the review text that rexdiff produces for the comment "hi" +// on the first change in a.go. const review = "a.go:4 (removed)\n-\treturn 1\nhi" -// comment adds the comment text on the first changed line of a.go. +// comment moves to the first change in the current file, adds a comment +// with the given text, and waits until it is shown. In the fixture, the +// first file is a.go. func comment(tm *term, text string) { tm.t.Helper() tm.keys("]", "c") @@ -23,23 +28,23 @@ func comment(tm *term, text string) { tm.waitFor("💬 " + text) } -// TestCommentList checks that a comment shows on its line and in the -// list. n and N move to it. The list edits the comment and deletes it. As -// in herdr-reviewr, an edit keeps the place of the comment in the list. An -// edit with blank text keeps the comment. +// TestCommentList checks that a comment appears on its line and in the +// comment list, that n and N move to the next and previous comment, and +// that the list can edit and delete comments. Editing a comment keeps its +// position in the list, and saving empty text keeps the old text. func TestCommentList(t *testing.T) { tm := start(t, newRepo(t), opts{}) comment(tm, "hi") tm.waitFor("comment added", "●1") tm.keys("k") tm.waitCursor("func A") - tm.keys("n") // n stays in the file, as in herdr-reviewr + tm.keys("n") // n stays in the current file tm.waitCursor("return 1") tm.keys("j", "j") tm.waitCursor("return x") tm.keys("N") tm.waitCursor("return 1") - tm.keys("c", "z", "enter") // a second comment on the line + tm.keys("c", "z", "enter") // a second comment on the same line tm.waitFor("💬 z") tm.keys("l") tm.waitFor("Comments (2)", "a.go:4 (removed) hi") @@ -51,20 +56,50 @@ func TestCommentList(t *testing.T) { if _, other, _ := position(s, "(removed) z"); edited > other { t.Errorf("the edit moves the comment down. The screen shows:\n%s", s) } - // The flash of the edit hides the badge of the draft, so wait for - // the keys of the box. + // The flash message after the edit hides the draft badge, so wait for + // the comment box's key hints instead. tm.keys("e") tm.waitFor("enter save") tm.keys(slices.Repeat([]string{"bs"}, len("hi more"))...) tm.keys("enter") tm.waitGone("enter save") tm.waitFor("a.go:4 (removed) hi more") - tm.keys("d", "d") // the list closes with its last comment + tm.keys("d", "d") // deleting the last comment closes the list tm.waitGone("Comments (", "💬") } -// TestCommentDrag checks that a drag on the line numbers comments on a -// range of lines. +// TestCommentListPages checks that the page and half-page keys move the +// cursor in the comment list. +func TestCommentListPages(t *testing.T) { + repo := filepath.Join(t.TempDir(), "repo") + testgit.InitRepo(t, repo) + testgit.WriteFile(t, repo, "u.txt", numbered("line", 1, 30)) + testgit.Git(t, repo, "add", ".") + testgit.Git(t, repo, "commit", "-qm", "first") + tm := start(t, repo, opts{}) + tm.keys("2") + tm.waitCursor("line 1") + for i := 1; i <= 20; i++ { + tm.keys("c") + tm.typed(fmt.Sprintf("note %d", i)) + tm.keys("enter", "j") + } + tm.waitFor("20 unsent comments") + tm.keys("l") + tm.waitFor("Comments (20)") + for _, step := range []struct{ key, want string }{ + {"pgdown", "u.txt:16 note 16"}, + {"ctrl+u", "u.txt:8 note 8"}, + {"ctrl+d", "u.txt:16 note 16"}, + {"pgup", "u.txt:1 note 1"}, + } { + tm.keys(step.key) + tm.waitCursor(step.want) + } +} + +// TestCommentDrag checks that dragging over the line numbers starts a +// comment on that range of lines. func TestCommentDrag(t *testing.T) { tm := start(t, newRepo(t), opts{}) tm.drag(" 1 1 ", " 3 3 ") @@ -73,20 +108,20 @@ func TestCommentDrag(t *testing.T) { tm.waitGone("✎ a.go:1-3") } -// TestCommentBox checks that the box takes more than one line, and that -// esc drops a new comment. It also checks that v comments on a range of -// lines, and that e does not end the range. +// TestCommentBox checks that the comment box accepts more than one line, +// that esc discards a new comment, and that a comment started on a v +// selection covers the whole range and is not interrupted by e. func TestCommentBox(t *testing.T) { tm := start(t, newRepo(t), opts{}) - // The draft comes first, because the flash of an added comment hides - // the badge of the draft. + // Check the draft badge first, because the flash message after adding + // a comment hides it. tm.keys("]", "j", "c", "x") tm.waitFor("✎ a.go:4 ") tm.keys("esc") tm.waitGone("✎") tm.keys("k", "c", "a", "ctrl+j", "b", "enter") tm.waitFor("💬 a", "b") - // As in herdr-reviewr, e does nothing while a selection is active. + // e does nothing while a selection is active. tm.keys("j", "v", "j", "e", "c") if s := tm.waitFor("enter save"); strings.Contains(s, "editor failed") { t.Errorf("e with a selection opens the editor. The screen shows:\n%s", s) @@ -95,9 +130,21 @@ func TestCommentBox(t *testing.T) { tm.waitFor("a.go:4-5 r") } -// TestSend checks that s pastes the review into the agent and gives it -// the focus. S does the same, and so does s in the question before a quit. -// A comment that the user edits during the send stays. +// TestCommentBoxAtBottom checks that a comment box opened on the last line +// of the pane is shown in full, below the existing comments on that line. +func TestCommentBoxAtBottom(t *testing.T) { + tm := start(t, newRepo(t), opts{height: 16}) + tm.keys("f", ":", "$", "enter") + tm.waitCursor("last") + tm.keys("c", "a", "ctrl+j", "b", "ctrl+j", "d", "enter") + tm.waitFor("💬 a") + tm.keys("c", "x", "ctrl+j", "y", "ctrl+j", "z") + tm.waitFor("💬 a", "│ x", "│ y", "│ z", "enter save") +} + +// TestSend checks that s, S, and s in the quit prompt paste the review +// into the agent and move the focus to it. A comment that is edited while +// the paste is in progress is kept. func TestSend(t *testing.T) { repo := newRepo(t) rex := newRex(t, repo) @@ -134,7 +181,8 @@ func TestSend(t *testing.T) { tm.waitFor("sent 1 comment to agent", "💬 hi edit") } -// TestSendWithoutAgent checks that s outside Rex tells the user to copy. +// TestSendWithoutAgent checks that outside Rex, s shows a "not in Rex" +// message and keeps the comment. func TestSendWithoutAgent(t *testing.T) { tm := start(t, newRepo(t), opts{}) comment(tm, "hi") @@ -143,8 +191,8 @@ func TestSendWithoutAgent(t *testing.T) { tm.waitFor("💬 hi") } -// TestAgentPicker checks that a send with two agents asks which one. As -// in herdr-reviewr, a digit moves the cursor to an agent, and enter sends. +// TestAgentPicker checks that with two agents, s asks which agent to send +// to, a digit moves the cursor to that agent, and enter sends. func TestAgentPicker(t *testing.T) { repo := newRepo(t) rex := newRex(t, repo) @@ -161,8 +209,8 @@ func TestAgentPicker(t *testing.T) { rex.expectCalls(t, `block.event {"block_id":"block:cc"`) } -// TestAgentElsewhere checks that the last-turn scope tells when no agent -// works in the repository. +// TestAgentElsewhere checks that the last-turn scope says so when no +// agent is working in the repository. func TestAgentElsewhere(t *testing.T) { repo := newRepo(t) rex := newRex(t, repo) @@ -172,8 +220,8 @@ func TestAgentElsewhere(t *testing.T) { tm.waitFor("no agent works here") } -// TestCopy checks that y and Y copy the review and clear the comments. y -// in the question before a quit does the same. +// TestCopy checks that y, Y, and y in the quit prompt copy the review to +// the clipboard and clear the comments. func TestCopy(t *testing.T) { bin := clipboard(t) clip := filepath.Join(bin, "clip") @@ -187,18 +235,16 @@ func TestCopy(t *testing.T) { tm.keys(keys...) tm.waitFor("copied 1 comment") tm.waitGone("💬") - waitFile(t, clip, func(s string) bool { return s == review }) + waitFile(t, clip, "the review", func(s string) bool { return s == review }) } } -// TestMouse checks that a click picks a file, and a triple click copies a -// line. It also checks that a drag on the code copies the lines, and that -// the wheel scrolls. As in herdr-reviewr, the wheel on the file list does -// not pick a file. +// TestMouse checks that clicking a file opens it, that a triple click or a +// drag over code copies the text, and that the wheel scrolls without +// selecting anything. func TestMouse(t *testing.T) { - // The fake pbcopy is slow, as a real tool can be. Its copy takes longer - // than the time between the clicks of a triple click. The triple click - // still copies the line. + // The fake pbcopy takes longer than the gap between the clicks of a + // triple click, and the triple click must still copy the line. slow := `sleep 0.5; cat > "${0%/*}/clip"` + "\n" bin := tools(t, map[string]string{"pbcopy": slow, "wl-copy": slow}) clip := filepath.Join(bin, "clip") @@ -209,14 +255,14 @@ func TestMouse(t *testing.T) { tm.wheel("M a.go", 1) tm.click("func A() int {", 3) tm.waitFor("copied 14 chars") - waitFile(t, clip, func(s string) bool { return s == "func A() int {" }) + waitFile(t, clip, `"func A() int {"`, func(s string) bool { return s == "func A() int {" }) tm.drag("package a", "func A()") - waitFile(t, clip, func(s string) bool { return s == "package a\n\nf" }) + waitFile(t, clip, `"package a\n\nf"`, func(s string) bool { return s == "package a\n\nf" }) tm.drag("A() int", "func A") - waitFile(t, clip, func(s string) bool { return s == "func A" }) + waitFile(t, clip, `"func A"`, func(s string) bool { return s == "func A" }) tm.click("M long.txt", 1) - tm.keys(":", "2", "0", "enter") // opens the fold + tm.keys(":", "2", "0", "enter") // going to line 20 expands the fold tm.waitCursor("line 20") tm.keys(":", "1", "enter") tm.waitFor(" first") @@ -224,11 +270,12 @@ func TestMouse(t *testing.T) { tm.waitGone(" first") } -// TestQuit checks that q with a comment asks first. +// TestQuit checks that q asks for confirmation when there are unsent +// comments, that esc cancels, and that Q in the prompt quits. func TestQuit(t *testing.T) { tm := start(t, newRepo(t), opts{}) comment(tm, "hi") - tm.keys("Q", "q") // Q does nothing outside the prompt + tm.keys("Q", "q") // Q does nothing outside the quit prompt tm.waitFor("1 unsent comment will be lost", "Q quit (1 pending) · esc cancel · s send 1 · y copy") tm.keys("esc") @@ -240,17 +287,17 @@ func TestQuit(t *testing.T) { } } -// TestCommentStays checks that a comment stays when the agent edits the -// file. As in herdr-reviewr, it keeps its line number. When the file -// leaves the changes, the list marks the comment stale. +// TestCommentStays checks that a comment keeps its line number and stays +// fresh when the agent edits other lines, and is marked stale once the +// file no longer has changes. func TestCommentStays(t *testing.T) { repo := newRepo(t) tm := start(t, repo, opts{}) - tm.keys("]", "j", "c", "h", "i", "enter") // on x := 2 + tm.keys("]", "j", "c", "h", "i", "enter") // comment on x := 2 tm.waitFor("💬 hi") testgit.WriteFile(t, repo, "a.go", - "package a\n\n// A adds.\n// It is new.\nfunc A() int {\n\tx := 2\n\treturn x\n}\n") - tm.waitFor("It is new.") + "package a\n\nfunc A() int {\n\tx := 2\n\treturn x + 1\n}\n") + tm.waitFor("return x + 1", "💬 hi") tm.keys("l") tm.waitFor("a.go:4 hi") tm.waitGone("stale") @@ -263,8 +310,89 @@ func TestCommentStays(t *testing.T) { "mark the comment stale") } -// TestEditorInPane checks that e opens a terminal editor at the line, in -// a new Rex pane. +// TestCommentStaleMoved checks that a comment does not follow its code +// when the agent adds lines above it: the comment stays on its line +// number, is marked stale below the code and in the comment list, and the +// copied review marks it stale with the code it was made on. +func TestCommentStaleMoved(t *testing.T) { + bin := clipboard(t) + clip := filepath.Join(bin, "clip") + repo := newRepo(t) + tm := start(t, repo, opts{path: bin, env: clipEnv}) + tm.keys("]", "j", "c", "h", "i", "enter") // comment on x := 2, line 4 + tm.waitFor("💬 hi") + testgit.WriteFile(t, repo, "a.go", + "package a\n\n// A adds.\n// It is new.\nfunc A() int {\n\tx := 2\n\treturn x\n}\n") + tm.waitFor("It is new.", "💬 stale hi") + tm.keys("l") + tm.waitUntil(func(s string) bool { return sameLine(s, "a.go:4 hi", "stale") }, + "mark the comment stale in the list") + tm.keys("esc") + tm.waitGone("Comments (") + tm.keys("y") + tm.waitFor("copied 1 comment") + want := "a.go:4 (stale)\n+\tx := 2\nhi" + waitFile(t, clip, "the review", func(s string) bool { return s == want }) +} + +// TestCommentStaleGone checks that a comment on lines past the new end of +// a shorter file is marked stale in the comment list and still sent, and +// that enter in the list goes to the nearest line. +func TestCommentStaleGone(t *testing.T) { + bin := clipboard(t) + clip := filepath.Join(bin, "clip") + repo := newRepo(t) + tm := start(t, repo, opts{path: bin, env: clipEnv}) + tm.keys("]", "j", "j", "c", "h", "i", "enter") // comment on return x, line 5 + tm.waitFor("💬 hi") + testgit.WriteFile(t, repo, "a.go", "package a\n\nvar A = 2\n") + tm.waitGone("💬") + tm.waitFor("var A = 2") + tm.keys(":") + tm.typed("1") + tm.keys("enter") + tm.waitCursor("package a") + tm.keys("l") + tm.waitUntil(func(s string) bool { return sameLine(s, "a.go:5 hi", "stale") }, + "mark the comment stale in the list") + tm.keys("enter") + tm.waitGone("Comments (") + tm.waitCursor("var A = 2") + tm.keys("y") + tm.waitFor("copied 1 comment") + want := "a.go:5 (stale)\n+\treturn x\nhi" + waitFile(t, clip, "the review", func(s string) bool { return s == want }) +} + +// TestCommentStaleWhileOpen checks that a comment written while the agent +// rewrites the file keeps the code that was on its line when the comment +// box opened, and is marked stale once the new file shows. +func TestCommentStaleWhileOpen(t *testing.T) { + bin := clipboard(t) + clip := filepath.Join(bin, "clip") + repo := newRepo(t) + log := filepath.Join(t.TempDir(), "log") + tm := start(t, repo, opts{path: bin, env: clipEnv, args: []string{"-log", log}}) + tm.keys("]", "j", "c") // a comment on x := 2, line 4 + tm.waitFor("enter save") + testgit.WriteFile(t, repo, "a.go", + "package a\n\nfunc A() int {\n\ty := 3\n\treturn y\n}\n") + // Type once rexdiff has seen the change, while the box is open. + waitFile(t, log, "a watch message with files=true after the key c", func(s string) bool { + _, after, _ := strings.Cut(s, "key=c ") + return sameLine(after, "msg=watch", "files=true") + }) + tm.typed("hi") + tm.keys("enter") + tm.waitFor("y := 3", "💬 stale hi") + tm.keys("y") + tm.waitFor("copied 1 comment") + want := "a.go:4 (stale)\n+\tx := 2\nhi" + waitFile(t, clip, "the review", func(s string) bool { return s == want }) +} + +// TestEditorInPane checks that inside Rex, e opens a terminal editor at +// the cursor line in a new Rex pane. func TestEditorInPane(t *testing.T) { repo := newRepo(t) rex := newRex(t, repo) @@ -276,8 +404,8 @@ func TestEditorInPane(t *testing.T) { tm.waitFor("opened a.go") } -// TestWindowEditor checks that e starts a window editor at the line, and -// that the editor setting picks the editor. +// TestWindowEditor checks that e starts a GUI editor from $VISUAL at the +// cursor line, and that the editor setting overrides the choice of editor. func TestWindowEditor(t *testing.T) { bin := tools(t, map[string]string{ "code": `printf '%s\n' "$@" > "${0%/*}/code"` + "\n", @@ -285,7 +413,7 @@ func TestWindowEditor(t *testing.T) { }) tm := start(t, newRepo(t), opts{path: bin, env: []string{"VISUAL=code --wait"}}) tm.keys("]", "j", "e") - waitFile(t, filepath.Join(bin, "code"), func(s string) bool { + waitFile(t, filepath.Join(bin, "code"), "arguments that start with --wait -g and end with a.go:4", func(s string) bool { return strings.HasPrefix(s, "--wait\n-g\n") && strings.HasSuffix(s, "a.go:4\n") }) @@ -294,20 +422,21 @@ func TestWindowEditor(t *testing.T) { `{"editor": "mine --line {line} {file}"}`, }}) tm.keys("]", "j", "e") - waitFile(t, filepath.Join(bin, "mine"), func(s string) bool { + waitFile(t, filepath.Join(bin, "mine"), "arguments that start with --line 4 and end with a.go", func(s string) bool { return strings.HasPrefix(s, "--line\n4\n") && strings.HasSuffix(s, "a.go\n") }) } -// TestNoEditor checks that e tells how to set an editor when none is set. +// TestNoEditor checks that when no editor is configured, e shows how to +// set one. func TestNoEditor(t *testing.T) { tm := start(t, newRepo(t), opts{}) tm.keys("]", "j", "e") tm.waitFor(`editor failed: no editor: set "editor" in rexdiff.lua, $EDITOR, or git's core.editor`) } -// TestCopyHangs checks that a clipboard tool that does not answer fails -// the copy, and rexdiff still takes keys. +// TestCopyHangs checks that when the clipboard tool hangs, the copy fails, +// the comment is kept, and rexdiff still responds to keys. func TestCopyHangs(t *testing.T) { hang := "exec sleep 60\n" bin := tools(t, map[string]string{"pbcopy": hang, "wl-copy": hang}) @@ -319,3 +448,140 @@ func TestCopyHangs(t *testing.T) { tm.keys("f") tm.waitFor("⋯ 32 unmodified lines") } + +// TestCommentPaste checks that a pasted \r\n or lone \r is a single line +// break in the comment box, and that the box scrolls to the cursor after +// a long paste. +func TestCommentPaste(t *testing.T) { + tm := start(t, newRepo(t), opts{width: 100, height: 20}) + tm.keys("]", "c") + tm.waitFor("enter save") + tm.paste("first pasted\r\nsecond pasted\rthird pasted") + s := tm.waitFor("third pasted") + first, second, third := rowY(s, "first pasted"), rowY(s, "second pasted"), rowY(s, "third pasted") + if second != first+1 || third != second+1 { + t.Errorf("the pasted lines are on lines %d, %d and %d, want one after another. The screen shows:\n%s", + first, second, third, s) + } + tm.keys("esc") + tm.waitGone("enter save") + + var long []string + for i := 1; i <= 20; i++ { + long = append(long, fmt.Sprintf("pasted %02d %s", i, strings.Repeat("x", 90))) + } + tm.keys("c") + tm.waitFor("enter save") + tm.paste(strings.Join(long, "\n")) + tm.waitFor("pasted 20") +} + +// TestCommentStaleInFile checks that a comment in the Files tab is marked +// stale when the agent adds a line above it, and fresh again when the +// line comes back. +func TestCommentStaleInFile(t *testing.T) { + repo := filepath.Join(t.TempDir(), "repo") + testgit.InitRepo(t, repo) + testgit.WriteFile(t, repo, "x.txt", "one\ntwo\n") + testgit.Git(t, repo, "add", ".") + testgit.Git(t, repo, "commit", "-qm", "first") + tm := start(t, repo, opts{}) + tm.keys("2") + tm.waitCursor("one") + tm.keys("c") + tm.typed("hi") + tm.keys("enter") + tm.waitFor("💬 hi") + testgit.WriteFile(t, repo, "x.txt", "zero\none\ntwo\n") + tm.waitFor("zero", "💬 stale hi") + tm.keys("l") + tm.waitUntil(func(s string) bool { return sameLine(s, "x.txt:1 hi", "stale") }, + "mark the comment stale in the list") + tm.keys("esc") + tm.waitGone("Comments (") + testgit.WriteFile(t, repo, "x.txt", "one\ntwo\n") + tm.waitGone("zero", "stale") + tm.waitFor("💬 hi") +} + +// TestCommentStaleInFileFromChanges checks that a comment made in the +// Files tab is marked stale in the comment list, and copied as stale, when +// the agent adds a line above it while the Changes tab is shown. +func TestCommentStaleInFileFromChanges(t *testing.T) { + bin := clipboard(t) + clip := filepath.Join(bin, "clip") + repo := filepath.Join(t.TempDir(), "repo") + testgit.InitRepo(t, repo) + testgit.WriteFile(t, repo, "u.txt", numbered("line", 1, 9)) + testgit.Git(t, repo, "add", ".") + testgit.Git(t, repo, "commit", "-qm", "first") + tm := start(t, repo, opts{path: bin, env: clipEnv}) + tm.keys("2") + tm.waitCursor("line 1") + tm.keys(":") + tm.typed("5") + tm.keys("enter") + tm.waitCursor("line 5") + tm.keys("c") + tm.typed("hi") + tm.keys("enter") + tm.waitFor("💬 hi") + tm.keys("1") + tm.waitFor("no changes") + testgit.WriteFile(t, repo, "u.txt", "line 0\n"+numbered("line", 1, 9)) + tm.waitFor("Changes (1)") + tm.keys("l") + tm.waitUntil(func(s string) bool { return sameLine(s, "u.txt:5 hi", "stale") }, + "mark the comment stale in the list") + tm.keys("esc") + tm.waitGone("Comments (") + tm.keys("y") + tm.waitFor("copied 1 comment") + waitFile(t, clip, "the review with the comment marked stale", func(s string) bool { + return strings.HasPrefix(s, "u.txt:5 (stale)\n") + }) +} + +// TestSendLong checks that a review that Rex would refuse as one paste, +// a comment of 30,000 bytes that Rex encodes as 180,000 because it escapes +// <, & and >, is pasted in several parts that join to the review. +func TestSendLong(t *testing.T) { + repo := newRepo(t) + rex := newRex(t, repo) + tm := start(t, repo, opts{rex: rex}) + tm.waitFor("send → 1 agent") + long := strings.Repeat("<&>", 10_000) + tm.keys("]", "c") + tm.paste(long) + tm.keys("enter") + tm.waitFor("comment added") + tm.keys("s") + tm.waitFor("sent 1 comment to agent") + var text strings.Builder + parts := 0 + for line := range strings.Lines(rex.calls(t)) { + args, ok := strings.CutPrefix(line, "api call -s S block.event ") + if !ok { + continue + } + var call struct { + Event struct { + Payload struct { + Text string `json:"text"` + } `json:"payload"` + } `json:"event"` + } + if err := json.Unmarshal([]byte(args), &call); err != nil { + t.Fatal(err) + } + text.WriteString(call.Event.Payload.Text) + parts++ + } + if want := "a.go:4 (removed)\n-\treturn 1\n" + long; text.String() != want { + t.Errorf("the pasted parts join to %d bytes that differ from the %d of the review", + text.Len(), len(want)) + } + if parts < 4 { + t.Errorf("the review was pasted in %d parts, want at least 4", parts) + } +} diff --git a/test/config_test.go b/test/config_test.go new file mode 100644 index 0000000..0b5350f --- /dev/null +++ b/test/config_test.go @@ -0,0 +1,159 @@ +package test + +import ( + "cmp" + "encoding/json/v2" + "regexp" + "strings" + "testing" +) + +// actionNames lists every action that the keybindings setting accepts. +// alias is a former rexdiff name that the setting also accepts, or "". +var actionNames = []struct{ name, alias string }{ + {"down", ""}, + {"up", ""}, + {"next-hunk", ""}, + {"prev-hunk", ""}, + {"next-file", ""}, + {"prev-file", ""}, + {"collapse", ""}, + {"expand", ""}, + {"page-up", ""}, + {"page-down", ""}, + {"half-up", ""}, + {"half-down", ""}, + {"focus", ""}, + {"scope-uncommitted", ""}, + {"scope-branch", ""}, + {"scope-last-turn", ""}, + {"scope-commits", ""}, + {"base-pick", ""}, + {"commit-pick", ""}, + {"tab-changes", ""}, + {"tab-all-files", ""}, + {"tab-pr", ""}, + {"wrap", ""}, + {"rendered", "preview"}, + {"split", ""}, + {"navigator-position", ""}, + {"navigator-hide", ""}, + {"navigator-grow", "list-wider"}, + {"navigator-shrink", "list-narrower"}, + {"select", ""}, + {"comment", ""}, + {"edit", ""}, + {"delete", ""}, + {"next-comment", ""}, + {"prev-comment", ""}, + {"comments", ""}, + {"search", ""}, + {"find", ""}, + {"goto-line", ""}, + {"keys", ""}, + {"send", ""}, + {"copy", ""}, + {"open-pr", ""}, + {"refresh", ""}, + {"quit", ""}, + {"quit-discard", ""}, +} + +// actionKeys holds the keys that TestConfigNames binds, one for each +// action in actionNames, in order. It leaves out U, which is always the +// update key. +const actionKeys = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTVWXYZ" + +// TestConfigNames checks that a config that uses the action names, or +// their aliases, binds every key and sets the position and width of the +// file list. It checks the key bindings through the help screen. +func TestConfigNames(t *testing.T) { + keyOf := map[string]string{} + for i, a := range actionNames { + keyOf[a.name] = actionKeys[i : i+1] + } + // Each help entry lists the keys followed by a label. Some labels + // cover two or three actions, such as "next/prev file". + k := func(names ...string) string { + keys := make([]string, len(names)) + for i, n := range names { + keys[i] = keyOf[n] + } + return strings.Join(keys, "/") + } + help := [][2]string{ + {k("down", "up"), "move"}, + {k("half-down", "half-up"), "half page"}, + {k("next-hunk", "prev-hunk"), "next/prev change"}, + {k("next-file", "prev-file"), "next/prev file"}, + {k("focus"), "switch pane"}, + {k("collapse", "expand"), "scroll sideways"}, + {k("goto-line"), "go to line"}, + {k("tab-changes", "tab-all-files", "tab-pr"), "changes/files/PR"}, + {k("scope-uncommitted"), "uncommitted"}, + {k("scope-branch"), "branch"}, + {k("scope-last-turn"), "last turn"}, + {k("scope-commits"), "commits"}, + {k("base-pick"), "pick base branch"}, + {k("commit-pick"), "pick commits"}, + {k("select"), "select lines"}, + {k("comment"), "comment"}, + {k("edit"), "edit comment/open editor"}, + {k("delete"), "delete comment"}, + {k("next-comment", "prev-comment"), "next/prev comment"}, + {k("comments"), "list comments"}, + {k("send"), "send to agent"}, + {k("copy"), "copy comments"}, + {k("search"), "search files and code"}, + {k("find"), "find in file"}, + {k("rendered"), "rendered markdown"}, + {k("split"), "side by side"}, + {k("wrap"), "wrap lines"}, + {k("navigator-position"), "move list"}, + {k("navigator-hide"), "hide list"}, + {k("navigator-grow", "navigator-shrink"), "resize list"}, + {k("open-pr"), "open PR in browser"}, + {k("refresh"), "refresh"}, + {k("keys"), "all keys"}, + {k("quit"), "quit"}, + } + var res []*regexp.Regexp + for _, h := range help { + res = append(res, regexp.MustCompile(`(^|\s)`+regexp.QuoteMeta(h[0])+` +`+regexp.QuoteMeta(h[1]))) + } + for _, names := range []string{"names", "aliases"} { + t.Run(names, func(t *testing.T) { + kb := map[string][]string{} + for _, a := range actionNames { + name := a.name + if names == "aliases" { + name = cmp.Or(a.alias, a.name) + } + kb[name] = []string{keyOf[a.name]} + } + b, err := json.Marshal(map[string]any{ + "keybindings": kb, "file_list": "left", "file_list_width": 50, "url_opener": "opener {url}", + }) + if err != nil { + t.Fatal(err) + } + tm := start(t, newRepo(t), opts{width: 220, args: []string{"-config", string(b)}}) + s := tm.waitFor("Changes (2)") + if c := column(s, "Changes (2)"); c < 0 || c > 5 { + t.Errorf("the file list starts at column %d, want it at the left", c) + } + if ls := lines(s, "M a.go"); len(ls) == 0 || column(ls[0], "│") < 48 || column(ls[0], "│") > 52 { + t.Errorf("the file list rows %q, want a width of 50", ls) + } + tm.keys(keyOf["keys"]) + tm.waitUntil(func(s string) bool { + for _, re := range res { + if !re.MatchString(s) { + return false + } + } + return true + }, "show the keys of the config in the help") + }) + } +} diff --git a/test/e2e.sh b/test/e2e.sh new file mode 100755 index 0000000..5efc55c --- /dev/null +++ b/test/e2e.sh @@ -0,0 +1,811 @@ +#!/bin/sh +# e2e.sh runs the rexdiff of this tree in a real Rex and walks through each +# feature of .doc/user.md, then through the cases at the edges: hostile +# file names, tiny and huge blocks, boxes open while the block resizes, a +# send of more than 64 KB, and the quit prompt. +# +# REXDIFF_REX=$(which rex) test/e2e.sh +# +# It needs a running Rex server, go, git and jq. It creates the session +# rexdiff-e2e-PID and kills only that session when it ends. Fake programs +# stand in for the agent, gh, the clipboard, the editor and the browser. +# With REXDIFF_E2E_KEEP=1, it keeps its files, such as the debug log of +# rexdiff. +# +# The script leaves out the install, the toggle key and U, because they +# change the Rex configuration or need a release. The Go tests in test/ +# cover them. +# +# Each step types keys, then reads the screen with rex capture until it +# shows what the step expects in a whole frame: rexdiff still runs, no line +# is wrapped, no crash shows, and on a block big enough the tab bar is on +# the first line and the key hints on the last. The script prints one line +# per step. At the first failure it prints the screen and exits with 1. +set -eu + +: "${REXDIFF_REX:?set REXDIFF_REX to the path of rex}" +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d "${TMPDIR:-/tmp}/rexdiff-e2e.XXXXXX") +work=$(cd "$work" && pwd -P) # without links, the paths match what git reports +session=rexdiff-e2e-$$ +wait_seconds=20 # how long a step waits for the screen + +rex() { "$REXDIFF_REX" --autostart=false "$@"; } + +cleanup() { + if [ -n "${events_pid:-}" ]; then kill "$events_pid" 2>/dev/null || true; fi + rex kill "$session" > /dev/null 2>&1 || true + if [ -n "${REXDIFF_E2E_KEEP:-}" ]; then echo "kept $work"; else rm -rf "$work"; fi +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM + +# ---- Steps and failures ---- + +n=0 +what="" +screen="" + +# step prints the number and the name of the next step. +step() { + n=$((n + 1)) + what=$* + printf '%3d %s\n' "$n" "$what" +} + +# fail prints why the step failed and the last screen, and exits. +fail() { + printf 'FAIL at step %d, %s: %s\n' "$n" "$what" "$*" >&2 + printf '%s\n' "The block shows:" "$screen" >&2 + exit 1 +} + +# poll runs the command in its arguments until it succeeds, or fails the +# step after wait_seconds. The first argument says what poll waits for. +poll() { + why=$1 + shift + end=$(($(date +%s) + wait_seconds)) + until "$@"; do + [ "$(date +%s)" -lt "$end" ] || fail "$why" + sleep 0.05 + done +} + +# ---- The fixture: tools and repositories ---- + +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_ATTR_NOSYSTEM=1 GIT_TEMPLATE_DIR= +export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t + +# numbered prints the lines "WORD N" for N from FIRST to LAST. +numbered() { awk -v w="$1" -v a="$2" -v b="$3" 'BEGIN { for (i = a; i <= b; i++) print w, i }'; } + +step "build rexdiff" +bin=$work/rexdiff +(cd "$root" && go build -buildvcs=false -o "$bin" ./cmd/rexdiff) || fail "go build failed" + +step "write the fake tools" +mkdir -p "$work/tools" "$work/home" +cat > "$work/tools/pbcopy" << EOF +#!/bin/sh +cat > "$work/clip" +EOF +cp "$work/tools/pbcopy" "$work/tools/wl-copy" +cat > "$work/tools/code" << EOF +#!/bin/sh +printf '%s\n' "\$*" >> "$work/edited" +EOF +cat > "$work/tools/opener" << EOF +#!/bin/sh +printf '%s\n' "\$*" >> "$work/opened" +EOF +cat > "$work/tools/gh" << 'EOF' +#!/bin/sh +cat << 'JSON' +{"number": 7, "title": "Add b", "state": "OPEN", "isDraft": false, + "url": "https://github.com/o/r/pull/7", "body": "The body of the PR.", + "author": {"login": "ann"}, "headRefName": "feat", "baseRefName": "main", + "comments": [{"author": {"login": "bob"}, "body": "Looks good.\n\n
The logs\n\nA hidden line.\n\n
", + "createdAt": "2020-01-01T00:00:00Z"}], + "statusCheckRollup": [{"name": "test", "conclusion": "SUCCESS"}, {"name": "lint", "conclusion": "FAILURE"}]} +JSON +EOF +chmod +x "$work/tools"/* +tools_path=$work/tools:$(dirname "$(command -v git)"):/usr/bin:/bin + +step "create the repository" +repo=$work/repo +mkdir -p "$repo" +cd "$repo" +git init -q -b main +printf 'package a\n\nfunc A() int {\n\treturn 1\n}\n' > a.go +numbered line 1 40 > long.txt +printf '# Title\n\nSome text.\n\n- one\n- two\n' > README.md +{ echo short; printf '%300s' '' | tr ' ' w; echo " needle end"; } > wide.txt +echo bye > gone.txt +git add . && git commit -qm first +git checkout -qb feat +printf 'package a\n\nvar B = 2\n' > b.go +git add . && git commit -qm "add b" +printf 'package a\n\nfunc A() int {\n\tx := 2\n\treturn x\n}\n' > a.go +{ echo first; numbered line 2 39; echo last; } > long.txt +printf '# Title\n\nSome **new** text with [a link](https://example.com/x).\n\n- one\n- two\n' > README.md +{ echo short; printf '%300s' '' | tr ' ' w; echo " needle changed"; } > wide.txt +git rm -q gone.txt +git remote add origin https://github.com/o/r.git +cd "$root" + +# ---- Rex ---- + +# The fake agent pi tells Rex that it is idle, then saves what it reads to +# agent.in, as the input box of an agent would hold a paste. A line WORK or +# IDLE in the FIFO agent.ctl sets its state. +mkfifo "$work/agent.ctl" +cat > "$work/agent" << EOF +#!/bin/sh +osc() { printf '\033]7501;state=%s:app=pi\007' "\$1"; } +osc idle +echo "agent ready" +while :; do + read -r cmd < "$work/agent.ctl" + case \$cmd in + WORK) osc working; echo "agent working" ;; + IDLE) osc idle; echo "agent idle" ;; + esac +done & +stty -icanon -echo min 1 time 0 +exec cat > "$work/agent.in" +EOF +chmod +x "$work/agent" + +step "start the session $session with the agent" +rex new "$session" --cwd "$repo" -- "$work/agent" > /dev/null +session_id=$(rex session inspect "$session" --json | jq -r .session_id) +agent=$(rex api call -s "$session" session.list_blocks '{}' | jq -r '.blocks[0].block_id') +rex block rename "$agent" agent -s "$session" +agent_shows() { screen=$(rex capture -s "$session" -b "$agent"); case $screen in *"$1"*) ;; *) return 1 ;; esac; } +poll "the agent does not start" agent_shows "agent ready" + +# shape prints the paths of the keys in the JSON on stdin, with 0 for +# each array index. +shape() { jq -c '[paths | map(if type == "number" then 0 else . end)] | unique'; } + +# same_shape fails the step if the JSON on stdin lacks a key of the +# recording in test/testdata/rex/NAME, which the fake rex of the Go tests +# prints. +same_shape() { + got=$(shape) + want=$(shape < "$root/test/testdata/rex/$1") + missing=$(jq -nc --argjson got "$got" --argjson want "$want" '$want - $got') + [ "$missing" = "[]" ] || fail "rex output lacks the keys $missing of $1" +} + +step "rex prints JSON in the shapes that the fake rex records" +rex api call -s "$session" session.list_blocks '{}' | jq '.blocks[0]' | same_shape block.json +rex session inspect "$session" --json | same_shape session.json +rex block inspect agent -s "$session" --json | same_shape inspect.json +rex block call -s "$session" -b "$agent" process | same_shape process.json +has_status() { rex block call -s "$session" -b "$agent" program_status | jq -e '.records[0]' > /dev/null; } +poll "the agent reports no status" has_status +rex block call -s "$session" -b "$agent" program_status | same_shape status.json +"$REXDIFF_REX" --autostart=false events -s "$session" --json > "$work/events" 2> /dev/null & +events_pid=$! +# Rex sends no event before the stream starts, so report states until one +# arrives. +status_event() { + echo WORK > "$work/agent.ctl" + grep -q program_status_changed "$work/events" +} +poll "rex events shows no status change" status_event +kill "$events_pid" +events_pid= +grep -m 1 program_status_changed "$work/events" | same_shape event.json +echo IDLE > "$work/agent.ctl" + +# ---- rexdiff in a block ---- + +block="" +cols=0 +rows=0 +config='{"focus_agent_after_send": false, "editor": "code -g {file}:{line}", "url_opener": "opener"}' + +# start runs rexdiff on the repository DIR in a new block, with the flags +# that follow, and waits until it runs. The block stays open after rexdiff +# quits. +start() { + dir=$1 + shift + block=$(rex run -s "$session" --cwd "$dir" --label under-test --shell=none --keep-open --focus=false --json -- \ + /usr/bin/env PATH="$tools_path" HOME="$work/home" XDG_CONFIG_HOME= REX_CONFIG= WAYLAND_DISPLAY=w \ + EDITOR= VISUAL= REXDIFF_AGENT= REXDIFF_LOG= REXDIFF_RESUME= REXDIFF_LATEST_URL=http://127.0.0.1:9/none \ + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_ATTR_NOSYSTEM=1 \ + GIT_CONFIG_COUNT=2 GIT_CONFIG_KEY_0=maintenance.auto GIT_CONFIG_VALUE_0=false \ + GIT_CONFIG_KEY_1=gc.auto GIT_CONFIG_VALUE_1=0 \ + "$bin" -log "$work/log" "$@" "$dir" | jq -r '.block_ids[0]') + # A detached block shows in no app, so no app sets its size. + rex api call -s "$session" session.detach_block \ + "{\"session_id\": \"$session_id\", \"block_id\": \"$block\"}" > /dev/null + poll "rexdiff does not start" running +} + +# running reports whether rexdiff runs in the block. +running() { [ "$(rex block call -s "$session" -b "$block" process | jq -r '.child.name // ""')" = rexdiff ]; } + +# resize sets the size of the block to COLS by ROWS. +resize() { + cols=$1 rows=$2 + rex block call -s "$session" -b "$block" resize "{\"columns\": $cols, \"rows\": $rows}" > /dev/null + size=$(rex block call -s "$session" -b "$block" size | jq -r '"\(.columns)x\(.rows)"') + [ "$size" = "${cols}x$rows" ] || fail "the block is $size, want ${cols}x$rows" +} + +# capture reads the screen of the block into $screen. +capture() { screen=$(rex capture -s "$session" -b "$block" | tail -n "$rows"); } + +# whole reports whether $screen is a whole frame at the size of the block, +# and says why not in $why. On a block big enough, the frame has the tab +# bar on the first line and the key hints on the last, or it is a whole +# box. +whole() { + why="rexdiff does not run" + running || return 1 + why="the screen shows a crash" + if printf '%s\n' "$screen" | grep -Eq 'panic:|goroutine [0-9]+ \[|runtime error|fatal error:|DATA RACE'; then return 1; fi + why="the terminal wrapped a line" + [ "$(rex capture -s "$session" -b "$block" | wc -l)" -eq "$(rex capture -s "$session" -b "$block" --unwrap | wc -l)" ] || return 1 + why="the frame is not whole at ${cols}x$rows" + [ "$cols" -ge 60 ] && [ "$rows" -ge 5 ] || return 0 + case $(printf '%s\n' "$screen" | head -n 1) in + *"╭"*) on_line "╰" "╯" ;; # a box in place of the screen + *"1 Changes"*) [ "$(printf '%s\n' "$screen" | wc -l)" -eq "$rows" ] && + [ -n "$(printf '%s\n' "$screen" | tail -n 1 | tr -d ' ')" ] ;; + *) return 1 ;; + esac +} + +# shows reports whether $screen holds each of its arguments. +shows() { + for want; do + case $screen in *"$want"*) ;; *) return 1 ;; esac + done +} + +# lacks reports whether $screen holds none of its arguments. +lacks() { + for want; do + case $screen in *"$want"*) return 1 ;; esac + done +} + +# on_line reports whether one line of $screen holds each of its arguments. +on_line() { + printf '%s\n' "$screen" | while IFS= read -r line; do + ok=1 + for want; do case $line in *"$want"*) ;; *) ok=0 ;; esac; done + [ "$ok" = 0 ] || exit 3 + done + [ $? -eq 3 ] +} + +# header reports whether the diff pane shows the file NAME: its header, +# on the second line, starts with NAME. +header() { + line=$(printf '%s\n' "$screen" | sed -n 2p | sed 's/^ *//') + case $line in "$1 "*) ;; *) return 1 ;; esac +} + +# until_screen CHECK ARGS... captures the screen until CHECK ARGS succeeds +# on a whole frame. +until_screen() { + end=$(($(date +%s) + wait_seconds)) + while :; do + capture + if "$@"; then + whole && return 0 + [ "$(date +%s)" -lt "$end" ] || fail "$why" + elif [ "$(date +%s)" -ge "$end" ]; then + running || fail "rexdiff stops, and the screen does not pass: $*" + fail "the screen does not pass: $*" + fi + sleep 0.05 + done +} + +wait_for() { until_screen shows "$@"; } +wait_gone() { until_screen lacks "$@"; } +wait_line() { until_screen on_line "$@"; } +wait_cursor() { until_screen on_line "▶" "$@"; } +wait_header() { until_screen header "$1"; } +wait_frame() { until_screen true; } + +# keys presses each named key, such as enter, escape or ctrl+f. +keys() { rex send-key -s "$session" -b "$block" "$@"; } + +# text types its argument, such as j or :20. +text() { rex send -s "$session" -b "$block" "$1"; } + +# at prints the column and the line of the first TEXT on the screen, +# counting from 1. The screen of the fixture has no wide characters, so +# a character is a column. +at() { + printf '%s\n' "$screen" | jq -Rnr --arg t "$1" \ + '[inputs] | to_entries | map(select(.value | contains($t))) | first + | "\((.value | split($t) | first | explode | length) + 1) \(.key + 1)"' +} + +# mouse sends the mouse event: BUTTON at COLUMN and LINE, pressed (M) or +# released (m), in the SGR encoding that rexdiff turns on. +mouse() { printf '\033[<%d;%d;%d%s' "$@" | rex send -s "$session" -b "$block"; } + +# click clicks on TEXT. +click() { + wait_for "$1" + set -- $(at "$1") + mouse 0 "$1" "$2" M + mouse 0 "$1" "$2" m +} + +# drag drags with the left button from the start of FROM to the start of +# TO. +drag() { + wait_for "$1" "$2" + set -- $(at "$1") $(at "$2") + mouse 0 "$1" "$2" M + mouse 32 "$3" "$4" M + mouse 0 "$3" "$4" m +} + +# file_is reports whether the file PATH holds exactly TEXT and a newline. +file_is() { [ -f "$1" ] && [ "$(cat "$1")" = "$2" ]; } + +# file_has reports whether the file PATH holds each TEXT that follows. +file_has() { + f=$1 + shift + [ -f "$f" ] || return 1 + for want; do grep -qF -- "$want" "$f" || return 1; done +} + +# column prints the column of TEXT on the screen. +column() { at "$1" | cut -d ' ' -f 1; } + +# line_of prints the line of TEXT on the screen. +line_of() { at "$1" | cut -d ' ' -f 2; } + +# ---- The user guide ---- + +step "3. rexdiff opens with the changes and finds the agent" +start "$repo" -config "$config" +resize 150 30 +wait_for "Changes (5)" "README.md +1 -1" "send → 1 agent" +labelled() { rex api call -s "$session" session.list_blocks '{}' | jq -e --arg b "$block" '.blocks[] | select(.block_id == $b and .label == "rexdiff")' > /dev/null; } +poll "rexdiff does not label its block" labelled + +step "3. j and k move one line" +text j +wait_cursor "# Title" +text jj +wait_cursor "Some text." +text k +wait_cursor "2 2" + +step "3. ] and [ go to the next and previous change" +text "]" +wait_cursor "Some text." +# At the last change of a file, ] goes to the next file on a second press. +text "]]" +wait_header a.go +wait_cursor "return 1" +text "[[" +wait_header README.md + +step "3. f and F go to the next and previous file" +for name in a.go gone.txt long.txt wide.txt; do + text f + wait_header "$name" +done +text F +wait_header long.txt + +step "3. the right arrow key opens a fold, the left one closes it" +text jjjjjj +wait_for "→ expand fold" "⋯ 32 unmodified lines" +keys right +wait_for "line 20" "← collapse fold" +keys left +wait_for "⋯ 32 unmodified lines" +wait_gone "line 20" + +step "3. : goes to a line number" +text ":20" +keys enter +wait_cursor "line 20" +text ':$' +keys enter +wait_cursor last +text ":0" +keys enter +wait_cursor first + +step "3. ctrl+f finds text in the file" +keys ctrl+f +text "line 3" +wait_for "find line 3" +keys enter +wait_cursor "line 3" +keys enter +wait_cursor "line 30" +keys escape +wait_gone "find line 3" + +step "3. ctrl+f scrolls to a hit far to the right of a cut line" +text f +wait_header wide.txt +text w +wait_gone needle +keys ctrl+f +text needle +keys enter +wait_for "needle changed" +keys escape +wait_gone "find needle" +text w +wait_for "needle end" "needle changed" + +step "3. m shows Markdown rendered, with comments" +text FFFF +wait_header README.md +text m +wait_for "• one" "Some new text with a link" +wait_gone "# Title" "**new**" +text ":3" +keys enter +wait_cursor "Some new text" +text c +text "on the markdown" +keys enter +wait_for "💬 on the markdown" "comment added" +text m +wait_for "**new**" "💬 on the markdown" + +step "8. y copies the review" +text y +wait_for "copied 1 comment" +wait_gone "💬" +poll "the clipboard has no review" file_has "$work/clip" "README.md:3" "on the markdown" + +step "3. ] goes to the next change in rendered Markdown" +text m +wait_for "• one" +text "]" +wait_cursor "Some new text" +text m +wait_for "# Title" + +step "3. a drag over the code copies it" +drag Some text. +wait_for copied +poll "the clipboard does not hold \"Some t\"" file_is "$work/clip" "Some t" + +step "3. a click on a link opens it" +text m +click "https://example.com/x" +poll "the link does not open" file_is "$work/opened" "https://example.com/x" +text m +wait_for "# Title" + +step "5. | shows both sides, if the block is wide enough" +text f +wait_header a.go +text "|" +wait_for "too narrow for split" +resize 220 30 +text "|" +wait_line "return 1" "x := 2" +text "|" +until_screen eval '! on_line "return 1" "x := 2"' +resize 150 30 +wait_frame + +step "5. z hides and shows the file list" +text z +wait_gone "Changes (5)" +text z +wait_for "Changes (5)" + +step "p moves the file list: bottom, left, top, right" +list_at() { + shows "Changes (5)" || return 1 + x=$(column "Changes (5)") y=$(line_of "Changes (5)") + case $1 in + bottom) [ "$y" -gt $((rows / 2)) ] ;; + left) [ "$y" -eq 2 ] && [ "$x" -le 3 ] && on_line "Changes (5)" "│" ;; + top) [ "$y" -eq 2 ] && [ "$x" -le 3 ] && ! on_line "Changes (5)" "│" ;; + right) [ "$y" -eq 2 ] && [ "$x" -gt $((cols / 2)) ] ;; + esac +} +for place in bottom left top right; do + text p + until_screen list_at "$place" +done + +step "< and > change the width of the file list" +x0=$(column "Changes (5)") +text "<" +until_screen eval '[ "$(column "Changes (5)")" -lt "$x0" ]' +text ">>" +until_screen eval '[ "$(column "Changes (5)")" -gt "$x0" ]' + +step "tab moves the focus to the file list and back" +keys tab +text j +wait_header gone.txt +keys tab +text j +wait_cursor bye + +step "9. 2 shows all the files, 11. 3 the pull request, 1 the changes" +text 2 +wait_for "Files (5)" "b.go" +text 3 +wait_for "Add b #7" "checks: ✓ 1 ✗ 1" "Looks good." "▸ The logs" +keys right +wait_for "▾ The logs" "A hidden line." +keys left +wait_gone "A hidden line." + +step "11. o opens the pull request in the browser" +text o +wait_for "opened PR in browser" +poll "the pull request does not open" file_has "$work/opened" "https://github.com/o/r/pull/7" +text 1 +wait_for "Changes (5)" + +step "4. u, b, t and g pick what to compare" +text b +wait_for "vs main" "Changes (6)" "A b.go" +text u +wait_for "Changes (5)" +text t +wait_for "waiting for the first turn" +text g +wait_for "commits · 1 over main" "add b" +keys enter +wait_for "Changes (1)" "A b.go" "var B = 2" + +step "4. B picks the base branch, G the commits" +text B +wait_for "base branch for the branch scope" "feat" +wait_line "▶ main" "default current" +text feat +keys enter +wait_for "vs feat" "Changes (5)" +text B +text main +keys enter +wait_for "vs main" "Changes (6)" +text G +wait_for "commits · 1 over main" +keys escape +wait_gone "commits · 1 over main" +text u +wait_for "Changes (5)" + +step "10. / finds a file, then tab finds code" +text /long +wait_for "files 1" "▶ long.txt" "preview · long.txt" +keys enter +wait_header long.txt +text / +keys tab +text "line 7" +wait_for "code 1" "7: line 7" +keys enter +wait_cursor "line 7" + +step "6. c comments on a line, v on a range" +text FF +wait_header a.go +text "]" +wait_cursor "return 1" +text c +text "first note" +keys enter +wait_for "💬 first note" "●1" +text jvjc +text "range note" +keys enter +wait_for "💬 range note" "●2" + +step "7. l lists the comments, e edits one" +text l +wait_for "Comments (2)" "a.go:4 (removed) first note" "a.go:4-5 range note" +text e +text " more" +keys enter +wait_for "a.go:4 (removed) first note more" +keys escape +wait_gone "Comments (" + +step "n and N go to the next and previous comment" +text ":0" +keys enter +wait_cursor "package a" +text n +wait_cursor "return 1" +text n +wait_cursor "x := 2" +text N +wait_cursor "return 1" + +step "7. d deletes a comment" +text ":0" +keys enter +wait_cursor "package a" +text c +text doomed +keys enter +wait_for "💬 doomed" "●3" +text d +wait_gone "💬 doomed" +wait_for "●2" + +step "8. s sends the review to the agent" +text s +wait_for "sent 2 comments to agent" +wait_gone "💬" +poll "the agent did not get the review" file_has "$work/agent.in" \ + "a.go:4 (removed)" "return 1" "first note more" "a.go:4-5" "range note" + +step "e opens the editor at the cursor line" +text ":0" +keys enter +text "]e" +wait_for "opened a.go" +poll "the editor does not open a.go:4" file_is "$work/edited" "-g $repo/a.go:4" + +step "4. t shows the last turn of the agent" +text t +wait_for "waiting for the first turn" +echo WORK > "$work/agent.ctl" +poll "rexdiff does not start the turn" file_has "$work/log" 'msg="turn start"' +echo "package a" > "$repo/turn.go" +echo IDLE > "$work/agent.ctl" +wait_for "Changes (1)" "turn.go" +text u +wait_for "Changes (6)" + +step "rexdiff reloads when a file changes" +mkdir "$repo/new" +echo "package c" > "$repo/new/c.go" +wait_for "Changes (7)" "c.go" +rm -r "$repo/new" "$repo/turn.go" +wait_for "Changes (5)" + +step "r reloads" +echo fresh > "$repo/fresh.txt" +text r +wait_for "Changes (6)" "fresh.txt" +rm "$repo/fresh.txt" +wait_for "Changes (5)" + +step "12. ? shows all the keys" +text "?" +wait_for "search files and code" "pick commits" "move list" +keys escape +wait_gone "pick commits" + +# ---- The edges ---- + +step "s sends a review of more than 64 KB in parts" +before=$(wc -c < "$work/agent.in") +numbered "<&> a long review line" 1 3000 > "$work/long" +echo "END OF THE LONG REVIEW" >> "$work/long" +text "]c" +printf '\033[200~' | rex send -s "$session" -b "$block" +size=$(wc -c < "$work/long") +i=0 +while [ $((i * 30000)) -lt "$size" ]; do + dd if="$work/long" bs=30000 skip=$i count=1 2> /dev/null | rex send -s "$session" -b "$block" + i=$((i + 1)) +done +printf '\033[201~' | rex send -s "$session" -b "$block" +keys enter +wait_for "comment added" +text s +wait_for "sent 1 comment to agent" +long_arrived() { [ $(($(wc -c < "$work/agent.in") - before)) -gt "$size" ] && file_has "$work/agent.in" "END OF THE LONG REVIEW"; } +poll "the agent did not get all of the long review" long_arrived + +step "the screen stays whole from 1x1 to 300x80" +for size in 10x3 1x1 20x5 40x10 300x80 13x7 150x30; do + resize "${size%x*}" "${size#*x}" + wait_frame +done +wait_for "Changes (5)" + +# with_box OPEN SHOWS CLOSE: OPEN opens a box that shows SHOWS, the block +# goes through sizes from tiny to huge, and the box still shows; CLOSE +# closes it. +with_box() { + eval "$1" + wait_for "$2" + for size in 10x3 300x80 1x1 20x5 200x50 40x10 150x30; do + resize "${size%x*}" "${size#*x}" + wait_frame + done + wait_for "$2" + eval "$3" + wait_gone "$2" +} + +step "the help stays open while the block resizes" +with_box 'text "?"' "pick commits" 'keys escape' + +step "search stays open while the block resizes" +with_box 'text /long' "preview · long.txt" 'keys escape' + +step "the comment box stays open while the block resizes" +with_box 'text c; text "typed so far"' "typed so far" 'keys escape' + +step "the find bar stays open while the block resizes" +with_box 'keys ctrl+f; text Title' "find Title" 'keys escape' + +step "the quit prompt stays open while the block resizes" +text c +text unsent +keys enter +wait_for "💬 unsent" +with_box 'text q' "1 unsent comment will be lost" 'keys escape' +wait_for "💬 unsent" + +step "q asks before it drops unsent comments, Q quits" +text q +wait_for "1 unsent comment will be lost" +text Q +stopped() { ! running; } +poll "Q does not quit" stopped + +# ---- Hostile names ---- + +step "create a repository of hostile names and contents" +hostile=$work/hostile +mkdir -p "$hostile" +cd "$hostile" +git init -q -b main +long_dir="" +for i in 01 02 03 04 05 06 07 08 09 10; do long_dir=${long_dir}very_long_directory_name_$i/; done +mkdir -p "$long_dir" +echo "package main" > "${long_dir}xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_final_file_name.go" +printf 'title\n' > "$(printf 'esc\033]0;PWNED\007title.txt')" +printf 'ctl\n' > "$(printf 'ctl\001\007\033[31mRED\033[0m.txt')" +printf 'nl\n' > "$(printf 'new\nline.txt')" +printf 'rtl\n' > "$(printf '\342\200\256evil_rtl_override_gnp.exe')" +printf 'zw\n' > "$(printf 'zero\342\200\213width\357\273\277bom.txt')" +printf 'cjk\n' > 中文中文中文中文中文中文中文中文中文中文.txt +printf 'emoji\n' > 😀😀😀😀😀😀😀😀😀😀😀😀.txt +printf 'normal\n\033[31mred\033[0m\n\033[2J\033[Hclear\n\033]0;PWNED\007title\n\007bell\010back\177del\n' > ansi.txt +git add . && git commit -qm "hostile $(printf '\033]0;PWNED\007') message" +find . -path ./.git -prune -o -type f -exec sh -c \ + 'for f; do printf "added \033[35mmagenta\033[0m \033]0;PWNED\007 中😀 line\n" >> "$f"; done' _ {} + +printf 'new\n' > "$(printf 'untracked \033[7mrev\033]0;PWNED\007.txt')" +cd "$root" + +step "hostile names show escaped and do not change the terminal" +start "$hostile" -config "$config" +resize 120 30 +wait_for "Changes (10)" '"new\nline.txt"' 'WNED\atitle.txt"' "␛]0;PWNED␇ 中😀 line" +title_ok() { ! rex block call -s "$session" -b "$block" title | grep -q PWN; } +for i in 1 2 3 4 5 6 7 8 9 10; do + text f + wait_frame +done +for size in 40x10 10x3 300x80 120x30; do + resize "${size%x*}" "${size#*x}" + wait_frame +done +title_ok || fail "a file changed the title of the block" +text q +poll "q does not quit" stopped + +echo "PASS: $n steps" diff --git a/test/fake_test.go b/test/fake_test.go index 1c4259c..0fc9738 100644 --- a/test/fake_test.go +++ b/test/fake_test.go @@ -13,9 +13,9 @@ import ( "github.com/rednafi/rexdiff/internal/testgit" ) -// repoFixture is a repository with a commit on main and on the branch -// feat. It has uncommitted edits of a.go, and of the first and the last -// line of long.txt. +// repoFixture is a repository with one commit on main and one on the +// branch feat, which is checked out. It has uncommitted edits to a.go and +// to the first and last lines of long.txt, and an ignored directory out/. var repoFixture = testgit.NewFixture(func(tb testing.TB, dir string) { tb.Helper() testgit.InitRepo(tb, dir) @@ -36,7 +36,7 @@ var repoFixture = testgit.NewFixture(func(tb testing.TB, dir string) { "first\n"+numbered("line", 2, 39)+"last\n") }) -// numbered returns the lines " " for n from first to last. +// numbered returns the lines " " for each n from first to last. func numbered(word string, first, last int) string { var b strings.Builder for n := first; n <= last; n++ { @@ -45,8 +45,9 @@ func numbered(word string, first, last int) string { return b.String() } -// newRepo returns a copy of the fixture repository. The path has no -// symbolic link, as the root that git gives. +// newRepo returns the path of a new copy of the fixture repository. The +// path contains no symbolic links, so it matches the root that git +// reports. func newRepo(t *testing.T) string { t.Helper() tmp, err := filepath.EvalSymlinks(t.TempDir()) @@ -58,17 +59,19 @@ func newRepo(t *testing.T) string { return dir } -// fakeRex is a rex for the session S. rexdiff runs in the block -// block:self. The agents run in repo. The fake logs each call and prints -// the output of a real rex from testdata/rex, with the values of the test. -// "rex events" prints the lines that the test adds. If the FIFO gate -// exists, a paste waits until the test writes to it. +// fakeRex is a fake rex command for the session S, with rexdiff in the +// block block:self and agents working in the test repository. It logs +// each call and prints responses from the templates in testdata/rex, +// filled in with the test's values. "rex events" prints the lines that +// the test adds. If the FIFO named gate exists, a paste waits until the +// test writes to it. type fakeRex struct { dir string - blocks []string // the JSON of each block + blocks []string // the JSON for each block } -// newRex returns a fake rex with the agent pi in the block block:pi. +// newRex returns a fake rex with an idle agent pi in the block block:pi, +// whose working directory is repo. func newRex(t *testing.T, repo string) *fakeRex { t.Helper() r := &fakeRex{dir: t.TempDir()} @@ -91,8 +94,8 @@ esac return r } -// recorded returns the content of the file name in testdata/rex, with -// each pair of old and new values replaced. +// recorded returns the content of the template file name in testdata/rex, +// with each placeholder in oldNew replaced by the value that follows it. func recorded(t *testing.T, name string, oldNew ...string) string { t.Helper() b, err := os.ReadFile(filepath.Join("testdata", "rex", name)) @@ -102,7 +105,7 @@ func recorded(t *testing.T, name string, oldNew ...string) string { return strings.TrimSpace(strings.NewReplacer(oldNew...).Replace(string(b))) } -// addBlock adds a terminal block with the label. +// addBlock adds a terminal block with the given ID and label. func (r *fakeRex) addBlock(t *testing.T, block, label string) { t.Helper() r.blocks = append(r.blocks, recorded(t, "block.json", "{{ID}}", block, "{{LABEL}}", label)) @@ -110,15 +113,16 @@ func (r *fakeRex) addBlock(t *testing.T, block, label string) { r.write(t, "inspect."+label, recorded(t, "inspect.json", "{{ID}}", block, "{{LABEL}}", label)) } -// addAgent adds a block with the label, and an idle agent app in it. +// addAgent adds a terminal block with the given label in which the agent +// app runs and is idle. func (r *fakeRex) addAgent(t *testing.T, block, label, app string) { t.Helper() r.addBlock(t, block, label) r.status(t, block, app, "idle") } -// status sets the state of the agent app in block. It returns the values -// for the recorded files. +// status sets the state of the agent app in block. It returns the +// placeholder values, so the caller can fill in other templates with them. func (r *fakeRex) status(t *testing.T, block, app, state string) []string { t.Helper() values := []string{ @@ -129,7 +133,8 @@ func (r *fakeRex) status(t *testing.T, block, app, state string) []string { return values } -// write writes content to the file name of the fake. +// write writes content and a newline to the file name in the fake's +// directory. func (r *fakeRex) write(t *testing.T, name, content string) { t.Helper() if err := os.WriteFile(filepath.Join(r.dir, name), []byte(content+"\n"), 0o644); err != nil { @@ -137,7 +142,7 @@ func (r *fakeRex) write(t *testing.T, name, content string) { } } -// add adds the line to the file name of the fake. +// add appends line to the file name in the fake's directory. func (r *fakeRex) add(t *testing.T, name, line string) { t.Helper() f, err := os.OpenFile(filepath.Join(r.dir, name), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644) @@ -153,7 +158,8 @@ func (r *fakeRex) add(t *testing.T, name, line string) { } } -// calls returns the calls of rex, one a line. +// calls returns the arguments of each call to the fake rex so far, one +// call per line. func (r *fakeRex) calls(t *testing.T) string { t.Helper() b, err := os.ReadFile(filepath.Join(r.dir, "calls")) @@ -163,7 +169,7 @@ func (r *fakeRex) calls(t *testing.T) string { return string(b) } -// expectCalls waits until the calls of rex have each of wants. +// expectCalls waits until the calls to the fake rex contain all of wants. func (r *fakeRex) expectCalls(t *testing.T, wants ...string) { t.Helper() var calls string @@ -172,16 +178,16 @@ func (r *fakeRex) expectCalls(t *testing.T, wants ...string) { }) } -// setState sets the state of the agent pi and sends the event of the -// change. +// setState sets the state of the agent pi and sends the matching status +// event. func (r *fakeRex) setState(t *testing.T, state string) { t.Helper() values := r.status(t, "block:pi", "pi", state) r.add(t, "events", recorded(t, "event.json", values...)) } -// tools writes a fake program for each name in bodies, with its body, to -// a new directory. It returns the directory, for opts.path. +// tools writes a fake program for each entry in bodies to a new directory +// and returns the directory, for use as opts.path. func tools(t *testing.T, bodies map[string]string) string { t.Helper() dir := t.TempDir() @@ -191,15 +197,15 @@ func tools(t *testing.T, bodies map[string]string) string { return dir } -// clipboard returns a directory with a fake pbcopy and wl-copy, for -// opts.path. Each writes what it gets to the file clip in the directory. +// clipboard returns a directory with a fake pbcopy and wl-copy, for use +// as opts.path. Both write their input to the file clip in that directory. func clipboard(t *testing.T) string { t.Helper() const body = `cat > "${0%/*}/clip"` + "\n" return tools(t, map[string]string{"pbcopy": body, "wl-copy": body}) } -// clipEnv makes rexdiff take the fake clipboard on Linux too. +// clipEnv makes rexdiff use the fake wl-copy on Linux too. var clipEnv = []string{"WAYLAND_DISPLAY=w"} // lines returns the lines of s that contain text. diff --git a/test/files_test.go b/test/files_test.go index ece0849..9995d8b 100644 --- a/test/files_test.go +++ b/test/files_test.go @@ -1,8 +1,10 @@ package test import ( + "fmt" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -10,8 +12,9 @@ import ( "github.com/rednafi/rexdiff/internal/testgit" ) -// TestFilesTab checks that the Files tab lists all the files, expands and -// collapses an ignored directory, opens a file, and renders markdown. +// TestFilesTab checks that the Files tab lists every file, expands and +// collapses an ignored directory, opens a file, and that m switches a +// Markdown file to rendered view. func TestFilesTab(t *testing.T) { tm := start(t, newRepo(t), opts{}) tm.keys("2") @@ -28,11 +31,37 @@ func TestFilesTab(t *testing.T) { tm.waitFor("Title", "Some text.") } -// TestMarkdownSetting checks that markdown_view shows rendered markdown -// at the start. As in herdr-reviewr, a changed block of rendered markdown -// has the bar, and a comment on it is on all the lines of the block. A -// drag copies the rendered text. n goes to a comment that starts in a -// block. +// TestFilesTabDeleted checks that the Files tab leaves out a tracked file +// that was deleted from the working tree, while the Changes tab still +// shows the deletion. +func TestFilesTabDeleted(t *testing.T) { + repo := newRepo(t) + if err := os.Remove(filepath.Join(repo, "docs", "guide.txt")); err != nil { + t.Fatal(err) + } + tm := start(t, repo, opts{}) + tm.waitFor("D docs/guide.txt") + tm.keys("2") + tm.waitFor("Files (5)", "long.txt") + tm.waitGone("guide.txt") +} + +// TestInvisibleNames checks that the file list shows a zero-width space, +// a byte order mark and a right-to-left override in a file name as +// escapes. +func TestInvisibleNames(t *testing.T) { + repo := newRepo(t) + testgit.WriteFile(t, repo, "zero\u200bwidth.txt", "a\n") + testgit.WriteFile(t, repo, "\ufeffbom.txt", "b\n") + testgit.WriteFile(t, repo, "evil\u202etxt.exe", "c\n") + tm := start(t, repo, opts{}) + tm.waitFor(`"zero\u200bwidth.txt"`, `"\ufeffbom.txt"`, `"evil\u202etxt.exe"`) +} + +// TestMarkdownSetting checks that the markdown_view setting shows Markdown +// rendered from the start. It also checks, in rendered view, the change +// bars on changed blocks, comments and copying on a block, and moving to +// the next comment with n. func TestMarkdownSetting(t *testing.T) { repo := newRepo(t) testgit.WriteFile(t, repo, "README.md", "# Title\n\nGone.\n\nSome text.\n\n\n") @@ -49,9 +78,9 @@ func TestMarkdownSetting(t *testing.T) { tm.keys("1", "tab") tm.waitFor("Some text.") tm.waitUntil(func(s string) bool { - return sameLine(s, "▌", "New text on three lines.") && !sameLine(s, "▌", "Some text.") && - sameLine(s, "▌", "− 1 line removed") && - sameLine(s, "▌", "⚠ 1 changed line doesn't render · m to see") + return sameLine(s, "▎", "New text on three lines.") && !sameLine(s, "▎", "Some text.") && + sameLine(s, "▎", "− 1 line removed") && + sameLine(s, "▎", "⚠ 1 changed line doesn't render · m to see") }, "mark the changed blocks, the removed block, and the changed comment") tm.keys(":", "8", "enter") tm.waitCursor("New text on three lines.") @@ -60,11 +89,11 @@ func TestMarkdownSetting(t *testing.T) { tm.keys("enter") tm.waitFor("💬 hi") tm.keys("y") - waitFile(t, filepath.Join(bin, "clip"), func(s string) bool { + waitFile(t, filepath.Join(bin, "clip"), "the three new lines of README.md with their location", func(s string) bool { return s == "README.md:7-9\n+New text\n+on\n+three lines.\nhi" }) tm.drag("Some", "text.") - waitFile(t, filepath.Join(bin, "clip"), func(s string) bool { return s == "Some t" }) + waitFile(t, filepath.Join(bin, "clip"), `"Some t"`, func(s string) bool { return s == "Some t" }) tm.keys("m", ":", "8", "enter") tm.waitCursor("on") tm.keys("c") @@ -77,9 +106,50 @@ func TestMarkdownSetting(t *testing.T) { tm.waitCursor("New text on three lines.") } -// TestSearch checks that / finds files by name and lines by code, shows a -// preview, and opens the pick. The wheel scrolls the preview. A changed -// file opens in the Changes tab, at the line, even in a fold. +// TestMarkdownHunks checks that ] and [ in rendered Markdown visit every +// changed block in turn, including the marker of a removed block, and do +// not get stuck at that marker. +func TestMarkdownHunks(t *testing.T) { + repo, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + testgit.InitRepo(t, repo) + var old, cur strings.Builder + for n := 1; n <= 30; n++ { + fmt.Fprintf(&old, "## Section %d\n\nParagraph %d text.\n\n", n, n) + if n == 25 { + fmt.Fprintf(&cur, "## Section %d\n\n", n) // paragraph 25 is removed + continue + } + word := "text" + if n == 5 || n == 20 || n == 27 { + word = "CHANGED" + } + fmt.Fprintf(&cur, "## Section %d\n\nParagraph %d %s.\n\n", n, n, word) + } + testgit.WriteFile(t, repo, "doc.md", old.String()) + testgit.Git(t, repo, "add", "doc.md") + testgit.Git(t, repo, "commit", "-qm", "doc") + testgit.WriteFile(t, repo, "doc.md", cur.String()) + tm := start(t, repo, opts{width: 120, height: 20, args: []string{ + "-config", `{"markdown_view": "rendered"}`, + }}) + tm.waitFor("Section 1") + stops := []string{"Paragraph 5 CHANGED", "Paragraph 20 CHANGED", "1 line removed", "Paragraph 27 CHANGED"} + for _, s := range stops { + tm.keys("]") + tm.waitCursor(s) + } + for _, s := range slices.Backward(stops[:3]) { + tm.keys("[") + tm.waitCursor(s) + } +} + +// TestSearch checks that / finds files by name and lines by content, +// shows a preview that the mouse wheel scrolls, and opens a changed file +// in the Changes tab at the matching line. func TestSearch(t *testing.T) { tm := start(t, newRepo(t), opts{}) tm.keys("/") @@ -98,8 +168,8 @@ func TestSearch(t *testing.T) { tm.waitCursor("line 7") } -// TestLiveReload checks that the Changes tab shows an edit, a new file, -// and a removed file without a key. +// TestLiveReload checks that the Changes tab picks up an edit, a new file +// and a removed file without any key press. func TestLiveReload(t *testing.T) { repo := newRepo(t) tm := start(t, repo, opts{}) @@ -114,27 +184,59 @@ func TestLiveReload(t *testing.T) { tm.waitFor("Changes (2)") } -// TestLastTurn checks that the last-turn scope shows what the agent -// changes in its last turn. +// TestRemoveAfterRefresh checks that the Changes tab drops a file that +// was added, shown by r, and removed before the next check of the watcher. +func TestRemoveAfterRefresh(t *testing.T) { + repo := newRepo(t) + log := filepath.Join(t.TempDir(), "log") + tm := start(t, repo, opts{args: []string{"-log", log}}) + tm.waitFor("Changes (2)") + // After the first check, a check reports only what changed since the + // last one. + waitFile(t, log, "a watcher check", func(s string) bool { return strings.Contains(s, "msg=watch") }) + testgit.WriteFile(t, repo, "fresh.txt", "fresh\n") + tm.keys("r") + tm.waitFor("Changes (3)", "fresh.txt") + if err := os.Remove(filepath.Join(repo, "fresh.txt")); err != nil { + t.Fatal(err) + } + tm.waitFor("Changes (2)") +} + +// TestReloadStorm checks that after many quick writes to a file, as an +// agent makes, the Changes tab shows the content of the last write. +func TestReloadStorm(t *testing.T) { + repo := newRepo(t) + tm := start(t, repo, opts{}) + tm.waitFor("Changes (2)") + for i := 1; i <= 200; i++ { + testgit.WriteFile(t, repo, "a.go", fmt.Sprintf("package a\n\nfunc A() int { return %d }\n", i)) + testgit.WriteFile(t, repo, fmt.Sprintf("storm/%d.go", i%10), fmt.Sprintf("package storm // %d\n", i)) + } + tm.waitFor("return 200 }", "Changes (12)") +} + +// TestLastTurn checks that the last-turn scope shows the changes made +// while the agent was working. func TestLastTurn(t *testing.T) { repo := newRepo(t) rex := newRex(t, repo) log := filepath.Join(t.TempDir(), "log") tm := start(t, repo, opts{rex: rex, args: []string{"-log", log}}) tm.keys("t") - // rexdiff takes the states of an agent after its first list of them. + // rexdiff follows agent states only after it has listed the agents once. tm.waitFor("waiting for the first turn", "send → 1 agent") rex.setState(t, "working") - // The turn starts with a snapshot of the files. - waitFile(t, log, func(s string) bool { return strings.Contains(s, "msg=\"turn start\"") }) + // Wait until rexdiff has taken the snapshot that starts the turn. + waitFile(t, log, "a turn start message", func(s string) bool { return strings.Contains(s, "msg=\"turn start\"") }) testgit.WriteFile(t, repo, "turn.go", "package a\n") rex.setState(t, "idle") tm.waitFor("Changes (1)", "turn.go", "pi") } -// TestTurnRaced checks that a turn whose start comes right after a write -// is not known, because its start snapshot can have the write. The scope -// tells so, as herdr-reviewr does. +// TestTurnRaced checks that when a file is written just before a turn +// starts, the last-turn scope says that it could not snapshot the turn, +// because the snapshot may already include that write. func TestTurnRaced(t *testing.T) { repo := newRepo(t) rex := newRex(t, repo) @@ -146,12 +248,10 @@ func TestTurnRaced(t *testing.T) { tm.waitFor("the last turn started before rexdiff could snapshot it") } -// TestPR checks that the PR tab shows the pull request of the branch from -// gh, with its checks and comments, and the age of each comment. It also -// checks that o opens it, as in herdr-reviewr. Without {url}, the URL goes -// at the end of the opener command. o does not open a URL that is not -// http or https. The keys of the scopes and of the base picker do nothing -// on the PR tab, and r reads the pull request again. +// TestPR checks the PR tab: the pull request with its checks and comment +// ages, collapsible details blocks, o and links opening in the url_opener +// command, scope keys that leave the tab alone, a link with an unsupported +// scheme, and r to reload. func TestPR(t *testing.T) { repo := newRepo(t) testgit.Git(t, repo, "remote", "add", "origin", "https://github.com/o/r.git") @@ -187,12 +287,12 @@ EOF if s := tm.waitFor("opened PR in browser"); !strings.Contains(s, "Add b #7") { t.Errorf("a key of a scope leaves the PR tab. The screen shows:\n%s", s) } - waitFile(t, filepath.Join(bin, "opened"), func(s string) bool { + waitFile(t, filepath.Join(bin, "opened"), "the pull request URL", func(s string) bool { return s == "https://github.com/o/r/pull/7\n" }) tm.click("the docs", 1) tm.waitFor("opened link in browser") - waitFile(t, filepath.Join(bin, "opened"), func(s string) bool { + waitFile(t, filepath.Join(bin, "opened"), "the docs URL", func(s string) bool { return s == "https://example.com/docs\n" }) @@ -210,8 +310,8 @@ EOF tm.waitFor("Add b again #7") } -// TestNoPR checks that the PR tab tells why it has no pull request. The -// repository has no remote, or the tool of the remote is not installed. +// TestNoPR checks that the PR tab explains why it shows no pull request: +// the repository has no remote, or the forge tool is not installed. func TestNoPR(t *testing.T) { tm := start(t, newRepo(t), opts{}) tm.keys("3") @@ -219,7 +319,7 @@ func TestNoPR(t *testing.T) { repo := newRepo(t) testgit.Git(t, repo, "remote", "add", "origin", "https://github.com/o/r.git") - // The PATH has only git, as /usr/bin can have gh. + // The PATH holds only git, because /usr/bin can contain gh. bin := t.TempDir() testgit.LinkGit(t, bin) tm = start(t, repo, opts{env: []string{"PATH=" + bin}}) diff --git a/test/limits_test.go b/test/limits_test.go new file mode 100644 index 0000000..428a0ee --- /dev/null +++ b/test/limits_test.go @@ -0,0 +1,144 @@ +package test + +import ( + "fmt" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/rednafi/rexdiff/internal/proc" + "github.com/rednafi/rexdiff/internal/testgit" +) + +// fastImport runs git fast-import in repo with stream as its input. +func fastImport(t *testing.T, repo, stream string) { + t.Helper() + cmd := proc.Command(t.Context(), testgit.ToolEnv(t), "git", "fast-import", "--quiet") + cmd.Dir = repo + cmd.Stdin = strings.NewReader(stream) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git fast-import: %v: %s", err, out) + } +} + +// TestTooManyChanges checks that the Changes tab names the number of +// changed files, without listing them, when there are too many. The +// files are committed but missing from the working tree, so the test +// writes no files. +func TestTooManyChanges(t *testing.T) { + repo := newRepo(t) + head := strings.TrimSpace(testgit.Git(t, repo, "rev-parse", "HEAD")) + var b strings.Builder + fmt.Fprintf(&b, "blob\nmark :1\ndata 2\nx\n\ncommit refs/heads/feat\n"+ + "committer a 0 +0000\ndata 4\nmany\nfrom %s\n", head) + for i := range 20_001 { + fmt.Fprintf(&b, "M 100644 :1 many/%d\n", i) + } + fastImport(t, repo, b.String()+"\n") + testgit.Git(t, repo, "read-tree", "HEAD") + tm := start(t, repo, opts{}) + // The two changes of the fixture make 20,003. + tm.waitFor("20003 files changed — too many to show", "Changes (20003)") +} + +// TestCommitPickerLimit checks that the commit picker lists at most 1,000 +// commits over the base, and says so in its title. +func TestCommitPickerLimit(t *testing.T) { + repo := newRepo(t) + head := strings.TrimSpace(testgit.Git(t, repo, "rev-parse", "HEAD")) + var b strings.Builder + for i := range 1000 { + fmt.Fprintf(&b, "commit refs/heads/feat\ncommitter a %d +0000\ndata 8\nc %05d\n", i, i) + if i == 0 { + fmt.Fprintf(&b, "from %s\n", head) + } + } + fastImport(t, repo, b.String()) + tm := start(t, repo, opts{}) + tm.keys("g") + tm.waitFor("commits · newest 1000 over main", "c 00999") +} + +// TestFindQueryLimit checks that the find bar stops taking characters +// once the query holds 256. +func TestFindQueryLimit(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.keys("f", "ctrl+f") + tm.paste(strings.Repeat("x", 300)) + tm.waitFor("find xxxxxxxx", "no matches") + // If the query held more than 256 characters, some would remain. + tm.keys(slices.Repeat([]string{"bs"}, 256)...) + tm.typed("line 3") + tm.waitUntil(func(s string) bool { return sameLine(s, "find line 3", " 11") }, + "count 11 lines") +} + +// TestFindInLongLine checks that find shows a match far into a long line: +// by scrolling sideways, or when lines wrap and the line is taller than +// the screen, by scrolling down through it. +func TestFindInLongLine(t *testing.T) { + for _, wrap := range []string{"off", "on"} { + t.Run("wrap="+wrap, func(t *testing.T) { + repo := newRepo(t) + testgit.Git(t, repo, "checkout", "--", "long.txt") + testgit.WriteFile(t, repo, "a.go", + "package a\n\n// start "+strings.Repeat("word ", 2000)+"NEEDLE end\nvar x = 1\n") + tm := start(t, repo, opts{height: 20, args: []string{"-wrap", wrap}}) + tm.waitFor("// start") + tm.waitGone("NEEDLE") + tm.keys("ctrl+f") + tm.typed("needle") + tm.keys("enter") + tm.waitFor("NEEDLE end") + }) + } +} + +// TestClickFindBarShortScreen checks that on a screen too short for the +// body, a click on the find bar does not count as a click on the code +// above it, which would move the cursor. +func TestClickFindBarShortScreen(t *testing.T) { + tm := start(t, newRepo(t), opts{height: 4}) + tm.keys("ctrl+f") + before := strings.Split(tm.waitFor("find "), "\n") + tm.click("find ", 1) + tm.typed("z") + after := strings.Split(tm.waitFor("find z"), "\n") + if before[2] != after[2] { + t.Errorf("the code line changed from %q to %q", before[2], after[2]) + } +} + +// cursorLine returns the line of s with the cursor ▶, or "". +func cursorLine(s string) string { + for l := range strings.Lines(s) { + if strings.Contains(l, "▶") { + return strings.TrimRight(l, "\n") + } + } + return "" +} + +// TestCursorShortScreen checks that on a screen with room for only one +// row of code, the cursor row stays on screen after each key that moves +// the cursor. +func TestCursorShortScreen(t *testing.T) { + repo := filepath.Join(t.TempDir(), "repo") + testgit.InitRepo(t, repo) + testgit.WriteFile(t, repo, "u.txt", numbered("line", 1, 40)) + testgit.Git(t, repo, "add", ".") + testgit.Git(t, repo, "commit", "-qm", "first") + tm := start(t, repo, opts{width: 40, height: 4}) + tm.keys("2") + tm.waitCursor("line 1") + prev := cursorLine(tm.screen()) + for _, k := range []string{"down", "down", "ctrl+d", "up", "pgdown", "j"} { + tm.keys(k) + s := tm.waitUntil(func(s string) bool { + l := cursorLine(s) + return l != "" && l != prev + }, "show the cursor row after "+k) + prev = cursorLine(s) + } +} diff --git a/test/log_test.go b/test/log_test.go new file mode 100644 index 0000000..357c8e2 --- /dev/null +++ b/test/log_test.go @@ -0,0 +1,52 @@ +package test + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestLogPrivate checks that -log creates a log file that only the user can +// read, and that the log names the keys that the user presses but not the +// text typed into the comment box or the find bar. +func TestLogPrivate(t *testing.T) { + log := filepath.Join(t.TempDir(), "log") + tm := start(t, newRepo(t), opts{args: []string{"-log", log}}) + tm.keys("]", "c") + tm.typed("hunter2") + tm.keys("enter") + tm.waitFor("💬 hunter2") + tm.keys("/") + tm.waitFor("no preview") + tm.typed("swordfish") + tm.waitFor("› swordfish", "no matches") + tm.keys("esc") + tm.waitGone("› swordfish") + tm.keys("q", "Q") + if err := tm.exited(); err != nil { + t.Fatal(err) + } + info, err := os.Stat(log) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("the log has mode %v, want -rw-------", perm) + } + b, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + s := string(b) + for _, want := range []string{"key=]", "key=c", "key=enter", "key=text"} { + if !strings.Contains(s, want) { + t.Errorf("the log has no %q. It holds:\n%s", want, s) + } + } + for _, secret := range []string{"key=h", "key=2", "key=s", "key=w"} { + if strings.Contains(s, secret+" ") { + t.Errorf("the log has %q, a key typed as text. It holds:\n%s", secret, s) + } + } +} diff --git a/test/main_test.go b/test/main_test.go index ba06638..7b2588d 100644 --- a/test/main_test.go +++ b/test/main_test.go @@ -9,12 +9,12 @@ import ( ) var ( - buildDir string // the directory of the binaries that the tests build - homeDir string // the HOME of rexdiff, an empty directory + buildDir string // the directory for the binaries that the tests build + homeDir string // an empty directory that rexdiff uses as HOME ) -// TestMain makes the directories of the binaries and of HOME. Then it -// runs the tests with testgit. +// TestMain creates the directories for the binaries and for HOME, then +// runs the tests through testgit.Main. func TestMain(m *testing.M) { var err error if buildDir, err = os.MkdirTemp("", "rexdiff-test"); err != nil { diff --git a/test/upgrade_test.go b/test/migratelua_test.go similarity index 76% rename from test/upgrade_test.go rename to test/migratelua_test.go index 3081861..bfbc045 100644 --- a/test/upgrade_test.go +++ b/test/migratelua_test.go @@ -11,11 +11,11 @@ import ( "testing" ) -// TestUpgradeAtStart checks that rexdiff in Rex writes a changed -// rexdiff_toggle.lua again, keeps rexdiff.lua, and reloads Rex. It does not -// change a rexdiff_toggle.lua that runs another rexdiff. Without -// rexdiff_toggle.lua, it writes no file. -func TestUpgradeAtStart(t *testing.T) { +// TestMigrateLuaAtStart checks that when rexdiff starts in Rex, it +// restores an edited rexdiff_toggle.lua, keeps the user's rexdiff.lua and +// reloads the Rex config. It must leave alone a rexdiff_toggle.lua that +// runs a different rexdiff binary, and must not create a missing one. +func TestMigrateLuaAtStart(t *testing.T) { repo := newRepo(t) rex := newRex(t, repo) config := filepath.Join(t.TempDir(), "init.lua") @@ -43,7 +43,7 @@ func TestUpgradeAtStart(t *testing.T) { if got := read(t, lua); got != edited { t.Errorf("rexdiff.lua after the start = \n%s\nwant:\n%s", got, edited) } - // The install reloads once, and the upgrade once. + // Expect one reload from the install and one from the start. if n := strings.Count(rex.calls(t), "config reload"); n != 2 { t.Errorf("config reloads = %d, want 2", n) } @@ -72,22 +72,22 @@ func TestUpgradeAtStart(t *testing.T) { } } -// TestUpgradeFrom027 checks the first start of rexdiff in Rex after an -// upgrade from rexdiff 0.2.7. The rexdiff.lua of 0.2.7 has the settings, -// then the code under a marker line. rexdiff keeps the settings, puts the -// loader in place of the code, writes rexdiff_toggle.lua, and reloads Rex. -func TestUpgradeFrom027(t *testing.T) { +// TestMigrateLuaFromV027 checks that the first start after an update from +// 0.2.7 keeps the user's settings in rexdiff.lua, moves the action code to +// rexdiff_toggle.lua, and reloads the Rex config once. With a real rex, it +// also checks that Rex accepts the new config. +func TestMigrateLuaFromV027(t *testing.T) { repo := newRepo(t) rex := newRex(t, repo) dir := t.TempDir() config := filepath.Join(dir, "init.lua") write(t, config, "require(\"rexdiff\")\n") lua := filepath.Join(dir, "rexdiff.lua") - old := strings.Replace(read(t, filepath.Join("testdata", "rexdiff-0.2.7.lua")), + oldLua := strings.Replace(read(t, filepath.Join("testdata", "rexdiff-0.2.7.lua")), "{{BIN}}", binary(t), 1) - old = strings.Replace(old, " -- layout = \"split\",", " layout = \"split\",", 1) - write(t, lua, old) - settings, _, _ := strings.Cut(old, "-- rexdiff code below.") + oldLua = strings.Replace(oldLua, " -- layout = \"split\",", " layout = \"split\",", 1) + write(t, lua, oldLua) + settings, _, _ := strings.Cut(oldLua, "-- rexdiff code below.") tm := start(t, repo, opts{rex: rex, env: []string{"REX_CONFIG=" + config}}) tm.keys("q") @@ -111,7 +111,7 @@ func TestUpgradeFrom027(t *testing.T) { check.Env = append(cleanEnv(), "REX_CONFIG="+config) out, err := check.CombinedOutput() if err != nil || !strings.Contains(string(out), "actions: 1") { - t.Errorf("rex config check after the upgrade: %v\n%s", err, out) + t.Errorf("rex config check after migrateLua: %v\n%s", err, out) } } } @@ -126,7 +126,8 @@ func read(t *testing.T, path string) string { return string(b) } -// write writes content to the file at path. +// write writes content to the file at path, replacing any existing +// content. func write(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o644); err != nil { diff --git a/test/navigator_test.go b/test/navigator_test.go new file mode 100644 index 0000000..579d7e1 --- /dev/null +++ b/test/navigator_test.go @@ -0,0 +1,214 @@ +package test + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// manyFiles is a repository with navigatorFiles committed files, f00.txt to +// f59.txt, each with an uncommitted new first line. +var manyFiles = testgit.NewFixture(func(tb testing.TB, dir string) { + tb.Helper() + testgit.InitRepo(tb, dir) + for i := range navigatorFiles { + testgit.WriteFile(tb, dir, fileName(i), numbered("old", 1, 3)) + } + testgit.Git(tb, dir, "add", ".") + testgit.Git(tb, dir, "commit", "-qm", "first") + for i := range navigatorFiles { + testgit.WriteFile(tb, dir, fileName(i), content(i)+"\n"+numbered("old", 1, 3)) + } +}) + +// navigatorFiles is the number of files in manyFiles. +const navigatorFiles = 60 + +// fileName returns the name of file i in manyFiles. +func fileName(i int) string { return fmt.Sprintf("f%02d.txt", i) } + +// content returns the uncommitted first line of file i in manyFiles. +func content(i int) string { return fmt.Sprintf("content of file %02d", i) } + +// navigatorRow returns the text of the file list row for file i. +func navigatorRow(i int) string { return "M " + fileName(i) } + +// newManyFiles returns the path of a new copy of the manyFiles +// repository. The path contains no symbolic links. +func newManyFiles(t *testing.T) string { + t.Helper() + tmp, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(tmp, "repo") + manyFiles.Copy(t, dir) + return dir +} + +// rowY returns the screen line of s on which text first appears, or -1. +func rowY(s, text string) int { + if _, y, ok := position(s, text); ok { + return y + } + return -1 +} + +// shownFiles returns the indices of the first and last files of manyFiles +// that the file list on the screen s shows, or -1 and -1 if it shows none. +func shownFiles(s string) (first, last int) { + first, last = -1, -1 + for i := range navigatorFiles { + if rowY(s, navigatorRow(i)) >= 0 { + if first < 0 { + first = i + } + last = i + } + } + return first, last +} + +// TestNavigatorWheelClick checks that after scrolling the file list with +// the wheel, a click opens the file under the pointer without scrolling +// the list again. It also checks that j and k scroll the list by one row +// only when the cursor moves past its top or bottom edge, and that the +// list keeps its scroll position when hidden and shown again. +func TestNavigatorWheelClick(t *testing.T) { + tm := start(t, newManyFiles(t), opts{height: 20}) + tm.waitFor(navigatorRow(0), content(0)) + // The wheel scrolls the list but does not move the cursor. Ten wheel + // steps of three rows each put file 30 at the top. + tm.wheel(navigatorRow(0), 10) + s := tm.waitUntil(func(s string) bool { + first, _ := shownFiles(s) + return first == 30 + }, "scroll the list to file 30") + if strings.Contains(s, navigatorRow(0)) || !strings.Contains(s, content(0)) { + t.Fatalf("the wheel does not scroll the list only:\n%s", s) + } + first, last := shownFiles(s) + y := rowY(s, navigatorRow(40)) + tm.click(navigatorRow(40), 1) + // A frame can be drawn in parts, so each wait checks the whole screen. + tm.waitUntil(func(s string) bool { + return strings.Contains(s, content(40)) && rowY(s, navigatorRow(40)) == y + }, fmt.Sprintf("open file 40 with its row on line %d", y)) + + // Inside the visible rows, the cursor moves and the list stays put. + // Past the top or bottom row, the list scrolls by one row. + for _, step := range []struct { + key string + from, to int // the cursor position before and after the keys + first, last int // the first and last files shown afterwards + }{ + {key: "k", from: 40, to: first, first: first, last: last}, + {key: "k", from: first, to: first - 1, first: first - 1, last: last - 1}, + {key: "j", from: first - 1, to: last - 1, first: first - 1, last: last - 1}, + {key: "j", from: last - 1, to: last, first: first, last: last}, + } { + for i := step.from; i != step.to; { + if step.key == "j" { + i++ + } else { + i-- + } + tm.keys(step.key) + } + tm.waitUntil(func(s string) bool { + f, l := shownFiles(s) + return strings.Contains(s, content(step.to)) && f == step.first && l == step.last + }, fmt.Sprintf("open file %d with files %d to %d in the list", step.to, step.first, step.last)) + } + // After the list is hidden and shown again, it shows the same rows. + tm.keys("z") + tm.waitGone(navigatorRow(first)) + tm.keys("z") + tm.waitUntil(func(s string) bool { + f, l := shownFiles(s) + return f == first && l == last + }, fmt.Sprintf("show files %d to %d in the list", first, last)) +} + +// TestMainWheelClick checks that after scrolling the main pane with the +// wheel, a click puts the cursor on the line under the pointer without +// scrolling the pane. +func TestMainWheelClick(t *testing.T) { + tm := start(t, newRepo(t), opts{height: 20}) + tm.click("M long.txt", 1) + tm.waitFor("⋯ 32 unmodified lines") + tm.keys(":", "2", "0", "enter") // going to line 20 expands the fold + tm.waitCursor("line 20") + tm.keys(":", "1", "enter") + tm.waitFor(" first") + // Five wheel steps of three rows each put line 16 at the top of the + // code, below the header and the first line. + tm.wheel(" first", 5) + s := tm.waitUntil(func(s string) bool { + return strings.Contains(s, "line 16") && !strings.Contains(s, "line 15") + }, "scroll to line 16") + y := rowY(s, "line 25") + tm.click("line 25", 1) + tm.waitUntil(func(s string) bool { + return sameLine(s, "▶", "line 25") && rowY(s, "line 25") == y + }, fmt.Sprintf("put the cursor on line 25, on line %d", y)) +} + +// TestWideNameCut checks that a file list row with a cut name of wide +// characters still shows the whole line count, for list widths of either +// parity, so that the cut falls both between and inside wide characters. +func TestWideNameCut(t *testing.T) { + repo := filepath.Join(t.TempDir(), "repo") + testgit.InitRepo(t, repo) + names := []string{strings.Repeat("中文", 10) + ".txt", strings.Repeat("😀", 20) + ".md"} + for _, n := range names { + testgit.WriteFile(t, repo, n, "old\n") + } + testgit.Git(t, repo, "add", ".") + testgit.Git(t, repo, "commit", "-qm", "first") + for _, n := range names { + testgit.WriteFile(t, repo, n, "new\n") + } + for _, w := range []int{30, 31} { + t.Run(strconv.Itoa(w), func(t *testing.T) { + tm := start(t, repo, opts{width: 90, height: 14, args: []string{ + "-config", fmt.Sprintf(`{"file_list_width": %d}`, w), + }}) + tm.waitUntil(func(s string) bool { + return sameLine(s, "M …", "文.txt", "+1 -1") && sameLine(s, "M …😀", "😀.md", "+1 -1") + }, "the cut names with their whole line counts") + }) + } +} + +// TestWideTitleCut checks that the main pane title with a cut path of wide +// characters fits in the pane and keeps its line count, for widths of +// either parity, so that the cut falls both between and inside wide +// characters. A title that is too wide pushes the file list to the right, +// so the end of its rows falls off the screen. +func TestWideTitleCut(t *testing.T) { + for _, name := range []string{strings.Repeat("中文", 30) + ".txt", strings.Repeat("😀", 60) + ".md"} { + repo := filepath.Join(t.TempDir(), "repo") + testgit.InitRepo(t, repo) + testgit.WriteFile(t, repo, name, "old\n") + testgit.Git(t, repo, "add", ".") + testgit.Git(t, repo, "commit", "-qm", "first") + testgit.WriteFile(t, repo, name, "new\n") + end := string([]rune(name)[len([]rune(name))-5:]) + for _, width := range []uint16{80, 81} { + t.Run(end+"/"+strconv.Itoa(int(width)), func(t *testing.T) { + tm := start(t, repo, opts{width: width, height: 14}) + s := tm.waitUntil(func(s string) bool { + return sameLine(s, "…", end+" +1 -1") && sameLine(s, "Changes (1)") + }, "the cut title with its line count") + if !sameLine(s, "M …", "+1 -1") { + t.Errorf("the file list row is cut short:\n%s", s) + } + }) + } + } +} diff --git a/test/norace_test.go b/test/norace_test.go index 6283e17..96586e4 100644 --- a/test/norace_test.go +++ b/test/norace_test.go @@ -2,5 +2,5 @@ package test -// raceOn is true in a run with the race detector. +// raceOn reports whether the tests run with the race detector. const raceOn = false diff --git a/test/perf_test.go b/test/perf_test.go new file mode 100644 index 0000000..b03dd37 --- /dev/null +++ b/test/perf_test.go @@ -0,0 +1,758 @@ +package test + +// The tests in this file measure rexdiff on large repositories, which +// they keep in the directory REXDIFF_PERF and generate if missing. They +// run only if REXDIFF_PERF is set. REXDIFF_PERF_OUT names a file to append +// "repositoryscenariovalue" lines to, and REXDIFF_PERF_BIN +// another rexdiff binary to measure. +// +// REXDIFF_PERF=/tmp/rexdiff-perf REXDIFF_PERF_OUT=/tmp/perf.tsv \ +// go test ./test -run TestPerf -count=1 -v -timeout 60m +// +// A time runs from the input to the last screen change before quietDelay +// without changes. RSS is in MB and CPU use in percent. + +import ( + "fmt" + "math/rand/v2" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/charmbracelet/x/vt" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// quietDelay is how long the screen must stay unchanged to count as +// settled. +const quietDelay = 1200 * time.Millisecond + +// settleTimeout is the longest time to wait for the screen to settle. +const settleTimeout = 5 * time.Minute + +// perfOut is the file named by REXDIFF_PERF_OUT, once it is open. +var perfOut struct { + sync.Mutex + f *os.File +} + +// perfRepo returns the path of the repository called name in the +// REXDIFF_PERF directory, generating it first if it does not exist. It +// skips t if REXDIFF_PERF is not set or the race detector is on. +func perfRepo(t *testing.T, name string) string { + t.Helper() + dir := os.Getenv("REXDIFF_PERF") + if dir == "" { + t.Skip("REXDIFF_PERF is not set") + } + if raceOn { + t.Skip("the race detector makes the times wrong") + } + repo := filepath.Join(dir, name) + if _, err := os.Stat(filepath.Join(repo, ".git")); err == nil { + return repo + } + gen, ok := perfRepos[strings.TrimSuffix(name, "-branch")] + if !ok { + t.Fatalf("no repository %s", name) + } + main := filepath.Join(dir, strings.TrimSuffix(name, "-branch")) + for _, d := range []string{main, main + "-branch"} { + if err := os.RemoveAll(d); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(main, 0o755); err != nil { + t.Fatal(err) + } + testgit.InitRepo(t, main) + gen(t, main) + return repo +} + +// perfRepos maps each repository name to the function that fills dir, a +// new empty git repository, with that repository's content. +var perfRepos = map[string]func(t *testing.T, dir string){ + "go7k": genGo7k, + "big50k": func(t *testing.T, dir string) { t.Helper(); genScattered(t, dir, goLines(50_000, "")) }, + "big200k": func(t *testing.T, dir string) { t.Helper(); genScattered(t, dir, shortLines(200_000)) }, + "rewrite20k": genRewrite20k, + "xxl": genXXL, // also creates xxl-branch + "md5mb": func(t *testing.T, dir string) { t.Helper(); genMarkdown(t, dir, 5<<20, 997) }, + "md400k": func(t *testing.T, dir string) { t.Helper(); genMarkdown(t, dir, 400<<10, 397) }, + "files3k": genFiles3k, +} + +// commitAll stages and commits every file in dir. +func commitAll(t *testing.T, dir string) { + t.Helper() + testgit.Git(t, dir, "add", "-A") + testgit.Git(t, dir, "commit", "-q", "--no-verify", "-m", "base") +} + +// goLines returns n lines of Go code. Most lines i, counting from 1, end +// with the marker L, so a test can find them on the screen. tag is +// added to the function names so they differ between files. +func goLines(n int, tag string) []string { + ls := make([]string, 0, n) + ls = append(ls, "package big", "") + for i := 3; i <= n; i++ { + switch i % 20 { + case 3: + ls = append(ls, fmt.Sprintf("func %sf%d(x int) int { // L%d", tag, i, i)) + case 19: + ls = append(ls, fmt.Sprintf("\treturn x // L%d", i)) + case 0: + ls = append(ls, "}") + default: + ls = append(ls, fmt.Sprintf("\tx += compute(%d, \"s%d\") // L%d", i, i*7, i)) + } + } + return ls[:n] +} + +// shortLines returns n short lines of Go code, with the same markers as +// goLines. 200,000 lines take about 4 MB. +func shortLines(n int) []string { + ls := goLines(n, "") + for i, l := range ls { + if strings.Contains(l, "compute(") { + ls[i] = fmt.Sprintf("\tx += %d // L%d", i+1, i+1) + } + } + return ls +} + +// mutate returns a copy of ls with about the fraction frac of its lines +// changed, in runs of 1 to 8 lines. A changed line has the marker M +// instead of L. Some lines are deleted, and some new lines are +// inserted. +func mutate(ls []string, frac float64, r *rand.Rand) []string { + out := make([]string, 0, len(ls)) + // A run is 4.5 lines long on average. + p := frac / 4.5 / (1 - frac) + for i := 0; i < len(ls); i++ { + if r.Float64() >= p { + out = append(out, ls[i]) + continue + } + run := 1 + r.IntN(8) + for k := 0; k < run && i < len(ls); k++ { + l := ls[i] + switch r.IntN(10) { + case 0: // delete the line + case 1: // insert a new line before it + out = append(out, fmt.Sprintf("\tlog.Printf(\"inserted %d\") // I%d", i, i), l) + default: + out = append(out, strings.ReplaceAll(strings.Replace(l, "compute", "recompute", 1), "// L", "// M")) + } + i++ + } + i-- + } + return out +} + +// join joins the lines, ending each with a newline. +func join(ls []string) string { return strings.Join(ls, "\n") + "\n" } + +// genGo7k creates a 7,000-line Go file with about 30% of its lines +// changed, and a small file with one edit. The changes are left +// uncommitted. +func genGo7k(t *testing.T, dir string) { + t.Helper() + old := goLines(7000, "") + testgit.WriteFile(t, dir, "big.go", join(old)) + testgit.WriteFile(t, dir, "small.go", "package big\n\nfunc small() {}\n") + commitAll(t, dir) + r := rand.New(rand.NewPCG(1, 7)) //nolint:gosec // a fixed seed makes the files reproducible + testgit.WriteFile(t, dir, "big.go", join(mutate(old, 0.30, r))) + testgit.WriteFile(t, dir, "small.go", "package big\n\nfunc small() { println() }\n") +} + +// genScattered commits the file huge.go with the lines old, then changes +// 200 lines at random places without committing. +func genScattered(t *testing.T, dir string, old []string) { + t.Helper() + testgit.WriteFile(t, dir, "huge.go", join(old)) + commitAll(t, dir) + r := rand.New(rand.NewPCG(2, uint64(len(old)))) //nolint:gosec // a fixed seed makes the files reproducible + ls := slices.Clone(old) + for range 200 { + i := 2 + r.IntN(len(ls)-2) + ls[i] = strings.ReplaceAll(ls[i], "// L", "// M") + } + testgit.WriteFile(t, dir, "huge.go", join(ls)) +} + +// genRewrite20k commits a 20,000-line file, then changes every line +// without committing. +func genRewrite20k(t *testing.T, dir string) { + t.Helper() + testgit.WriteFile(t, dir, "rewrite.go", join(goLines(20000, "a"))) + commitAll(t, dir) + ls := goLines(20000, "b") + for i := range ls { + ls[i] = strings.ReplaceAll(strings.ReplaceAll(ls[i], "compute", "other"), "// L", "// N") + } + testgit.WriteFile(t, dir, "rewrite.go", join(ls)) +} + +// genXXL creates a pull request-sized change: 2,000 changed files of 200 +// to 2,000 lines each, about 450,000 changed lines in total. In xxl the +// changes are uncommitted. In xxl-branch they are committed on the branch +// feature, with main as the base. +func genXXL(t *testing.T, dir string) { + t.Helper() + r := rand.New(rand.NewPCG(4, 2000)) //nolint:gosec // a fixed seed makes the files reproducible + type file struct { + name string + old []string + } + files := make([]file, 2000) + for i := range files { + n := 200 + r.IntN(1801) + name := fmt.Sprintf("pkg%02d/sub%d/file%04d.go", i%40, i%7, i) + files[i] = file{name, goLines(n, fmt.Sprintf("p%d", i))} + testgit.WriteFile(t, dir, name, join(files[i].old)) + } + commitAll(t, dir) + for _, f := range files { + testgit.WriteFile(t, dir, f.name, join(mutate(f.old, 0.23, r))) + } + branch := dir + "-branch" + copyDir(t, dir, branch) + testgit.Git(t, branch, "checkout", "-q", "-b", "feature") + commitAll(t, branch) +} + +// markdown returns about size bytes of Markdown lines with headings, +// lists, code blocks, tables and links. +func markdown(size int) []string { + var ls []string + n := 0 + for i := 0; n < size; i++ { + block := []string{ + fmt.Sprintf("## Section %d", i), + "", + fmt.Sprintf("Paragraph %d with **bold**, _italic_, `code`, and a [link][ref%d]. %s", + i, i%50, strings.Repeat("Lorem ipsum dolor sit amet. ", 4)), + "", + fmt.Sprintf("- item %d.1", i), + fmt.Sprintf("- item %d.2", i), + "", + "```go", + fmt.Sprintf("func s%d() int { return %d }", i, i), + "```", + "", + "| a | b |", + "|---|---|", + fmt.Sprintf("| %d | %d |", i, i*2), + "", + } + for _, l := range block { + n += len(l) + 1 + } + ls = append(ls, block...) + } + for i := range 50 { + ls = append(ls, fmt.Sprintf("[ref%d]: https://example.com/%d", i, i)) + } + return ls +} + +// genMarkdown commits a README.md of about size bytes, then edits each +// line whose index is a multiple of every, without committing. +func genMarkdown(t *testing.T, dir string, size, every int) { + t.Helper() + old := markdown(size) + testgit.WriteFile(t, dir, "README.md", join(old)) + commitAll(t, dir) + ls := slices.Clone(old) + for i := 0; i < len(ls); i += every { + ls[i] += " (edited)" + } + testgit.WriteFile(t, dir, "README.md", join(ls)) +} + +// genFiles3k commits 3,000 files, then changes 10 of them without +// committing. +func genFiles3k(t *testing.T, dir string) { + t.Helper() + r := rand.New(rand.NewPCG(5, 3000)) //nolint:gosec // a fixed seed makes the files reproducible + name := func(i int) string { return fmt.Sprintf("src/mod%02d/part%d/f%04d.go", i%60, i%5, i) } + for i := range 3000 { + testgit.WriteFile(t, dir, name(i), join(goLines(50+r.IntN(300), fmt.Sprintf("g%d", i)))) + } + commitAll(t, dir) + for i := 0; i < 3000; i += 300 { + b, err := os.ReadFile(filepath.Join(dir, name(i))) + if err != nil { + t.Fatal(err) + } + testgit.WriteFile(t, dir, name(i), string(b)+"// tail edit\n") + } +} + +// recordTime records the duration d in milliseconds; see [record]. +func recordTime(t *testing.T, repo, scenario string, d time.Duration) { + t.Helper() + record(t, repo, scenario, float64(d.Microseconds())/1000) +} + +// record logs one measurement, v, and appends it to the REXDIFF_PERF_OUT +// file, if set. +func record(t *testing.T, repo, scenario string, v float64) { + t.Helper() + t.Logf("PERF %-12s %-36s %10.1f", repo, scenario, v) + path := os.Getenv("REXDIFF_PERF_OUT") + if path == "" { + return + } + perfOut.Lock() + defer perfOut.Unlock() + if perfOut.f == nil { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + perfOut.f = f + } + fmt.Fprintf(perfOut.f, "%s\t%s\t%.1f\n", repo, scenario, v) +} + +// drain discards a pending screen change notification, if any. +func (tm *term) drain() { + select { + case <-tm.changed: + default: + } +} + +// settle waits until the screen has not changed for quietDelay. It +// returns the time of the last change, or since if nothing changed, and +// the screen. +func (tm *term) settle(since time.Time) (time.Time, string) { + tm.t.Helper() + return tm.settleFrom(since, tm.screen(), quietDelay, false) +} + +// settleFrom is like settle, but prev is the screen before the input and +// first is the longest time to wait for the first change. If +// ignoreLastLine is set, changes to the last line of the screen, where the +// find bar shows a blinking cursor, do not count. +func (tm *term) settleFrom(since time.Time, prev string, first time.Duration, ignoreLastLine bool) (time.Time, string) { + tm.t.Helper() + last := since + prev = withoutBlink(prev, ignoreLastLine) + limit := time.NewTimer(settleTimeout) + defer limit.Stop() + q := time.NewTimer(first) + defer q.Stop() + for { + select { + case <-tm.changed: + // A write that does not change the text, such as a colour query, + // does not count. + if s := withoutBlink(tm.screen(), ignoreLastLine); s != prev { + last, prev = time.Now(), s + q.Reset(quietDelay) + } + case <-q.C: + return last, tm.screen() + case <-tm.done: + tm.t.Fatalf("rexdiff stops. The screen shows:\n%s", tm.screen()) + case <-limit.C: + tm.t.Fatalf("the screen does not settle. It shows:\n%s", tm.screen()) + } + } +} + +// withoutBlink returns the screen s, without its last line if +// ignoreLastLine is set. +func withoutBlink(s string, ignoreLastLine bool) string { + if !ignoreLastLine { + return s + } + s = strings.TrimRight(s, "\n") + if i := strings.LastIndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +// changeTimeout is the longest time to wait for the screen to start +// changing after an input. +const changeTimeout = 15 * time.Second + +// waitForText waits up to settleTimeout until the screen shows text. +func (tm *term) waitForText(text string) { + tm.t.Helper() + limit := time.NewTimer(settleTimeout) + defer limit.Stop() + for !strings.Contains(tm.screen(), text) { + select { + case <-tm.changed: + case <-time.After(50 * time.Millisecond): + case <-limit.C: + tm.t.Fatalf("the screen does not show %q", text) + } + } +} + +// measure runs act and records how long the screen takes to settle +// afterwards. It returns the settled screen. +func (tm *term) measure(repo, scenario string, act func()) string { + tm.t.Helper() + return tm.measureScreen(repo, scenario, false, act) +} + +// measureFind is like measure, but ignores the last line of the screen, +// where the find bar shows a blinking cursor. +func (tm *term) measureFind(repo, scenario string, act func()) string { + tm.t.Helper() + return tm.measureScreen(repo, scenario, true, act) +} + +// measureScreen implements measure and measureFind. +func (tm *term) measureScreen(repo, scenario string, ignoreLastLine bool, act func()) string { + tm.t.Helper() + // Start from a screen that is not changing. + tm.settleFrom(time.Now(), tm.screen(), quietDelay, ignoreLastLine) + tm.drain() + t0 := time.Now() + prev := tm.screen() + act() + last, s := tm.settleFrom(t0, prev, changeTimeout, ignoreLastLine) + if last.Equal(t0) { + tm.t.Logf("PERF %s %s: the screen does not change", repo, scenario) + } + recordTime(tm.t, repo, scenario, last.Sub(t0)) + return s +} + +// repeat returns n copies of k. +func repeat(k string, n int) []string { + return slices.Repeat([]string{k}, n) +} + +// perfRun is a rexdiff process that a performance test measures. +type perfRun struct { + *term +} + +// startPerf starts rexdiff on repo in a 200 by 50 terminal, and records +// the time until the first screen and until rexdiff responds to the first +// key. +func startPerf(t *testing.T, name, repo string, o opts) *perfRun { + t.Helper() + o.width, o.height = 200, 50 + o.bin = os.Getenv("REXDIFF_PERF_BIN") + if o.bin == "" { + o.bin = binary(t) // build before starting the clock + } + t0 := time.Now() + tm := start(t, repo, o) // start waits for the first frame + recordTime(t, name, "first screen", time.Since(t0)) + // The first screen can appear while rexdiff is still busy, for example + // handling terminal replies. Its response to a key shows when it is + // really ready. + prev := tm.screen() + tm.drain() + tm.keys("j") + last, _ := tm.settleFrom(t0, prev, changeTimeout, false) + recordTime(t, name, "first key answered (from start)", last.Sub(t0)) + tm.keys("k") + tm.settle(time.Now()) + return &perfRun{term: tm} +} + +// pid returns the process ID of the running rexdiff, a child of the test +// process. +func pid(t *testing.T) string { + t.Helper() + out, err := exec.CommandContext(t.Context(), "pgrep", "-P", strconv.Itoa(os.Getpid()), "rexdiff").Output() + if err != nil { + t.Fatalf("pgrep: %v", err) + } + return strings.Fields(string(out))[0] +} + +// usage returns the resident memory of rexdiff in MB and the CPU time it +// has used in seconds. +func usage(t *testing.T) (rssMB, cpu float64) { + t.Helper() + out, err := exec.CommandContext(t.Context(), "ps", "-o", "rss=,time=", "-p", pid(t)).Output() + if err != nil { + t.Fatalf("ps: %v", err) + } + f := strings.Fields(string(out)) + kb, _ := strconv.ParseFloat(f[0], 64) + return kb / 1024, cpuSeconds(f[1]) +} + +// cpuSeconds converts a CPU time from ps, such as "M:SS.ss" or +// "H:MM:SS", to seconds. +func cpuSeconds(s string) float64 { + var total float64 + for p := range strings.SplitSeq(s, ":") { + v, _ := strconv.ParseFloat(p, 64) + total = total*60 + v + } + return total +} + +// idle records the resident memory, and the CPU use over d while rexdiff +// gets no input. +func (r *perfRun) idle(name string, d time.Duration) { + r.t.Helper() + r.settle(time.Now()) + rss, c0 := usage(r.t) + time.Sleep(d) + _, c1 := usage(r.t) + record(r.t, name, "RSS MB (value)", rss) + record(r.t, name, fmt.Sprintf("idle CPU %% over %v (value)", d), (c1-c0)/d.Seconds()*100) +} + +// copyRepo copies the repository at src to a temporary directory, so a +// test can edit the copy. +func copyRepo(t *testing.T, src string) string { + t.Helper() + dst := filepath.Join(t.TempDir(), filepath.Base(src)) + copyDir(t, src, dst) + return dst +} + +// copyDir copies the directory src to dst with cp -R. +func copyDir(t *testing.T, src, dst string) { + t.Helper() + if out, err := exec.CommandContext(t.Context(), "cp", "-R", src, dst).CombinedOutput(); err != nil { + t.Fatalf("cp: %v\n%s", err, out) + } +} + +// prependLine inserts line at the start of the file at path. +func prependLine(t *testing.T, path, line string) { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append([]byte(line+"\n"), b...), 0o644); err != nil { + t.Fatal(err) + } +} + +// wheelAt scrolls the mouse wheel down n times at column x, line y. +func (tm *term) wheelAt(x, y, n int) { + for range n { + tm.mouse(vt.MouseWheel{X: x, Y: y, Button: vt.MouseWheelDown}) + } +} + +// clickAt clicks the left mouse button at column x, line y. +func (tm *term) clickAt(x, y int) { + tm.mouse(vt.MouseClick{X: x, Y: y, Button: vt.MouseLeft}) + tm.mouse(vt.MouseRelease{X: x, Y: y, Button: vt.MouseLeft}) +} + +// noteTooBig is the note that rexdiff shows instead of a file that is too +// large. +const noteTooBig = "file too large to show" + +// viewScenarios measures common actions on the diff of one large file: +// moving, scrolling, wrapping, split view, finding, and switching tabs. +func viewScenarios(r *perfRun, name, findQuery string) { + m := r.measure + m(name, "j x200", func() { r.keys(repeat("j", 200)...) }) + m(name, "k x200", func() { r.keys(repeat("k", 200)...) }) + m(name, "j x1", func() { r.keys("j") }) + m(name, "] x30", func() { r.keys(repeat("]", 30)...) }) + m(name, "[ x30", func() { r.keys(repeat("[", 30)...) }) + m(name, "pgdown x1", func() { r.keys("pgdown") }) + m(name, "pgdown x20", func() { r.keys(repeat("pgdown", 20)...) }) + m(name, "goto :$", func() { r.keys(":", "$", "enter") }) + m(name, "goto :0", func() { r.keys(":", "0", "enter") }) + m(name, "wheel x30", func() { r.wheelAt(120, 25, 30) }) + m(name, "click after wheel", func() { r.clickAt(120, 25) }) + // Toggling wrap does not change a screen of short lines, so send a j + // afterwards to see when the toggle is done. + m(name, "w wrap off (+j)", func() { r.keys("w", "j") }) + m(name, "right (scroll x) x1", func() { r.keys("right") }) + m(name, "left (scroll x) x1", func() { r.keys("left") }) + m(name, "w wrap on (+j)", func() { r.keys("w", "j") }) + m(name, "| split on", func() { r.keys("|") }) + m(name, "j x50 (split)", func() { r.keys(repeat("j", 50)...) }) + m(name, "| split off", func() { r.keys("|") }) + m(name, "ctrl+f open", func() { r.keys("ctrl+f") }) + // The find bar at the bottom shows the query, the number of matches + // and a blinking cursor. + r.measureFind(name, "find type query", func() { r.typed(findQuery) }) + r.measureFind(name, "find enter x10", func() { r.keys(repeat("enter", 10)...) }) + m(name, "find close", func() { r.keys("esc") }) + m(name, "tab 2 (Files)", func() { r.keys("2") }) + m(name, "tab 1 (Changes)", func() { r.keys("1") }) + m(name, "z hide list", func() { r.keys("z") }) + m(name, "z show list", func() { r.keys("z") }) + m(name, "p x4 move list round", func() { r.keys("p", "p", "p", "p") }) +} + +// TestPerfGo7k measures rexdiff on a 7,000-line file with many changes. +func TestPerfGo7k(t *testing.T) { + name := "go7k" + r := startPerf(t, name, perfRepo(t, name), opts{}) + r.measure(name, "f next file (small)", func() { r.keys("f") }) + r.measure(name, "F prev file (7k)", func() { r.keys("F") }) + viewScenarios(r, name, "recompute") + r.idle(name, 10*time.Second) +} + +// TestPerfReload measures how long rexdiff takes to show an external edit +// to the file on screen. +func TestPerfReload(t *testing.T) { + for _, c := range []struct{ name, file string }{ + {"go7k", "big.go"}, + {"xxl", filepath.Join("pkg00", "sub0", "file0000.go")}, + } { + t.Run(c.name, func(t *testing.T) { + repo := copyRepo(t, perfRepo(t, c.name)) + r := startPerf(t, c.name+"-copy", repo, opts{}) + for i := range 2 { + t0 := time.Now() + prependLine(t, filepath.Join(repo, c.file), fmt.Sprintf("// EXTERNAL EDIT %d", i+1)) + r.waitForText(fmt.Sprintf("EXTERNAL EDIT %d", i+1)) + recordTime(t, c.name, fmt.Sprintf("reload after external edit #%d", i+1), time.Since(t0)) + r.settle(time.Now()) + } + }) + } +} + +// TestPerfBig measures rexdiff on files of 20,000 and 50,000 lines, and on +// a file of 200,000 lines, which is too large to show. +func TestPerfBig(t *testing.T) { + for _, c := range []struct{ name, query string }{ + {"rewrite20k", "other"}, + {"big50k", "M1"}, + {"big200k", "M1"}, + } { + t.Run(c.name, func(t *testing.T) { + r := startPerf(t, c.name, perfRepo(t, c.name), opts{}) + if s := r.screen(); strings.Contains(s, noteTooBig) { + t.Logf("PERF %s: too large to show", c.name) + } else { + viewScenarios(r, c.name, c.query) + } + r.measure(c.name, "files: j x200", func() { r.keys("2"); r.keys(repeat("j", 200)...) }) + r.measure(c.name, "files: goto :$", func() { r.keys(":", "$", "enter") }) + r.keys("1") + r.idle(c.name, 5*time.Second) + }) + } +} + +// TestPerfMarkdown measures rexdiff on large Markdown files, raw and +// rendered. md5mb is too large to show, so it measures the note instead. +func TestPerfMarkdown(t *testing.T) { + for _, name := range []string{"md400k", "md5mb"} { + t.Run(name, func(t *testing.T) { + r := startPerf(t, name, perfRepo(t, name), opts{}) + if s := r.screen(); strings.Contains(s, noteTooBig) { + t.Logf("PERF %s: too large to show", name) + } + m := r.measure + m(name, "j x200", func() { r.keys(repeat("j", 200)...) }) + m(name, "m rendered on", func() { r.keys("m") }) + m(name, "rendered: j x200", func() { r.keys(repeat("j", 200)...) }) + m(name, "rendered: goto :$", func() { r.keys(":", "$", "enter") }) + m(name, "m rendered off", func() { r.keys("m") }) + m(name, "tab 2 (Files)", func() { r.keys("2") }) + m(name, "files: m rendered on", func() { r.keys("m") }) + m(name, "files: m rendered off", func() { r.keys("m") }) + r.idle(name, 5*time.Second) + }) + } +} + +// TestPerfXXL measures rexdiff on 2,000 changed files, both as +// uncommitted changes and as a branch. +func TestPerfXXL(t *testing.T) { + for _, c := range []struct { + name string + args []string + }{ + {"xxl", nil}, + {"xxl-branch", []string{"-scope", "branch", "-base", "main"}}, + } { + t.Run(c.name, func(t *testing.T) { + name := c.name + r := startPerf(t, name, perfRepo(t, name), opts{args: c.args}) + m := r.measure + m(name, "f next file x1", func() { r.keys("f") }) + m(name, "f next file x20", func() { r.keys(repeat("f", 20)...) }) + m(name, "F prev file x20", func() { r.keys(repeat("F", 20)...) }) + m(name, "j x200", func() { r.keys(repeat("j", 200)...) }) + m(name, "pgdown x20", func() { r.keys(repeat("pgdown", 20)...) }) + m(name, "] x30 (crosses files)", func() { r.keys(repeat("]", 30)...) }) + m(name, "[ x30", func() { r.keys(repeat("[", 30)...) }) + m(name, "tab focus list", func() { r.keys("tab") }) + m(name, "list j x200", func() { r.keys(repeat("j", 200)...) }) + m(name, "list k x200", func() { r.keys(repeat("k", 200)...) }) + m(name, "list wheel x30", func() { r.wheelAt(190, 25, 30) }) + m(name, "list click", func() { r.clickAt(190, 30) }) + m(name, "list wheel x1", func() { r.wheelAt(190, 25, 1) }) + m(name, "tab focus diff", func() { r.keys("tab") }) + m(name, "wheel x30", func() { r.wheelAt(60, 25, 30) }) + m(name, "z hide list", func() { r.keys("z") }) + m(name, "z show list", func() { r.keys("z") }) + m(name, "tab 2 (Files)", func() { r.keys("2") }) + m(name, "tab 1 (Changes)", func() { r.keys("1") }) + m(name, "/ search open", func() { r.keys("/") }) + m(name, "search type query", func() { r.typed("file0999") }) + m(name, "search close", func() { r.keys("esc") }) + r.idle(name, 10*time.Second) + }) + } +} + +// TestPerfFiles3k measures the Files tab in a repository of 3,000 files. +func TestPerfFiles3k(t *testing.T) { + name := "files3k" + r := startPerf(t, name, perfRepo(t, name), opts{}) + m := r.measure + m(name, "tab 2 (Files)", func() { r.keys("2") }) + m(name, "tab focus list", func() { r.keys("tab") }) + m(name, "list right (expand dir) x1", func() { r.keys("right") }) + m(name, "list j x200", func() { r.keys(repeat("j", 200)...) }) + m(name, "list k x200", func() { r.keys(repeat("k", 200)...) }) + m(name, "list wheel x30", func() { r.wheelAt(190, 25, 30) }) + m(name, "list click", func() { r.clickAt(190, 30) }) + m(name, "/ search open", func() { r.keys("/") }) + m(name, "search type query", func() { r.typed("f2999") }) + m(name, "search close", func() { r.keys("esc") }) + m(name, "tab 1 (Changes)", func() { r.keys("1") }) + r.idle(name, 10*time.Second) +} + +// TestPerfOpenClose records the memory use of rexdiff after the first and +// the tenth time it opens a large file in each tab and layout, to show +// whether memory grows. It does not fail if it does. +func TestPerfOpenClose(t *testing.T) { + name := "big50k" + r := startPerf(t, name, perfRepo(t, name), opts{}) + for i := range 10 { + r.keys("2", ":", "$", "enter", "1", "w", "|", "w", "|") + r.settle(time.Now()) + if i == 0 || i == 9 { + rss, _ := usage(t) + record(t, name, fmt.Sprintf("RSS MB after %d opens (value)", i+1), rss) + } + } +} diff --git a/test/race_test.go b/test/race_test.go index 63cb408..a4172a5 100644 --- a/test/race_test.go +++ b/test/race_test.go @@ -2,5 +2,5 @@ package test -// raceOn is true in a run with the race detector. +// raceOn reports whether the tests run with the race detector. const raceOn = true diff --git a/test/resize_test.go b/test/resize_test.go new file mode 100644 index 0000000..92ca632 --- /dev/null +++ b/test/resize_test.go @@ -0,0 +1,65 @@ +package test + +import ( + "fmt" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// drawn reports whether s is a whole frame of a w by h terminal: it is +// not blank, it has at most h lines and none wider than w, and on a +// terminal big enough for them it has the tab bar on the first line and +// the key hints on the last, or a whole box that covers the screen. +func drawn(s string, w, h int) bool { + lines := strings.Split(s, "\n") + if strings.TrimSpace(s) == "" || len(lines) > h { + return false + } + for _, l := range lines { + if ansi.StringWidth(l) > w { + return false + } + } + if w < 60 || h < 5 { + return true + } + if strings.HasPrefix(strings.TrimSpace(lines[0]), "╭") { + return sameLine(s, "╰", "╯") + } + return len(lines) == h && strings.Contains(lines[0], "1 Changes") && strings.TrimSpace(lines[h-1]) != "" +} + +// TestResize checks that rexdiff draws a whole frame at each size while a +// box or bar is open, from 1 by 1 up to 300 by 80, and that the box is +// still open and closes at the end. +func TestResize(t *testing.T) { + sizes := [][2]int{{1, 1}, {10, 3}, {300, 80}, {40, 10}, {160, 40}} + tests := []struct { + name string + open []string + shows string + }{ + {"help", []string{"?"}, "pick commits"}, + {"search", []string{"/", "l", "o", "n", "g"}, "preview · long.txt"}, + {"comment box", []string{"]", "c", "t", "y", "p", "e", "d"}, "enter save"}, + {"quit prompt", []string{"]", "c", "h", "i", "enter", "q"}, "unsent comment will be lost"}, + {"find bar", []string{"ctrl+f", "r", "e", "t"}, "find ret"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.keys(tt.open...) + tm.waitFor(tt.shows) + for _, size := range sizes { + w, h := size[0], size[1] + tm.resize(w, h) + tm.waitUntil(func(s string) bool { return drawn(s, w, h) }, fmt.Sprintf("draw a whole %dx%d frame", w, h)) + } + tm.waitFor(tt.shows) + tm.keys("esc") + tm.waitGone(tt.shows) + }) + } +} diff --git a/test/rex_test.go b/test/rex_test.go deleted file mode 100644 index 313e63a..0000000 --- a/test/rex_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package test - -import ( - "context" - "encoding/json/v2" - "fmt" - "os" - "os/exec" - "strconv" - "strings" - "testing" - "time" -) - -// realRex runs rex in the session of a test with a real Rex. -type realRex struct { - t *testing.T - bin string // the path of rex - session string -} - -// run runs rex with args and returns its output. It fails the test if rex -// fails. -func (r *realRex) run(args ...string) string { - r.t.Helper() - cmd := exec.CommandContext(r.t.Context(), r.bin, append([]string{"--autostart=false"}, args...)...) - out, err := cmd.Output() - if err != nil { - r.t.Fatalf("rex %s: %v", strings.Join(args, " "), err) - } - return string(out) -} - -// blocks returns the IDs of the blocks of the session, by label. -func (r *realRex) blocks() map[string]string { - r.t.Helper() - var reply struct { - Blocks []struct { - ID string `json:"block_id"` - Label string `json:"label"` - } `json:"blocks"` - } - out := r.run("api", "call", "-s", r.session, "session.list_blocks", "{}") - if err := json.Unmarshal([]byte(out), &reply); err != nil { - r.t.Fatal(err) - } - ids := map[string]string{} - for _, b := range reply.Blocks { - ids[b.Label] = b.ID - } - return ids -} - -// write types data into block. -func (r *realRex) write(block, data string) { - r.t.Helper() - args, err := json.Marshal(map[string]string{"data": data}) - if err != nil { - r.t.Fatal(err) - } - r.run("block", "call", "-s", r.session, "-b", block, "write", string(args)) -} - -// waitFor waits until the screen of block shows each of texts. -func (r *realRex) waitFor(block string, texts ...string) { - r.t.Helper() - var s string - poll(r.t, func() bool { - s = r.run("capture", "-s", r.session, "-b", block) - return hasAll(s, texts...) - }, func() string { return "block " + block + " does not show " + strings.Join(texts, ", ") + ":\n" + s }) -} - -// TestRealRex runs rexdiff in a pane of a real Rex, beside a shell that -// says it is the agent pi. It checks that rexdiff finds the agent, and -// that a send pastes the review into the shell. It runs only if -// REXDIFF_REX is the path of rex and Rex runs. It makes a session of its -// own, and does not change the config of Rex. -func TestRealRex(t *testing.T) { - bin := os.Getenv("REXDIFF_REX") - if bin == "" { - t.Skip("set REXDIFF_REX to the path of rex to run rexdiff in a real Rex") - } - repo := newRepo(t) - r := &realRex{t: t, bin: bin, session: "rexdiff-test-" + strconv.Itoa(os.Getpid())} - r.run("new", r.session, "--cwd", repo, "--", "sh") - t.Cleanup(func() { - // t.Context is done in a cleanup, so drop its cancel. - ctx, cancel := context.WithTimeoutCause(context.WithoutCancel(t.Context()), 10*time.Second, - fmt.Errorf("rex kill: 10s timeout: %w", context.DeadlineExceeded)) - defer cancel() - kill := exec.CommandContext(ctx, bin, "--autostart=false", "kill", r.session) - _ = kill.Run() - }) - shell := r.blocks()["shell"] - r.run("block", "rename", shell, "agent", "-s", r.session) - r.write(shell, `printf '\033]7501;state=idle:app=pi\007'`+"\n") - - r.run("split", "-s", r.session, "-b", shell, "--cwd", repo, "--shell=none", - "--", binary(t), "-config", `{"focus_agent_after_send": false}`, repo) - var pane string - poll(t, func() bool { - for label, id := range r.blocks() { - if label != "agent" { - pane = id - } - } - return pane != "" - }, func() string { return "rexdiff does not start in a pane" }) - r.waitFor(pane, "Changes (2)") - r.write(pane, "]") - r.write(pane, "c") - r.write(pane, "hi from rexdiff") - r.write(pane, "\r") - r.waitFor(pane, "💬 hi from rexdiff") - r.write(pane, "s") - r.waitFor(pane, "sent 1 comment to agent") - r.waitFor(shell, "a.go:4 (removed)", "hi from rexdiff") - if got := r.blocks(); got["rexdiff"] == "" { - t.Errorf("blocks %v, want the label rexdiff on the pane", got) - } -} diff --git a/test/rexdiff_test.go b/test/rexdiff_test.go index 6e8e3e3..f83124c 100644 --- a/test/rexdiff_test.go +++ b/test/rexdiff_test.go @@ -1,8 +1,7 @@ -// Package test runs the rexdiff program as a user does. Each test starts -// the binary in a terminal, types keys, clicks, and reads the screen. A -// fake rex stands in for Rex, and the other tools are fakes on PATH. git -// is real. The tests run one at a time, because each runs a program in a -// terminal. +// Package test runs the rexdiff program the way a user does. Each test +// starts the binary in a terminal emulator, types keys, clicks, and reads +// the screen. Rex and the other tools are fake scripts on PATH, but git is +// the real git. package test import ( @@ -28,13 +27,14 @@ import ( "github.com/rednafi/rexdiff/internal/testgit" ) -// buildDev builds rexdiff once. The build serves all the tests, as -// TestMain does, so it has the context of no test. +// buildDev builds rexdiff once and shares the result between all tests, +// so it uses context.Background instead of a test's context. var buildDev = sync.OnceValues(func() (string, error) { return build(context.Background(), filepath.Join(buildDir, "dev"), "") }) -// binary returns the path of a rexdiff that the package builds once. +// binary returns the path of the development rexdiff binary, building it +// on the first call. func binary(t *testing.T) string { t.Helper() bin, err := buildDev() @@ -44,10 +44,10 @@ func binary(t *testing.T) string { return bin } -// build builds rexdiff with the version into dir, and returns its path. -// A run with the race detector builds a rexdiff with it. The build stops -// when ctx is done. -buildvcs=false keeps the version of a git tag out, -// so a build with no version does not look for updates. +// build builds rexdiff into dir with the given version and returns the +// binary's path. When the tests run with the race detector, it builds with +// -race too. The build stops when ctx is done. -buildvcs=false stops a +// build with an empty version from picking up a version from git tags. func build(ctx context.Context, dir, version string) (string, error) { bin := filepath.Join(dir, "rexdiff") args := []string{"build", "-buildvcs=false", "-o", bin, "-ldflags", "-X main.version=" + version} @@ -61,24 +61,18 @@ func build(ctx context.Context, dir, version string) (string, error) { return bin, nil } -// userEnv are the variables of the user that change what rexdiff does. -// Each run starts with them empty. +// userEnv lists the user's environment variables that change how rexdiff +// behaves. Each run sets them to empty values. var userEnv = []string{ "REX_SESSION", "REX_BLOCK", "REX_BIN_DIR", "REX_CONFIG", "REXDIFF_AGENT", "REXDIFF_LOG", "REXDIFF_RESUME", "EDITOR", "VISUAL", } -// cleanEnv returns the environment of the process for a run of rexdiff. -// It has no git variable of the user, and the variables of userEnv are -// empty. HOME is homeDir, so no file of the user applies. Git gets the -// variables of testgit.Env. +// cleanEnv returns the environment for running rexdiff. It drops the +// user's GIT_ variables, empties the variables in userEnv, sets HOME to +// homeDir, and adds the git settings from testgit.Env. func cleanEnv() []string { - var env []string - for _, kv := range os.Environ() { - if !strings.HasPrefix(kv, "GIT_") { - env = append(env, kv) - } - } + env := slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "GIT_") }) for _, k := range userEnv { env = append(env, k+"=") } @@ -86,34 +80,36 @@ func cleanEnv() []string { return append(env, testgit.Env()...) } -// opts are the options of a rexdiff run. +// opts holds the options for running rexdiff. type opts struct { - args []string // the flags. start adds the repo. - bin string // the rexdiff to run, or "" for the one of binary + args []string // the flags; start appends the repository path + bin string // the rexdiff binary to run, or "" for the one from binary width uint16 // 0 means 160 height uint16 // 0 means 40 rex *fakeRex // nil runs rexdiff outside Rex - path string // a directory of fake tools, or "" - env []string // more variables + path string // a directory of fake tools to put on PATH, or "" + env []string // extra environment variables } -// term is a rexdiff that runs in a terminal. +// term is a rexdiff process running in a terminal emulator. type term struct { t *testing.T emu *vt.Emulator mu sync.Mutex // guards emu - wait func() error // waits for rexdiff once, and keeps its error - // changed gets a value after each write to the screen. It holds one - // value, so a write that comes before a wait is not lost. + pty *os.File // the terminal of rexdiff + wait func() error // waits for rexdiff to exit; later calls return the same error + // changed receives a value after each write to the screen. Its buffer + // holds one value, so a write that happens before a wait is not lost. changed chan struct{} - done chan struct{} // closed when the screen gets no more output + done chan struct{} // closed when rexdiff's output ends } -// waitTime is how long a test waits for the screen or for a file. +// waitTime is how long a test waits for the screen or a file to change +// before it fails. const waitTime = 15 * time.Second -// start runs rexdiff on repo in a new terminal. The test stops it at the -// end. +// start runs rexdiff on repo in a new terminal and waits for the first +// screen. rexdiff is stopped when the test ends. func start(t *testing.T, repo string, o opts) *term { t.Helper() w, h := cmp.Or(o.width, 160), cmp.Or(o.height, 40) @@ -123,14 +119,16 @@ func start(t *testing.T, repo string, o opts) *term { } cmd := exec.CommandContext(t.Context(), bin, append(o.args, repo)...) cmd.Dir = repo - // A stop lets rexdiff remove its files before it exits. + // SIGTERM, unlike a kill, lets rexdiff remove its files before it + // exits. cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } cmd.WaitDelay = 2 * time.Second var dirs []string if o.path != "" { dirs = append(dirs, o.path) } - // The PATH of ToolEnv has git, but no other tool of the user. + // The PATH from ToolEnv includes git but none of the user's other + // tools. cmd.Env = append(cleanEnv(), "TERM=xterm-256color") cmd.Env = append(cmd.Env, testgit.ToolEnv(t, dirs...)...) if o.rex != nil { @@ -145,6 +143,7 @@ func start(t *testing.T, repo string, o opts) *term { tm := &term{ t: t, emu: vt.NewEmulator(int(w), int(h)), + pty: f, wait: sync.OnceValue(cmd.Wait), changed: make(chan struct{}, 1), done: make(chan struct{}), @@ -160,7 +159,7 @@ func start(t *testing.T, repo string, o opts) *term { tm.mu.Unlock() select { case tm.changed <- struct{}{}: - default: // the wait has a value that it does not take yet + default: // a change is already pending } } if err != nil { @@ -168,20 +167,20 @@ func start(t *testing.T, repo string, o opts) *term { } } }() - // The input of rexdiff comes from the emulator, as from a terminal. It - // has the keys and the mouse of the test. It also has the answers to - // the queries of rexdiff, for example for the colours. + // The emulator produces rexdiff's input: the test's key presses and + // mouse events, and replies to terminal queries, such as colour + // queries. input := make(chan struct{}) go func() { defer close(input) _, _ = io.Copy(f, tm.emu) }() t.Cleanup(func() { - _ = tm.wait() // t.Context is done, so Cancel stops rexdiff + _ = tm.wait() // t.Context is done by now, so cmd.Cancel stops rexdiff _ = f.Close() <-tm.done - // Close of the pipe, not of the emulator, ends the copy. The Close - // of the emulator races with its Read. + // Close the input pipe, not the emulator, to end the copy, because + // closing the emulator races with its Read. _ = tm.emu.InputPipe().(io.Closer).Close() <-input }) @@ -189,6 +188,20 @@ func start(t *testing.T, repo string, o opts) *term { return tm } +// resize changes the size of the terminal, as a user does who resizes +// the window. It clears the screen, so that what shows next is what +// rexdiff draws at the new size. +func (tm *term) resize(w, h int) { + tm.t.Helper() + tm.mu.Lock() + tm.emu.Resize(w, h) + _, _ = tm.emu.Write([]byte("\x1b[2J")) + tm.mu.Unlock() + if err := pty.Setsize(tm.pty, &pty.Winsize{Cols: uint16(w), Rows: uint16(h)}); err != nil { //nolint:gosec // the sizes of the tests fit + tm.t.Fatal(err) + } +} + // screen returns the text on the screen. func (tm *term) screen() string { tm.mu.Lock() @@ -196,9 +209,10 @@ func (tm *term) screen() string { return tm.emu.String() } -// keys types each key. A key is a name in keyCodes, or else a text. As -// in a terminal, an esc and the key after it can join into one key, so esc -// must be the last key. Wait for the screen after it, as a user does. +// keys types each key in turn. A key is either a name from keyCodes or +// literal text. An esc followed by another key can be read as a single +// key, so esc must come last; wait for the screen before sending more +// keys. func (tm *term) keys(keys ...string) { tm.t.Helper() if i := slices.Index(keys, "esc"); i >= 0 && i != len(keys)-1 { @@ -215,13 +229,20 @@ func (tm *term) keys(keys ...string) { } } -// typed types each character of text. +// paste pastes text as a terminal does, in bracketed paste mode. +func (tm *term) paste(text string) { + tm.mu.Lock() + defer tm.mu.Unlock() + tm.emu.Paste(text) +} + +// typed types text one character at a time. func (tm *term) typed(text string) { tm.t.Helper() tm.keys(strings.Split(text, "")...) } -// keyCodes are the named keys. +// keyCodes maps key names to key presses. var keyCodes = map[string]vt.KeyPressEvent{ "enter": {Code: vt.KeyEnter}, "esc": {Code: vt.KeyEscape}, @@ -239,15 +260,16 @@ var keyCodes = map[string]vt.KeyPressEvent{ "bs": {Code: vt.KeyBackspace}, } -// at returns the column and the line of text on the screen, from 0. +// at waits until the screen shows text and returns its column and line, +// counting from 0. func (tm *term) at(text string) (x, y int) { tm.t.Helper() x, y, _ = position(tm.waitFor(text), text) return x, y } -// position returns the column and the line of the first text in s, from -// 0. ok is false if s does not have text. +// position returns the column and line of the first occurrence of text in +// s, counting from 0. ok is false if s does not contain text. func position(s, text string) (x, y int, ok bool) { for i, l := range strings.Split(s, "\n") { if before, _, found := strings.Cut(l, text); found { @@ -257,14 +279,15 @@ func position(s, text string) (x, y int, ok bool) { return 0, 0, false } -// mouse sends the mouse event m, as the terminal encodes it. +// mouse sends the mouse event m to rexdiff, encoded as a terminal would +// encode it. func (tm *term) mouse(m vt.Mouse) { tm.mu.Lock() defer tm.mu.Unlock() tm.emu.SendMouse(m) } -// click clicks text on the screen the given number of times in a row. +// click clicks on text on the screen the given number of times in a row. func (tm *term) click(text string, times int) { tm.t.Helper() x, y := tm.at(text) @@ -274,8 +297,8 @@ func (tm *term) click(text string, times int) { } } -// drag drags the mouse from the start of text from to the start of text -// to. +// drag drags the mouse from the start of the text from to the start of +// the text to, holding the left button. func (tm *term) drag(from, to string) { tm.t.Helper() x0, y0 := tm.at(from) @@ -294,7 +317,7 @@ func (tm *term) wheel(text string, n int) { } } -// waitFor waits until the screen shows each of texts, and returns the +// waitFor waits until the screen shows all of texts, and returns the // screen. func (tm *term) waitFor(texts ...string) string { tm.t.Helper() @@ -306,20 +329,20 @@ func (tm *term) waitFor(texts ...string) string { func (tm *term) waitGone(texts ...string) string { tm.t.Helper() return tm.waitUntil(func(s string) bool { return !hasAny(s, texts...) }, - "not show "+strings.Join(texts, ", ")) + "stop showing "+strings.Join(texts, ", ")) } -// hasAll reports whether s has each of texts. +// hasAll reports whether s contains all of texts. func hasAll(s string, texts ...string) bool { return !slices.ContainsFunc(texts, func(t string) bool { return !strings.Contains(s, t) }) } -// hasAny reports whether s has one of texts. +// hasAny reports whether s contains at least one of texts. func hasAny(s string, texts ...string) bool { return slices.ContainsFunc(texts, func(t string) bool { return strings.Contains(s, t) }) } -// sameLine reports whether a line of s has each of texts. +// sameLine reports whether a single line of s contains all of texts. func sameLine(s string, texts ...string) bool { for l := range strings.Lines(s) { if hasAll(l, texts...) { @@ -329,15 +352,17 @@ func sameLine(s string, texts ...string) bool { return false } -// waitCursor waits until the line of the cursor shows text. +// waitCursor waits until the line with the cursor marker ▶ also +// shows text. func (tm *term) waitCursor(text string) { tm.t.Helper() tm.waitUntil(func(s string) bool { return sameLine(s, "▶", text) }, "put the cursor on "+text) } -// waitUntil waits until ok is true for the screen, and returns the screen. -// It checks the screen after each write to it. +// waitUntil waits until ok reports true for the screen, and returns the +// screen. It checks again after every write to the screen. what describes +// the condition in the failure message. func (tm *term) waitUntil(ok func(string) bool, what string) string { tm.t.Helper() timeout := time.NewTimer(waitTime) @@ -350,8 +375,8 @@ func (tm *term) waitUntil(ok func(string) bool, what string) string { select { case <-tm.changed: case <-tm.done: - // rexdiff writes no more. The last write can come after the - // check. + // The output has ended, but the last write may have arrived + // after the check above, so check once more. if s = tm.screen(); ok(s) { return s } @@ -362,40 +387,42 @@ func (tm *term) waitUntil(ok func(string) bool, what string) string { } } -// pollTime is the time between two checks of poll. -const pollTime = 20 * time.Millisecond +// pollInterval is how often poll checks its condition. +const pollInterval = 20 * time.Millisecond -// poll checks again and again until ok is true. It is for the files that -// the fake tools write, and for other programs, because they give no -// event. It fails the test with the text of why after waitTime. +// poll calls ok until it reports true. Use it for files that the fake +// tools write and for other programs, which send no notification when +// they change. If ok is still false after waitTime, poll fails the test +// with the message from why. func poll(t *testing.T, ok func() bool, why func() string) { t.Helper() timeout := time.NewTimer(waitTime) defer timeout.Stop() - tick := time.NewTicker(pollTime) - defer tick.Stop() + tick := time.Tick(pollInterval) for !ok() { select { - case <-tick.C: + case <-tick: case <-timeout.C: t.Fatal(why()) } } } -// waitFile waits until the file at path has content, and returns it. -func waitFile(t *testing.T, path string, content func(string) bool) string { +// waitFile waits until content reports true for the content of the file +// at path, and returns that content. want describes the expected content +// for the failure message. +func waitFile(t *testing.T, path, want string, content func(string) bool) string { t.Helper() var s string poll(t, func() bool { b, _ := os.ReadFile(path) s = string(b) return content(s) - }, func() string { return path + " has " + strconv.Quote(s) }) + }, func() string { return path + " has " + strconv.Quote(s) + ", want " + want }) return s } -// exited waits until rexdiff exits and returns its error. +// exited waits for rexdiff to exit and returns its exit error. func (tm *term) exited() error { tm.t.Helper() errc := make(chan error, 1) diff --git a/test/testdata/script/cli.txt b/test/testdata/script/cli.txt index 04d2de2..86821b4 100644 --- a/test/testdata/script/cli.txt +++ b/test/testdata/script/cli.txt @@ -8,6 +8,11 @@ stderr '^usage: rexdiff' ! exec rexdiff -nope stderr -count=1 'flag provided but not defined: -nope' +# A -log FIFO with no reader is an error at once, not a wait for a reader. +exec mkfifo fifo +! exec rexdiff -log fifo repo +stderr '^rexdiff: open log fifo: the FIFO has no reader, start one first$' + # -check checks the settings without a repository, for the Rex action. ! exec rexdiff -check -config '{"layuot": "split"}' stderr '^rexdiff: config: unknown key "layuot"$' @@ -23,6 +28,39 @@ exec rexdiff -check -config '{"keybindings": {"list-wider": ["W"], "preview": [" ! exec rexdiff -check -config '{"keybindings": {"rendered": ["M"], "preview": ["P"]}}' stderr '^rexdiff: config: invalid value for keybindings: "rendered" and "preview" name the same action$' +# The names of the settings and of the actions are those of herdr-reviewr. +exec rexdiff -check -config '{"keybindings": {"next-hunk": ["x"], "send": ["S"], "navigator-hide": ["Z"], "keys": ["H"]}, "file_list": "left", "file_list_width": 40, "url_opener": "open {url}"}' +! exec rexdiff -check -config '{"file_list_width": 3}' +stderr '^rexdiff: config: invalid value for file_list_width: expected 16 or more, not "3"$' +! exec rexdiff -check -config '{"keybindings": {"down": ["bogus"]}}' +stderr '^rexdiff: config: invalid value for keybindings.down: expected a list of keys' +! exec rexdiff -check -config '{"keybindings": {"down": ["x"], "up": ["x"]}}' +stderr '^rexdiff: config: invalid value for keybindings: "x" is bound to both "down" and "up"$' +! exec rexdiff -check -config '{"keybindings": {"down": ["x", "x"]}}' +stderr '^rexdiff: config: invalid value for keybindings: "x" is bound twice to "down"$' +! exec rexdiff -check -config '{"keybindings": {"down": ["U"]}}' +stderr '^rexdiff: config: invalid value for keybindings: "U" is bound to both "down" and "update"$' +! exec rexdiff -check -config '{"keybindings": {"next-hunk": ["j"]}}' +stderr '^rexdiff: config: invalid value for keybindings: "j" is bound to both "down" and "next-hunk"$' + +# Other names of the settings and of the actions are unknown keys. +! exec rexdiff -check -config '{"list_position": "left"}' +stderr '^rexdiff: config: unknown key "list_position"$' +! exec rexdiff -check -config '{"list_width": 40}' +stderr '^rexdiff: config: unknown key "list_width"$' +! exec rexdiff -check -config '{"browser": "open {url}"}' +stderr '^rexdiff: config: unknown key "browser"$' +! exec rexdiff -check -config '{"keybindings": {"move-down": ["x"]}}' +stderr '^rexdiff: config: unknown key "keybindings.move-down"$' +! exec rexdiff -check -config '{"keybindings": {"next-change": ["x"]}}' +stderr '^rexdiff: config: unknown key "keybindings.next-change"$' +! exec rexdiff -check -config '{"keybindings": {"toggle-split": ["x"]}}' +stderr '^rexdiff: config: unknown key "keybindings.toggle-split"$' +! exec rexdiff -check -config '{"keybindings": {"toggle-help": ["x"]}}' +stderr '^rexdiff: config: unknown key "keybindings.toggle-help"$' +! exec rexdiff -check -config '{"keybindings": {"upgrade": ["x"]}}' +stderr '^rexdiff: config: unknown key "keybindings.upgrade"$' + # A bad value of a flag names the flag, as the settings do. ! exec rexdiff -layout sideways repo stderr '^rexdiff: invalid value for -layout: expected one of unified, split, not "sideways"$' @@ -30,3 +68,10 @@ stderr '^rexdiff: invalid value for -layout: expected one of unified, split, not stderr '^rexdiff: invalid value for -scope: expected one of uncommitted, branch, last-turn, not "all"$' ! exec rexdiff -wrap no repo stderr '^rexdiff: invalid value for -wrap: expected one of on, off, not "no"$' + +# -check checks the flags as a normal start does. +! exec rexdiff -check -layout sideways +stderr '^rexdiff: invalid value for -layout: expected one of unified, split, not "sideways"$' +! exec rexdiff -check -scope all +stderr '^rexdiff: invalid value for -scope: expected one of uncommitted, branch, last-turn, not "all"$' +exec rexdiff -check -layout split -scope branch diff --git a/test/update_test.go b/test/update_test.go index 426078a..0374245 100644 --- a/test/update_test.go +++ b/test/update_test.go @@ -7,10 +7,13 @@ import ( "compress/gzip" "crypto/sha256" "encoding/hex" + "fmt" + "hash/fnv" "net/http" "net/http/httptest" "os" "os/exec" + "path/filepath" "runtime" "strings" "sync" @@ -18,22 +21,24 @@ import ( "testing/fstest" ) -// release serves the latest release tag with the binary at bin, as GitHub -// does. It returns the address of the latest release. -func release(t *testing.T, tag, bin string) string { +// release starts a fake GitHub release server whose latest release is +// tag, with an archive of the binary at bin and its checksum. If tampered, +// the checksum does not match the archive. It returns the URL of the +// latest release. +func release(t *testing.T, tag, bin string, tampered bool) string { t.Helper() - content, err := os.ReadFile(bin) + binData, err := os.ReadFile(bin) if err != nil { t.Fatal(err) } var b bytes.Buffer gz := gzip.NewWriter(&b) tw := tar.NewWriter(gz) - hdr := &tar.Header{Name: "rexdiff", Mode: 0o755, Size: int64(len(content)), Typeflag: tar.TypeReg} + hdr := &tar.Header{Name: "rexdiff", Mode: 0o755, Size: int64(len(binData)), Typeflag: tar.TypeReg} if err := tw.WriteHeader(hdr); err != nil { t.Fatal(err) } - if _, err := tw.Write(content); err != nil { + if _, err := tw.Write(binData); err != nil { t.Fatal(err) } if err := tw.Close(); err != nil { @@ -45,6 +50,9 @@ func release(t *testing.T, tag, bin string) string { name := "rexdiff-" + tag + "-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" archive := b.Bytes() sum := sha256.Sum256(archive) + if tampered { + sum[0]++ + } files := fstest.MapFS{ name: {Data: archive}, "sha256sums.txt": {Data: []byte(hex.EncodeToString(sum[:]) + " " + name + "\n")}, @@ -57,10 +65,10 @@ func release(t *testing.T, tag, bin string) string { return srv.URL + "/latest" } -// TestUpdate checks that U installs the latest release and starts it in -// the same terminal, with the comments and the place of the user. +// TestUpdate checks that U replaces the binary with the latest release +// and starts it in the same terminal, keeping the user's comments. func TestUpdate(t *testing.T) { - // The two builds run at once. + // Build the old and new versions in parallel. var next string var nextErr error var wg sync.WaitGroup @@ -71,13 +79,13 @@ func TestUpdate(t *testing.T) { if err = cmp.Or(err, nextErr); err != nil { t.Fatal(err) } - latest := release(t, "v0.2.0", next) + latest := release(t, "v0.2.0", next, false) tm := start(t, newRepo(t), opts{bin: bin, env: []string{"REXDIFF_LATEST_URL=" + latest}}) tm.waitFor("rexdiff v0.2.0 is out: press U or click here to update") comment(tm, "hi") tm.keys("U") - // The new rexdiff is the latest, so it shows no update. It has the - // comment, but not the message of the old one. + // The new rexdiff is the latest version, so it offers no update. It + // shows the comment, but not the flash message from the old process. tm.waitUntil(func(s string) bool { return strings.Contains(s, "💬 hi") && !hasAny(s, "is out", "updating", "comment added") }, "show the comment in the new rexdiff") @@ -86,3 +94,80 @@ func TestUpdate(t *testing.T) { t.Errorf("rexdiff -version = %q (%v), want v0.2.0", got, err) } } + +// TestUpdateBadChecksum checks that when the archive of the latest +// release does not match its checksum, U shows the error and rexdiff keeps +// running. The tests of update check that the binary stays the same. +func TestUpdateBadChecksum(t *testing.T) { + bin, err := build(t.Context(), t.TempDir(), "0.1.0") + if err != nil { + t.Fatal(err) + } + latest := release(t, "v0.2.0", bin, true) + tm := start(t, newRepo(t), opts{bin: bin, env: []string{"REXDIFF_LATEST_URL=" + latest}}) + tm.waitFor("rexdiff v0.2.0 is out") + tm.keys("U") + tm.waitFor("update failed", "does not match its checksum") + tm.keys("2") + tm.waitFor("Files (6)") +} + +// headState writes a resume state file, like the one rexdiff saves before +// an update, and returns its path. The state shows long.txt with the +// cursor on row 3 and a sideways scroll of xoff, the file list on the left +// with a width of 50, and the fold at line 20 expanded. Line 20 has an +// unsent comment. hidden hides the file list, and focus gives it the +// focus. +func headState(t *testing.T, xoff int, hidden, focus bool) string { + t.Helper() + h := fnv.New64a() + _, _ = h.Write([]byte(numbered("line", 1, 40))) // the committed content of long.txt; Write on a hash.Hash never fails + state := fmt.Sprintf(`{"tab": 0, "scope": "uncommitted", "base": "", "from": "", "to": "", + "turn": null, "change_path": "long.txt", "file_path": "", + "changes": {"cur": 3, "top": 0, "xoff": %d}, "viewer": {"cur": 0, "top": 0, "xoff": 0}, + "pr": {"cur": 0, "top": 0, "xoff": 0}, "split": false, "wrap": false, "rendered": false, + "list_pos": "left", "list_hidden": %t, "focus_list": %t, "list_w": 50, + "folds": {"long.txt": {"old": %d, "ranges": [[20, 20]]}}, + "comments": {"last": 1, "items": [{"id": 1, "path": "long.txt", "side": 1, + "start": 20, "end": 20, "text": "kept", "in_diff": true, "rev": "", + "code": ["line 20"], "snippet": null}]}, + "last_sent": "", "beside": ""}`, xoff, hidden, focus, h.Sum64()) + path := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(path, []byte(state), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// TestResumeState checks that rexdiff restores every part of the resume +// state that it saves before an update: the file, cursor, sideways +// scroll, expanded fold, comment, and the position, width, visibility and +// focus of the file list. +func TestResumeState(t *testing.T) { + t.Run("shown", func(t *testing.T) { + path := headState(t, 0, false, true) + tm := start(t, newRepo(t), opts{width: 160, env: []string{"REXDIFF_RESUME=" + path}}) + s := tm.waitFor("long.txt +2 -2", "line 20", "💬 kept", "●1") + if strings.Contains(s, "unmodified lines") { + t.Errorf("the fold of the state is not expanded. The screen shows:\n%s", s) + } + if c := column(s, "Changes (2)"); c < 0 || c > 5 { + t.Errorf("the file list starts at column %d, want it at the left", c) + } + if ls := lines(s, "M a.go"); len(ls) == 0 || column(ls[0], "│") < 48 || column(ls[0], "│") > 52 { + t.Errorf("the file list rows %q, want a width of 50", ls) + } + tm.waitCursor("line 2") // row 3, after the header and the first change + // The file list has the focus, so k selects the previous file. + tm.keys("k") + tm.waitFor("a.go +2 -1") + }) + t.Run("scrolled and hidden", func(t *testing.T) { + path := headState(t, 3, true, false) + tm := start(t, newRepo(t), opts{width: 160, env: []string{"REXDIFF_RESUME=" + path}}) + s := tm.waitFor("long.txt +2 -2", "e 20", "💬 kept") + if hasAny(s, "Changes (2)", "line 20") { + t.Errorf("the file list shows, or the lines do not scroll sideways. The screen shows:\n%s", s) + } + }) +} diff --git a/tui/agentpick.go b/tui/agentpick.go index 06c3a02..c8f6101 100644 --- a/tui/agentpick.go +++ b/tui/agentpick.go @@ -1,6 +1,6 @@ package tui -// The agent picker picks the agent that gets the review. +// The agent picker lets the user choose which agent receives the review. import ( "fmt" @@ -9,13 +9,14 @@ import ( "github.com/rednafi/rexdiff/rex" ) -// openAgentPicker lets the user pick the agent to send to. The cursor -// starts on the agent of the last send, else the agent beside the pane. +// openAgentPicker opens a list of agents for the user to send the review +// to. The cursor starts on the agent that was used last or, failing that, +// on the agent in the pane beside rexdiff. func (m *model) openAgentPicker(agents []rex.Agent) { ov := newOverlay(overlayAgents, "send to which agent?") ov.agents = agents - var items []pickItem - armed := 0 + var items []overlayItem + crossing := 0 for i, a := range agents { detail := a.Status.App + " · " + string(a.Status.State) if a.Status.State == rex.StateBlocked { @@ -27,14 +28,14 @@ func (m *model) openAgentPicker(agents []rex.Agent) { tag := "" switch a.Block { case m.lastSent: - tag, armed = "last used", i + tag, crossing = "last used", i case m.beside: tag = "opened beside" if m.lastSent == "" { - armed = i + crossing = i } } - items = append(items, pickItem{ + items = append(items, overlayItem{ label: fmt.Sprintf("%d %s", i+1, a.Name), detail: detail, tag: tag, @@ -42,19 +43,19 @@ func (m *model) openAgentPicker(agents []rex.Agent) { }) } ov.setItems(items) - ov.cur = armed - m.open(ov) + ov.cur = crossing + m.openOverlay(ov) } -// agent returns the agent of item it of the agent picker. If it is nil, -// ok is false. -func (o *overlay) agent(it *pickItem) (a rex.Agent, ok bool) { +// agent returns the agent of the agent picker item it. ok is false if it +// is nil or matches no agent. +func (ov *overlay) agent(it *overlayItem) (a rex.Agent, ok bool) { if it == nil { return rex.Agent{}, false } - i := slices.IndexFunc(o.agents, func(a rex.Agent) bool { return a.Block == it.id }) + i := slices.IndexFunc(ov.agents, func(a rex.Agent) bool { return a.Block == it.id }) if i < 0 { return rex.Agent{}, false } - return o.agents[i], true + return ov.agents[i], true } diff --git a/tui/basepick.go b/tui/basepick.go index 158cbf0..7078f3e 100644 --- a/tui/basepick.go +++ b/tui/basepick.go @@ -1,49 +1,45 @@ package tui -// The base picker picks the base of the branch scope. +// The base picker lets the user choose the base branch for the branch scope. import ( - "context" - "fmt" "slices" "strings" "time" tea "charm.land/bubbletea/v2" + "github.com/rednafi/rexdiff/internal/xctx" "github.com/rednafi/rexdiff/vcs" ) -// branchTimeout is the longest time of the branch list. +// branchTimeout limits how long listing the branches can take. const branchTimeout = 5 * time.Second -// branchesMsg has the branch list of the base picker. +// branchesMsg carries the list of branches for the base picker. type branchesMsg struct { branches []string err error } -// openBasePicker lists the branches for the base picker. The list does -// nothing on the PR tab, or if the base comes from the flag. +// openBasePicker returns a command that lists the branches for the base +// picker. It returns nil on the PR tab, or when the base was set by a flag. func (m *model) openBasePicker() tea.Cmd { - if m.baseFlag || m.tab == tabPR { + if m.baseFromFlag || m.tab == tabPR { return nil } - ctx, cancel := context.WithCancelCause(m.ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, branchTimeout, - fmt.Errorf("branch list: %v timeout: %w", branchTimeout, context.DeadlineExceeded)) + ctx, cancel := xctx.WithTimeout(m.ctx, branchTimeout, "branch list") repo := m.repo return func() tea.Msg { - defer cancelTimeout() - defer cancel(fmt.Errorf("branch list: done: %w", context.Canceled)) + defer cancel() branches, err := repo.Branches(ctx) return branchesMsg{branches: branches, err: err} } } -// onBranches opens the base picker with the branch list of msg. The -// current base has a tag. As in herdr-reviewr, the base of an open pull -// request and the default branch come first, with a tag. +// onBranches opens the base picker with the branches in msg. The base of +// an open pull request comes first, then the default branch. These two and +// the current base are tagged. func (m *model) onBranches(msg branchesMsg) { if msg.err != nil { m.flash("branch list failed: " + msg.err.Error()) @@ -59,9 +55,9 @@ func (m *model) onBranches(msg branchesMsg) { } defaultBase := strings.TrimPrefix(m.defaultBase, "origin/") current := strings.TrimPrefix(m.base, "origin/") - // groups has the PR base, then the default branch, then the others. - // Each group keeps the newest first. - var groups [3][]pickItem + // groups holds the PR base, then the default branch, then the rest. + // Each group keeps the order of msg.branches, newest first. + var groups [3][]overlayItem for _, b := range msg.branches { var tags []string name, g := strings.TrimPrefix(b, "origin/"), 2 @@ -74,22 +70,24 @@ func (m *model) onBranches(msg branchesMsg) { if name == current { tags = append(tags, "current") } - groups[g] = append(groups[g], pickItem{label: b, id: b, tag: strings.Join(tags, " ")}) + groups[g] = append(groups[g], overlayItem{label: b, id: b, tag: strings.Join(tags, " ")}) } ov.setItems(slices.Concat(groups[:]...)) - m.open(ov) + m.openOverlay(ov) } -// baseRef returns the ref that the base picker picks, with the selected -// item it. A typed ref, for example a tag, wins over the first match. A -// match that the user moves to wins over the typed text. If the ref is -// not valid, ok is false. -func (m *model) baseRef(it *pickItem) (ref string, ok bool) { +// baseRef returns the ref chosen in the base picker, given the selected +// item it. A valid typed ref, such as a tag, is preferred over the +// first match, but a match that the user moves the cursor to is preferred +// over the typed text. It returns ok false if the ref does not resolve. +func (m *model) baseRef(it *overlayItem) (ref string, ok bool) { + ctx, done := m.gitContext("base resolve") + defer done() ref = strings.TrimSpace(m.overlay.input.Value()) - _, err := m.repo.Resolve(m.ctx, ref) + _, err := m.repo.ResolveRev(ctx, ref) if it != nil && (ref == "" || err != nil || m.overlay.cur > 0) { ref = it.id - _, err = m.repo.Resolve(m.ctx, ref) + _, err = m.repo.ResolveRev(ctx, ref) } return ref, ref != "" && err == nil } diff --git a/tui/comment.go b/tui/comment.go index 00e8aff..2af6db0 100644 --- a/tui/comment.go +++ b/tui/comment.go @@ -5,6 +5,7 @@ import ( "path/filepath" "slices" "strings" + "time" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -12,24 +13,24 @@ import ( "github.com/rednafi/rexdiff/diff" ) -// targetLine is the source line that a comment on a row attaches to. +// targetLine is the source line that a comment on a row is attached to. type targetLine struct { side diff.Side line int } -// target returns the line that a comment on row i of the current pane -// attaches to, or nil. If the row changes a line on side pref, target uses -// that side. Else target uses the new side. Thus a comment on an unchanged -// line is on the new side. -func (m *model) target(i int, pref diff.Side) *targetLine { - p := m.cur() +// targetAt returns the line that a comment on row i of the current pane +// would attach to, or nil if there is none. If the row changes a line on +// side prefer, targetAt uses that side. Otherwise it uses the new side when +// the row has one. +func (m *model) targetAt(i int, prefer diff.Side) *targetLine { + p := m.curPane() if p.path == "" || i < 0 || i >= len(p.rows) { return nil } r := p.rows[i] switch { - case pref == diff.SideOld && r.Old > 0 && r.Kind != diff.KindEqual: + case prefer == diff.SideOld && r.Old > 0 && r.Kind != diff.KindEqual: return &targetLine{diff.SideOld, r.Old} case r.New > 0: return &targetLine{diff.SideNew, r.New} @@ -39,41 +40,42 @@ func (m *model) target(i int, pref diff.Side) *targetLine { return nil } -// selectedChange returns the selected file of the Changes tab, or nil. -func (m *model) selectedChange() *changedFile { - if m.changeSel >= len(m.changeFiles) { +// curChangedFile returns the selected file in the Changes tab, or nil. +func (m *model) curChangedFile() *changedFile { + if m.changes.cur >= len(m.changes.files) { return nil } - return &m.changeFiles[m.changeSel] + return &m.changes.files[m.changes.cur] } -// contentFor returns the text of side s of the file at path, as the pane -// shows it. Thus a comment quotes the lines on the screen, and not a newer -// version of the file. A file that the pane does not show gives "". -func (m *model) contentFor(path string, s diff.Side) string { - if f := m.selectedChange(); m.tab == tabChanges && f != nil && f.Path == path { - if s == diff.SideOld { - return f.Old +// contentFor returns the text of the given side of the file at path, as +// the current tab shows it. It returns "" if the tab is not showing that +// file. +func (m *model) contentFor(path string, side diff.Side) string { + if f := m.curChangedFile(); m.tab == tabChanges && f != nil && f.Path == path { + if side == diff.SideOld { + return f.OldText } - return f.New + return f.NewText } - if m.tab == tabFiles && path == m.viewedPath { - return m.viewed + if m.tab == tabAllFiles && path == m.allFiles.shownPath { + return m.allFiles.shownText } return "" } -// diffRev returns the revision of the diff in the Changes tab, for -// [diff.Anchor]. That is the picked commits, or "" for the working tree. +// diffRev returns the revision range of the diff in the Changes tab, for +// use in a [diff.Anchor]. It returns "" when the diff is of the working +// tree. func (m *model) diffRev() string { if m.scope == scopeCommits { - return m.from + ".." + m.to + return m.oldest + ".." + m.newest } return "" } -// anchorOf returns the anchor of a comment made in tab t. As in -// herdr-reviewr, a comment on a file view is always on the working tree. +// anchorOf returns the anchor for a comment made in tab t. A comment made +// outside the Changes tab is always on the working tree. func (m *model) anchorOf(t tab) diff.Anchor { if t == tabChanges { return diff.Anchor{InDiff: true, Rev: m.diffRev()} @@ -81,117 +83,170 @@ func (m *model) anchorOf(t tab) diff.Anchor { return diff.Anchor{} } -// anchor returns the anchor of a comment made in the current tab. -func (m *model) anchor() diff.Anchor { return m.anchorOf(m.tab) } +// curAnchor returns the anchor for a comment made in the current tab. +func (m *model) curAnchor() diff.Anchor { return m.anchorOf(m.tab) } -// revCurrent reports whether the scope shows the diff that c is made on. -// Only then can the user edit c from the comment list. -func (m *model) revCurrent(c *diff.Comment) bool { +// commentOnCurRev reports whether the current scope shows the diff that c +// was made on. The user can edit c from the comment list only if it does. +func (m *model) commentOnCurRev(c *diff.Comment) bool { return !c.Anchor.InDiff || c.Anchor.Rev == m.diffRev() } -// stale reports whether the lines of c can have moved. A comment on a diff -// is stale if its file leaves the changes. A comment on a file view is -// stale if its file is gone. -func (m *model) stale(c *diff.Comment) bool { +// commentStale reports whether the lines of c may no longer hold the code +// that c was made on. Comments do not follow moved code, so a comment is +// stale when its lines now hold other code or are past the end of the +// file, as [diff.Comment.Moved] reports. A comment on a diff is also stale +// when its file is no longer among the changed files, and any other +// comment when its file no longer exists. The code of a comment on a +// different diff than the one shown is not checked. +func (m *model) commentStale(c *diff.Comment) bool { if c.Anchor.InDiff { - return !slices.ContainsFunc(m.changeFiles, func(f changedFile) bool { return f.Path == c.Path }) + i := slices.IndexFunc(m.changes.files, func(f changedFile) bool { return f.Path == c.Path }) + switch { + case i < 0: + return true + case c.Anchor.Rev != m.diffRev() || m.changes.files[i].Err != nil: + return false + case c.Side == diff.SideOld: + return c.Moved(m.changes.files[i].OldText) + } + return c.Moved(m.changes.files[i].NewText) } - _, err := os.Lstat(filepath.Join(m.repo.Root(), c.Path)) - return err != nil + // Check the file as it is now, not as the Files tab last showed it, + // so that the answer is right on every tab. + full := filepath.Join(m.repo.Root(), c.Path) + info, err := os.Stat(full) + if err != nil { + _, err := os.Lstat(full) // a broken symbolic link still exists + return err != nil + } + f, ok := m.commentFiles[c.Path] + if !ok || f.size != info.Size() || !f.modTime.Equal(info.ModTime()) { + text, err := m.repo.ReadFile(c.Path) + f = fileCopy{size: info.Size(), modTime: info.ModTime(), text: text, err: err} + if m.commentFiles == nil { + m.commentFiles = make(map[string]fileCopy) + } + m.commentFiles[c.Path] = f + } + // The code of a binary or unreadable file is not checked. + return f.err == nil && c.Moved(f.text) +} + +// fileCopy is the content of a file in the working tree, read when the +// file had the given size and modification time. err is the error from +// reading it. +type fileCopy struct { + size int64 + modTime time.Time + text string + err error } -// commentAtCursor returns the comment on the cursor line, or nil. -func (m *model) commentAtCursor() *diff.Comment { - p := m.cur() - t := m.target(p.cur, diff.SideNew) - if t == nil { +// jumpToNearLine moves the cursor to the line nearest line Start of c if +// the current pane shows the file of c but not that line, for example +// because the file is now shorter. +func (m *model) jumpToNearLine(c *diff.Comment) { + p := m.curPane() + hasSide := func(r diff.Row) bool { return r.Line(c.Side) > 0 } + if p.path != c.Path || p.rowOf(c.Side, c.Start) >= 0 || !slices.ContainsFunc(p.rows, hasSide) { + return + } + p.cur = p.lineStart(lineRow(p.rows, c.Side, c.Start)) +} + +// curComment returns the comment on the cursor line, or nil. +func (m *model) curComment() *diff.Comment { + p := m.curPane() + target := m.targetAt(p.cur, diff.SideNew) + if target == nil { return nil } - return m.commentOn(p.cur, t) + return m.commentOn(p.cur, target) } -// selection returns the first and the last row of the visual selection. -// With no selection, it returns the rows of the cursor line. -func (p *pane) selection() (lo, hi int) { - if p.anchor < 0 { - return p.cur, p.end(p.cur) +// selRange returns the first and last rows of the visual selection. With +// no selection, it returns the rows of the cursor line. +func (p *pane) selRange() (lo, hi int) { + if p.selStart < 0 { + return p.cur, p.lineEnd(p.cur) } - return p.span(p.anchor, p.cur) + return p.span(p.selStart, p.cur) } -// selectedRows returns the source rows of the selection. In rendered -// markdown, they are all the rows of the selected blocks, with the +// selRows returns the source rows of the selection. In rendered Markdown, +// these are all the source rows of the selected blocks, including any // deleted lines between them. -func (p *pane) selectedRows() []diff.Row { - lo, hi := p.selection() - if p.src == nil { +func (p *pane) selRows() []diff.Row { + lo, hi := p.selRange() + if p.srcRows == nil { return p.rows[lo : hi+1] } - first, last := p.rows[p.head(lo)].New, p.rows[hi].New - i := slices.IndexFunc(p.src, func(r diff.Row) bool { return r.New == first }) - j := slices.IndexFunc(p.src, func(r diff.Row) bool { return r.New == last }) - return p.src[i : j+1] + first, last := p.rows[p.lineStart(lo)].New, p.rows[hi].New + i := slices.IndexFunc(p.srcRows, func(r diff.Row) bool { return r.New == first }) + j := slices.IndexFunc(p.srcRows, func(r diff.Row) bool { return r.New == last }) + return p.srcRows[i : j+1] } -// span returns the first and the last row of the lines between rows a and -// b, in either order. +// span returns the first and last rows of the lines from row a to row b. +// a and b can be in either order. func (p *pane) span(a, b int) (lo, hi int) { - return min(a, b), p.end(max(a, b)) + return min(a, b), p.lineEnd(max(a, b)) } -// startComment opens the comment box for a new comment on the selection, -// or on the cursor line. A line can have more than one comment. -func (m *model) startComment(pref diff.Side) tea.Cmd { - p := m.cur() - t := m.target(p.cur, pref) - if t == nil { +// openComment opens the comment box for a new comment on the selection or, +// with no selection, on the cursor line. A line can have more than one +// comment. +func (m *model) openComment(prefer diff.Side) tea.Cmd { + p := m.curPane() + target := m.targetAt(p.cur, prefer) + if target == nil { return nil } c := diff.NewComment( p.path, - t.side, - p.selectedRows(), + target.side, + p.selRows(), m.contentFor(p.path, diff.SideOld), m.contentFor(p.path, diff.SideNew), ) - c.Anchor = m.anchor() - m.draft = &draft{Comment: c, listRow: -1} - m.sizeEditor() - m.editor.SetValue("") + c.Anchor = m.curAnchor() + m.draft = &draft{Comment: c, itemRow: -1} + m.sizeCommentInput() + m.commentInput.SetValue("") m.mode = modeComment - // Put the cursor on the last line of the range, so that the box opens - // below the range. - cursorToLine(p, c.Side, c.End) - return m.editor.Focus() + // The box opens below the cursor, so move the cursor to the last + // commented line. + jumpToLine(p, c.Side, c.End) + return m.commentInput.Focus() } -// editComment opens the comment box with the text of c. After the edit, -// the comment list opens at listRow, if listRow is 0 or more. -func (m *model) editComment(c *diff.Comment, listRow int) tea.Cmd { - m.draft = &draft{Comment: *c, editID: c.ID, listRow: listRow} - m.sizeEditor() - m.editor.SetValue(c.Text) +// editComment opens the comment box with the text of c. If itemRow is 0 +// or more, the comment list opens again at that row when the box closes. +func (m *model) editComment(c *diff.Comment, itemRow int) tea.Cmd { + m.draft = &draft{Comment: *c, editID: c.ID, itemRow: itemRow} + m.sizeCommentInput() + m.commentInput.SetValue(c.Text) m.mode = modeComment - cursorToLine(m.cur(), c.Side, c.End) - return m.editor.Focus() + jumpToLine(m.curPane(), c.Side, c.End) + return m.commentInput.Focus() } -// showsLine reports whether the current pane shows the last line of c, -// with the anchor of c. -func (m *model) showsLine(c *diff.Comment) bool { - p := m.cur() - return m.anchor() == c.Anchor && p.path == c.Path && p.rowOf(c.Side, c.End) >= 0 +// commentShown reports whether the current pane shows the last line of c +// in the same diff or file view that c was made on. +func (m *model) commentShown(c *diff.Comment) bool { + p := m.curPane() + return m.curAnchor() == c.Anchor && p.path == c.Path && p.rowOf(c.Side, c.End) >= 0 } // updateComment handles a key while the comment box is open. func (m *model) updateComment(msg tea.KeyPressMsg) tea.Cmd { switch { - case key.Matches(msg, m.keys.Save): - // As in herdr-reviewr, blank text cancels. An edit keeps the - // comment, its ID, and its place in the list. + case key.Matches(msg, m.keys.SaveComment): + // Blank text cancels. Editing keeps the comment's ID and its place + // in the list. d := m.draft - text := strings.TrimSpace(m.editor.Value()) + text := strings.TrimSpace(m.commentInput.Value()) edited := m.comments.Find(d.editID) switch { case text == "": @@ -211,34 +266,32 @@ func (m *model) updateComment(msg tea.KeyPressMsg) tea.Cmd { return nil } var cmd tea.Cmd - m.editor, cmd = m.editor.Update(msg) - m.ensureVisible() + m.commentInput, cmd = m.commentInput.Update(msg) + m.scrollToCursor() return cmd } -// closeComment closes the comment box and clears the selection. Then it -// does the reload that waits for the box. After an edit from the comment -// list, it opens the list again. +// closeComment closes the comment box, clears the selection and runs any +// reload that was put off while the box was open. If the edit started from +// the comment list, it opens the list again. func (m *model) closeComment() { - row := m.draft.listRow + row := m.draft.itemRow m.mode, m.draft = modeNormal, nil - m.cur().anchor = -1 - m.editor.Blur() - m.reloadDue() + m.curPane().selStart = -1 + m.commentInput.Blur() + m.reloadIfDue() if row >= 0 && m.comments.Len() > 0 { - m.openCommentList() + m.openComments() m.overlay.cur = min(row, len(m.overlay.shown)-1) } } -// nearNewLine returns the new line of row i. A row with no new line, such -// as a deleted line, gives the next new line below it. If there is none, it -// gives the last new line above it. If there is none, it gives 1. -func nearNewLine(rows []diff.Row, i int) int { - for _, r := range rows[i:] { - if r.New > 0 { - return r.New - } +// nearestNewLine returns the new-side line number of row i. For a row with +// no new line, such as a deleted line, it returns the next new line below, +// then the nearest one above, and 1 if there are none. +func nearestNewLine(rows []diff.Row, i int) int { + if j := slices.IndexFunc(rows[i:], func(r diff.Row) bool { return r.New > 0 }); j >= 0 { + return rows[i+j].New } for _, r := range slices.Backward(rows[:i]) { if r.New > 0 { @@ -248,19 +301,19 @@ func nearNewLine(rows []diff.Row, i int) int { return 1 } -// editOrOpen edits the comment at the cursor. If there is no comment, it -// opens the file in an editor at the cursor line. As in herdr-reviewr, it -// does nothing while a selection is active, so the selection stays. -func (m *model) editOrOpen() tea.Cmd { - if m.cur().anchor >= 0 { +// onEdit edits the comment on the cursor line or, if there is none, opens +// the file in an editor at that line. It does nothing while a selection is +// active. +func (m *model) onEdit() tea.Cmd { + if m.curPane().selStart >= 0 { return nil } - if c := m.commentAtCursor(); c != nil { + if c := m.curComment(); c != nil { return m.editComment(c, -1) } - p := m.cur() + p := m.curPane() if p.path == "" { return nil } - return m.openEditor(p.path, nearNewLine(p.rows, p.cur)) + return m.launchEditor(p.path, nearestNewLine(p.rows, p.cur)) } diff --git a/tui/commentlist.go b/tui/commentlist.go deleted file mode 100644 index 0216f0a..0000000 --- a/tui/commentlist.go +++ /dev/null @@ -1,107 +0,0 @@ -package tui - -// The comment list shows all the comments. The user can jump to a -// comment, edit it, delete it, or send or copy all the comments. - -import ( - "fmt" - "strings" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" -) - -// openCommentList lists the comments, to jump to one, edit one, delete -// one, or send or copy them all. -func (m *model) openCommentList() { - if m.comments.Len() == 0 { - m.flash("no comments yet") - return - } - ov := newOverlay(overlayComments, "") - m.fillCommentList(ov) - m.open(ov) -} - -// fillCommentList sets the comments as the items of ov, and the count in -// the title, as in herdr-reviewr. -func (m *model) fillCommentList(ov *overlay) { - ov.title = fmt.Sprintf("Comments (%d)", m.comments.Len()) - var items []pickItem - for _, c := range m.comments.All() { - tag := "" - if m.stale(c) { - tag = "stale" - } - text, _, _ := strings.Cut(c.Text, "\n") - items = append(items, pickItem{ - label: c.Location(), - detail: text, - tag: tag, - comment: c.ID, - }) - } - ov.setItems(items) -} - -// updateCommentList handles a key in the comment list. The keys are those -// of herdr-reviewr. Enter also jumps to the comment. -func (m *model) updateCommentList(msg tea.KeyPressMsg) tea.Cmd { - ov, k := m.overlay, m.keys - it := ov.selected() - switch { - case key.Matches(msg, k.Comments, k.Cancel): - m.closeOverlay() - case key.Matches(msg, overlayKeys.Choose): - return m.chooseOverlay() - case key.Matches(msg, k.Down): - ov.move(1) - case key.Matches(msg, k.Up): - ov.move(-1) - case key.Matches(msg, k.Send): - return m.startSend() - case key.Matches(msg, k.Copy): - m.copyComments() - case key.Matches(msg, k.Edit) && it != nil: - // As in herdr-reviewr, a comment on another diff takes no edit. - c := m.comments.Find(it.comment) - if c == nil || !m.revCurrent(c) { - return nil - } - row := ov.cur - m.closeOverlay() - m.jumpToComment(c) - m.focusList = false - if !m.showsLine(c) { - m.flash("the line of the comment does not show") - return nil - } - m.ensureVisible() - return m.editComment(c, row) - case key.Matches(msg, k.Delete) && it != nil: - m.comments.Remove(it.comment) - m.flash("comment deleted") - if m.comments.Len() == 0 { - m.closeOverlay() - return nil - } - m.fillCommentList(ov) - } - return nil -} - -// listHint returns the keys of the comment list, as the footer of -// herdr-reviewr shows them. The edit key shows only if the selected -// comment takes an edit. -func (m *model) listHint() string { - k := m.keys - edit := "" - if it := m.overlay.selected(); it != nil { - if c := m.comments.Find(it.comment); c != nil && m.revCurrent(c) { - edit = fmt.Sprintf(" · %s edit", firstKey(k.Edit)) - } - } - return fmt.Sprintf("%s send %d · esc close · %s copy%s · %s delete · enter jump", - firstKey(k.Send), m.comments.Len(), firstKey(k.Copy), edit, - firstKey(k.Delete)) -} diff --git a/tui/comments.go b/tui/comments.go new file mode 100644 index 0000000..6c7d58b --- /dev/null +++ b/tui/comments.go @@ -0,0 +1,115 @@ +package tui + +// The comment list shows every comment. From it the user can jump to a +// comment, edit or delete it, or send or copy all the comments. + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +// openComments opens the comment list, or shows a message if there are no +// comments. +func (m *model) openComments() { + if m.comments.Len() == 0 { + m.flash("no comments yet") + return + } + ov := newOverlay(overlayComments, "") + m.setCommentItems(ov) + m.openOverlay(ov) +} + +// setCommentItems fills ov with one item per comment and puts the number +// of comments in its title. +func (m *model) setCommentItems(ov *overlay) { + ov.title = fmt.Sprintf("Comments (%d)", m.comments.Len()) + var items []overlayItem + for _, c := range m.comments.All() { + tag := "" + if m.commentStale(c) { + tag = "stale" + } + text, _, _ := strings.Cut(c.Text, "\n") + items = append(items, overlayItem{ + label: c.Location(), + detail: text, + tag: tag, + comment: c.ID, + }) + } + ov.setItems(items) +} + +// updateComments handles a key press in the comment list. Enter jumps to +// the selected comment. The page and half-page keys move the cursor as in +// the commit picker. +func (m *model) updateComments(msg tea.KeyPressMsg) tea.Cmd { + ov, k := m.overlay, m.keys + it := ov.curItem() + switch { + case key.Matches(msg, k.Comments, k.Cancel): + m.closeOverlay() + case key.Matches(msg, overlayKeys.Choose): + return m.choose() + case key.Matches(msg, k.Down): + ov.move(1) + case key.Matches(msg, k.Up): + ov.move(-1) + case key.Matches(msg, k.PageDown): + ov.move(itemsPage) + case key.Matches(msg, k.PageUp): + ov.move(-itemsPage) + case key.Matches(msg, k.HalfDown): + ov.move(itemsHalf) + case key.Matches(msg, k.HalfUp): + ov.move(-itemsHalf) + case key.Matches(msg, k.Send): + return m.startSend() + case key.Matches(msg, k.Copy): + m.copyComments() + case key.Matches(msg, k.Edit) && it != nil: + // A comment made on a different diff cannot be edited here. + c := m.comments.Find(it.comment) + if c == nil || !m.commentOnCurRev(c) { + return nil + } + row := ov.cur + m.closeOverlay() + m.jumpToComment(c) + m.navigatorFocused = false + if !m.commentShown(c) { + m.flash("the line of the comment does not show") + return nil + } + m.scrollToCursor() + return m.editComment(c, row) + case key.Matches(msg, k.Delete) && it != nil: + m.comments.Delete(it.comment) + m.flash("comment deleted") + if m.comments.Len() == 0 { + m.closeOverlay() + return nil + } + m.setCommentItems(ov) + } + return nil +} + +// commentsHint returns the key hints for the comment list. The edit key +// is listed only if the user can edit the selected comment. +func (m *model) commentsHint() string { + k := m.keys + edit := "" + if it := m.overlay.curItem(); it != nil { + if c := m.comments.Find(it.comment); c != nil && m.commentOnCurRev(c) { + edit = fmt.Sprintf(" · %s edit", firstKey(k.Edit)) + } + } + return fmt.Sprintf("%s send %d · esc close · %s copy%s · %s delete · enter jump", + firstKey(k.Send), m.comments.Len(), firstKey(k.Copy), edit, + firstKey(k.Delete)) +} diff --git a/tui/commitpick.go b/tui/commitpick.go index 697c4e4..8e67725 100644 --- a/tui/commitpick.go +++ b/tui/commitpick.go @@ -1,109 +1,149 @@ package tui -// The commit picker picks a commit or a range of commits for the commits -// scope. +// The commit picker lets the user choose a commit, or a range of commits, +// for the commits scope. import ( "fmt" "slices" + "time" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" + "github.com/rednafi/rexdiff/internal/xctx" "github.com/rednafi/rexdiff/vcs" ) -// openCommitPicker lets the user pick a commit or a range of commits for -// the commits scope, as in herdr-reviewr. It lists the first-parent -// commits after the fork point of the base, the newest first. On the base -// itself, or with no fork point, it lists the last commits. The current -// pick has the cursor and the anchor. -func (m *model) openCommitPicker() { +// commitTimeout limits how long listing the commits can take. +const commitTimeout = 10 * time.Second + +// commitsMsg carries the commits for the commit picker ov. since is the +// merge base that they come after, or "" for the most recent commits. +type commitsMsg struct { + ov *overlay + commits []vcs.Commit + since string + err error +} + +// openCommitPicker opens the commit picker and returns a command that +// lists its commits: the first-parent commits since the merge base with +// the base branch, newest first, up to vcs.MaxCommits. If HEAD is the +// merge base, or there is none, it lists the most recent commits instead. +// Listing can take a while in a large repository, so the picker shows that +// it is loading until the commits arrive, and closing it stops the +// listing. It does nothing on the PR tab. +func (m *model) openCommitPicker() tea.Cmd { if m.tab == tabPR { - return + return nil } - since, err := m.repo.MergeBaseCommit(m.ctx, m.base) - if head, herr := m.repo.Resolve(m.ctx, "HEAD"); err != nil || herr != nil || since == head { - since = "" + ctx, cancel := xctx.WithTimeout(m.ctx, commitTimeout, "commit list") + ov := newOverlay(overlayCommits, "commits") + ov.loading, ov.stop = true, cancel + m.openOverlay(ov) + repo, base := m.repo, m.base + return func() tea.Msg { + defer cancel() + since, err := repo.MergeBaseCommit(ctx, base) + if head, headErr := repo.ResolveRev(ctx, "HEAD"); err != nil || headErr != nil || since == head { + since = "" + } + commits, err := repo.Commits(ctx, since) + return commitsMsg{ov: ov, commits: commits, since: since, err: err} } - commits, err := m.repo.Commits(m.ctx, since) - if err != nil { - m.flash("commit list failed: " + err.Error()) +} + +// onCommits fills the commit picker with the commits in msg. The cursor +// and range anchor start on the current choice. If the picker that asked +// for the commits has closed, onCommits does nothing, so that a slow list +// does not open the picker again after the user has moved on. +func (m *model) onCommits(msg commitsMsg) { + ov := msg.ov + if m.overlay != ov || m.tab == tabPR { return } + ov.loading, ov.stop = false, nil + if msg.err != nil { + m.closeOverlay() + m.flash("commit list failed: " + msg.err.Error()) + return + } + commits := msg.commits title := fmt.Sprintf("commits · last %d", vcs.LastCommits) - if since != "" { + switch { + case msg.since != "" && len(commits) >= vcs.MaxCommits: + title = fmt.Sprintf("commits · newest %d over %s", len(commits), m.base) + case msg.since != "": title = fmt.Sprintf("commits · %d over %s", len(commits), m.base) } - ov := newOverlay(overlayCommits, title) - var items []pickItem + ov.title = title + var items []overlayItem for _, c := range commits { - items = append(items, pickItem{ + items = append(items, overlayItem{ label: c.Short + " " + c.Subject, detail: c.Author + ", " + c.When, id: c.SHA, }) } ov.setItems(items) - isSHA := func(sha string) func(pickItem) bool { - return func(it pickItem) bool { return it.id == sha } + isSHA := func(sha string) func(overlayItem) bool { + return func(it overlayItem) bool { return it.id == sha } } - newest := slices.IndexFunc(items, isSHA(m.to)) - oldest := slices.IndexFunc(items, isSHA(m.from)) - if m.to != "" && newest >= 0 && oldest >= 0 { + newest := slices.IndexFunc(items, isSHA(m.newest)) + oldest := slices.IndexFunc(items, isSHA(m.oldest)) + if m.newest != "" && newest >= 0 && oldest >= 0 { ov.cur = newest if oldest > newest { - ov.mark = oldest + ov.rangeStart = oldest } } - m.open(ov) } -// updateCommitPicker handles a key in the commit picker, as herdr-reviewr -// does. The key of Select sets or clears the anchor of a range. Esc clears -// the anchor, else it closes the picker. +// updateCommitPicker handles a key press in the commit picker. The Select +// key sets or clears the start of a range. Esc clears the range start if +// there is one, and otherwise closes the picker. func (m *model) updateCommitPicker(msg tea.KeyPressMsg) tea.Cmd { ov, k := m.overlay, m.keys switch { - case key.Matches(msg, overlayKeys.Close) && ov.mark >= 0: - ov.mark = -1 + case key.Matches(msg, overlayKeys.Close) && ov.rangeStart >= 0: + ov.rangeStart = -1 case key.Matches(msg, overlayKeys.Close): m.closeOverlay() case key.Matches(msg, overlayKeys.Choose): - return m.chooseOverlay() + return m.choose() case key.Matches(msg, k.Down): ov.move(1) case key.Matches(msg, k.Up): ov.move(-1) case key.Matches(msg, k.PageDown): - ov.move(pickerPage) + ov.move(itemsPage) case key.Matches(msg, k.PageUp): - ov.move(-pickerPage) + ov.move(-itemsPage) case key.Matches(msg, k.HalfDown): - ov.move(pickerHalf) + ov.move(itemsHalf) case key.Matches(msg, k.HalfUp): - ov.move(-pickerHalf) + ov.move(-itemsHalf) case key.Matches(msg, k.Select) && ov.cur < len(ov.shown): - if ov.mark == ov.shown[ov.cur] { - ov.mark = -1 + if ov.rangeStart == ov.shown[ov.cur] { + ov.rangeStart = -1 } else { - ov.mark = ov.shown[ov.cur] + ov.rangeStart = ov.shown[ov.cur] } } return nil } -// commitHint returns the keys of the commit picker, as the footer of -// herdr-reviewr shows them. +// commitHint returns the key hints for the commit picker. func (m *model) commitHint() string { ov, k := m.overlay, m.keys - if len(ov.shown) == 0 { + if ov.loading || len(ov.shown) == 0 { return "esc cancel" } open, esc := "open", "cancel" - if ov.mark >= 0 { + if ov.rangeStart >= 0 { esc = "clear" - if n := max(ov.mark, ov.cur) - min(ov.mark, ov.cur) + 1; n > 1 { + if n := max(ov.rangeStart, ov.cur) - min(ov.rangeStart, ov.cur) + 1; n > 1 { open = fmt.Sprintf("open %d", n) } } @@ -111,13 +151,14 @@ func (m *model) commitHint() string { open, esc, firstKey(k.Down), firstKey(k.Up), firstKey(k.Select)) } -// commitRange returns the oldest and the newest commit that the commit -// picker picks with item it. That is one commit, or the range from the -// mark to it. The list shows the newest commit first. -func (o *overlay) commitRange(it *pickItem) (oldest, newest string) { - if o.mark < 0 { +// commitRange returns the oldest and newest commits chosen in the commit +// picker, given the selected item it. Without a range start, both are the +// commit of it. Otherwise they are the ends of the range from the start +// to the cursor. +func (ov *overlay) commitRange(it *overlayItem) (oldest, newest string) { + if ov.rangeStart < 0 { return it.id, it.id } - a, b := o.mark, o.shown[o.cur] - return o.items[max(a, b)].id, o.items[min(a, b)].id + a, b := ov.rangeStart, ov.shown[ov.cur] + return ov.items[max(a, b)].id, ov.items[min(a, b)].id } diff --git a/tui/config.go b/tui/config.go index 35379f6..efcd8c5 100644 --- a/tui/config.go +++ b/tui/config.go @@ -12,26 +12,28 @@ import ( "strings" ) -// Layout is the layout of the diff. +// Layout is the way a diff is laid out on screen. type Layout string -// The layouts. +// The supported layouts. const ( - LayoutUnified Layout = "unified" // the old and the new lines in one column - LayoutSplit Layout = "split" // the old lines beside the new lines + LayoutUnified Layout = "unified" // old and new lines in one column + LayoutSplit Layout = "split" // old lines on the left, new lines on the right ) -// Scope tells what the Changes tab compares with the working tree. +// Scope selects what the Changes tab compares the working tree with. type Scope string -// The scopes, and what they compare with the working tree. +// The scopes that can be set in the config. Each comment says what the +// working tree is compared with. const ( ScopeUncommitted Scope = "uncommitted" // HEAD ScopeBranch Scope = "branch" // the base branch ScopeLastTurn Scope = "last-turn" // the start of the last agent turn ) -// markdownView tells how the program shows a Markdown file. +// markdownView selects whether a Markdown file is shown as source or +// rendered. type markdownView string const ( @@ -39,7 +41,7 @@ const ( markdownViewRendered markdownView = "rendered" ) -// position is the place of the file list around the main pane. +// position is where the file list sits relative to the main pane. type position string const ( @@ -49,8 +51,7 @@ const ( positionTop position = "top" ) -// next returns the position after p, clockwise around the main pane, as -// the key p of herdr-reviewr moves it. +// next returns the position after p, clockwise around the main pane. func (p position) next() position { switch p { case positionRight: @@ -63,11 +64,12 @@ func (p position) next() position { return positionRight } -// stacked reports whether the file list is above or below the main pane. -// Then the list splits the rows of the body, not the columns. +// stacked reports whether the file list is above or below the main pane, +// in which case it takes rows from the body rather than columns. func (p position) stacked() bool { return p == positionTop || p == positionBottom } -// The values that the settings accept, in the order of the error messages. +// The values that each setting accepts, in the order the error messages +// list them. var ( layouts = []Layout{LayoutUnified, LayoutSplit} configScopes = []Scope{ScopeUncommitted, ScopeBranch, ScopeLastTurn} @@ -75,32 +77,31 @@ var ( positions = []position{positionRight, positionBottom, positionLeft, positionTop} ) -// config holds the settings of rexdiff. The settings are in rexdiff.lua, -// beside the configuration file (init.lua) of Rex. The toggle action gives -// them to rexdiff as JSON in --config. +// config holds the rexdiff settings. The toggle action reads them from +// rexdiff.lua, in the same directory as the Rex init.lua, and passes them +// as JSON in --config. type config struct { Layout Layout `json:"layout"` DefaultScope Scope `json:"default_scope"` - // ReviewPrefix is the text above each review that rexdiff sends or - // copies. + // ReviewPrefix is the text that starts each sent or copied review. ReviewPrefix string `json:"review_prefix"` - // Editor is a command, for example "code -g {file}:{line}". + // Editor is the command that opens a file, such as + // "code -g {file}:{line}". Editor string `json:"editor"` - // URLOpener is a command, for example "open {url}". + // URLOpener is the command that opens a URL, such as "open {url}". URLOpener string `json:"url_opener"` MarkdownView markdownView `json:"markdown_view"` - // FileList is the place of the list of files: right, bottom, left, or - // top. + // FileList is where the file list goes: right, bottom, left or top. FileList position `json:"file_list"` - // FileListWidth is the width of the file list at a side, in columns. - // It is minListWidth or more, or 0 if the text does not set it. + // FileListWidth is the width in columns of the file list when it is at + // the left or right. It is at least minNavigatorWidth, or 0 if not set. FileListWidth int `json:"file_list_width"` - // DiffSigns marks a changed line with + or -, in place of a bar. + // DiffSigns marks changed lines with + or - instead of a coloured bar. DiffSigns bool `json:"diff_signs"` - // FocusAgent is true if the focus moves to the agent after a send. - // Nil means true. + // FocusAgent reports whether focus moves to the agent after sending a + // review. Nil means true. FocusAgent *bool `json:"focus_agent_after_send"` - // GitHubHost, GitLabHost, and AzureHost are bare hostnames of + // GitHubHost, GitLabHost and AzureHost are the bare hostnames of // self-hosted forges. GitHubHost string `json:"github_host"` GitLabHost string `json:"gitlab_host"` @@ -108,10 +109,10 @@ type config struct { Keybindings keybindings `json:"keybindings"` } -// keybindings sets the keys of each action. The JSON keys are the names of -// the actions. A nil list keeps the default keys. +// keybindings holds the keys bound to each action. The JSON names are the +// action names. A nil list keeps the default keys. type keybindings struct { - // Move. + // Movement. Down []string `json:"down"` Up []string `json:"up"` PageDown []string `json:"page-down"` @@ -165,15 +166,14 @@ type keybindings struct { Quit []string `json:"quit"` QuitDiscard []string `json:"quit-discard"` - // The old names of navigator-grow, navigator-shrink, and rendered. - // herdr-reviewr also takes them. + // Former names of navigator-grow, navigator-shrink and rendered. ListWider []string `json:"list-wider"` ListNarrower []string `json:"list-narrower"` Preview []string `json:"preview"` } -// useOldNames gives the keys of each old name to its action. An action -// and its old name cannot both have keys. +// useOldNames moves keys set under a former action name to the current +// name. It returns an error if both names have keys. func (kb *keybindings) useOldNames() error { names := []struct { name, old string @@ -196,10 +196,11 @@ func (kb *keybindings) useOldNames() error { return nil } -// minListWidth is the smallest width of the file list, in columns. -const minListWidth = 16 +// minNavigatorWidth is the smallest allowed width of the file list, in +// columns. +const minNavigatorWidth = 16 -// defaultConfig returns the value of each setting that --config does not +// defaultConfig returns the settings used for anything --config does not // set. func defaultConfig() config { return config{ @@ -210,16 +211,16 @@ func defaultConfig() config { } } -// parseConfig reads the JSON text over the default settings. An empty -// text gives the default settings. An unknown key, a key in the wrong -// case, or a bad value gives an error. +// parseConfig decodes the JSON text on top of the default settings. An +// empty text returns the defaults. It returns an error for an unknown key, +// a key in the wrong case, or an invalid value. func parseConfig(text string) (config, error) { c := defaultConfig() if text == "" { return c, nil } - // set tells which keys the text has. A blank text is an error only if - // the user sets it. + // set records which keys the text sets, so that a blank value is an + // error only when the user sets it. var set map[string]jsontext.Value if err := json.Unmarshal([]byte(text), &set); err != nil { return config{}, configError(err) @@ -230,14 +231,14 @@ func parseConfig(text string) (config, error) { if err := c.Keybindings.useOldNames(); err != nil { return config{}, fmt.Errorf("config: %w", err) } - if err := c.check(set); err != nil { + if err := c.validate(set); err != nil { return config{}, fmt.Errorf("config: %w", err) } return c, nil } -// configError changes an error of the JSON decoder into an error that -// uses the names of the settings, for example "keybindings.send". +// configError converts a JSON decoding error into one that names the +// setting, such as "keybindings.send". func configError(err error) error { se, ok := errors.AsType[*json.SemanticError](err) if !ok { @@ -265,7 +266,7 @@ func configError(err error) error { key, want, jsonKinds[se.JSONKind]) } -// jsonKinds names the kinds of JSON values in the words of Lua. +// jsonKinds describes each kind of JSON value in Lua terms. var jsonKinds = map[jsontext.Kind]string{ '"': "text", '0': "a number", @@ -276,43 +277,43 @@ var jsonKinds = map[jsontext.Kind]string{ 'n': "nil", } -// check returns an error for the first value that rexdiff does not accept. -// set has the keys that the text sets. -func (c config) check(set map[string]jsontext.Value) error { - if err := c.checkChoices(set); err != nil { +// validate returns an error for the first invalid setting. set holds the +// keys that the config text sets. +func (c config) validate(set map[string]jsontext.Value) error { + if err := c.validateChoices(set); err != nil { return err } - if err := c.checkTexts(set); err != nil { + if err := c.validateTexts(set); err != nil { return err } - if err := c.checkHosts(set); err != nil { + if err := c.validateHosts(set); err != nil { return err } - return c.checkKeybindings() + return c.validateKeybindings() } -// checkChoices checks the values that have fixed choices or a range. They -// are the layout, the scope, the markdown view, and the file list. set -// has the keys that the text sets. -func (c config) checkChoices(set map[string]jsontext.Value) error { +// validateChoices checks the layout, default scope, Markdown view and file +// list settings. set holds the keys that the config text sets. +func (c config) validateChoices(set map[string]jsontext.Value) error { if err := cmp.Or( - choice("layout", c.Layout, layouts), - choice("default_scope", c.DefaultScope, configScopes), - choice("markdown_view", c.MarkdownView, markdownViews), - choice("file_list", c.FileList, positions), + validateChoice("layout", c.Layout, layouts), + validateChoice("default_scope", c.DefaultScope, configScopes), + validateChoice("markdown_view", c.MarkdownView, markdownViews), + validateChoice("file_list", c.FileList, positions), ); err != nil { return err } - if set["file_list_width"] != nil && c.FileListWidth < minListWidth { - want := strconv.Itoa(minListWidth) + " or more" - return invalid("file_list_width", want, strconv.Itoa(c.FileListWidth)) + if set["file_list_width"] != nil && c.FileListWidth < minNavigatorWidth { + want := strconv.Itoa(minNavigatorWidth) + " or more" + return invalidError("file_list_width", want, strconv.Itoa(c.FileListWidth)) } return nil } -// checkTexts checks the commands and the review prefix. A command can use -// only its placeholders. A text that the user sets must not be blank. -func (c config) checkTexts(set map[string]jsontext.Value) error { +// validateTexts checks the commands and the review prefix. A command may +// use only its own placeholders, and a value that the user sets must not +// be blank. +func (c config) validateTexts(set map[string]jsontext.Value) error { commands := []struct { key, value, want string known []string @@ -332,24 +333,24 @@ func (c config) checkTexts(set map[string]jsontext.Value) error { } for _, k := range commands { blank := set[k.key] != nil && strings.TrimSpace(k.value) == "" - if blank || !onlyPlaceholders(k.value, k.known...) { - return invalid(k.key, k.want, k.value) + if blank || !hasOnlyPlaceholders(k.value, k.known...) { + return invalidError(k.key, k.want, k.value) } } - // As in herdr-reviewr, the first word of the URL opener is a program, - // and not the URL. - if w := splitWords(c.URLOpener); c.URLOpener != "" && - (len(w) == 0 || w[0] == "" || strings.Contains(w[0], "{url}")) { - return invalid("url_opener", "a command that names a program first", c.URLOpener) + // The first word of the URL opener must name a program, not the URL. + w := splitWords(c.URLOpener) + noProgram := len(w) == 0 || w[0] == "" || strings.Contains(w[0], "{url}") + if c.URLOpener != "" && noProgram { + return invalidError("url_opener", "a command that names a program first", c.URLOpener) } if set["review_prefix"] != nil && strings.TrimSpace(c.ReviewPrefix) == "" { - return invalid("review_prefix", "text", c.ReviewPrefix) + return invalidError("review_prefix", "text", c.ReviewPrefix) } return nil } -// checkHosts checks the hostnames of the self-hosted forges. -func (c config) checkHosts(set map[string]jsontext.Value) error { +// validateHosts checks the hostnames of the self-hosted forges. +func (c config) validateHosts(set map[string]jsontext.Value) error { hosts := []struct{ key, value string }{ {"github_host", c.GitHubHost}, {"gitlab_host", c.GitLabHost}, @@ -357,17 +358,16 @@ func (c config) checkHosts(set map[string]jsontext.Value) error { } for _, k := range hosts { if set[k.key] != nil && !isHostname(k.value) { - return invalid(k.key, "a bare hostname", k.value) + return invalidError(k.key, "a bare hostname", k.value) } } return nil } -// checkKeybindings checks the keys of the actions, as herdr-reviewr does. -// The decoder rejects an unknown action. A list that is set must have -// keys, and each key must have a valid name. Then no key can have two -// actions, after the setting changes the default keys. -func (c config) checkKeybindings() error { +// validateKeybindings checks the key bindings. A list that is set must not +// be empty, and each key must have a valid name. Once the settings are +// applied to the default keys, no key may be bound to two actions. +func (c config) validateKeybindings() error { k := defaultKeys() bs := k.bindings(c.Keybindings) for _, b := range bs { @@ -377,25 +377,25 @@ func (c config) checkKeybindings() error { want := "a list of keys, each a character or a named key (" + keyNames() + "), alone or after ctrl+ or alt+" if len(b.keys) == 0 { - return invalid("keybindings."+b.name, want, "[]") + return invalidError("keybindings."+b.name, want, "[]") } for _, s := range b.keys { if _, ok := keyName(s); !ok { - return invalid("keybindings."+b.name, want, s) + return invalidError("keybindings."+b.name, want, s) } } } k.applyConfig(c.Keybindings) - first := map[string]string{} // the first action of each key + first := map[string]string{} // the first action bound to each key for _, b := range bs { for _, key := range b.key.Keys() { switch a, ok := first[key]; { case ok && a == b.name: return fmt.Errorf("invalid value for keybindings: %q is bound twice to %q", - herdrReviewrKeyName(key), b.name) + settingKeyName(key), b.name) case ok: return fmt.Errorf("invalid value for keybindings: %q is bound to both %q and %q", - herdrReviewrKeyName(key), a, b.name) + settingKeyName(key), a, b.name) } first[key] = b.name } @@ -403,15 +403,15 @@ func (c config) checkKeybindings() error { return nil } -// invalid returns the error for a value of the setting key that rexdiff -// does not accept. want tells the values that rexdiff accepts. -func invalid(key, want, value string) error { +// invalidError returns an error for an invalid value of the setting key. +// want describes the accepted values. +func invalidError(key, want, value string) error { return fmt.Errorf("invalid value for %s: expected %s, not %q", key, want, value) } -// onlyPlaceholders reports whether each { in command opens one of the -// known placeholders, for example {file}. -func onlyPlaceholders(command string, known ...string) bool { +// hasOnlyPlaceholders reports whether every { in command starts one of the +// known placeholders, such as {file}. +func hasOnlyPlaceholders(command string, known ...string) bool { for _, rest, ok := strings.Cut(command, "{"); ok; _, rest, ok = strings.Cut(rest, "{") { name, _, closed := strings.Cut(rest, "}") if !closed || !slices.Contains(known, name) { @@ -421,13 +421,13 @@ func onlyPlaceholders(command string, known ...string) bool { return true } -// The longest DNS name and the longest label of a DNS name (RFC 1035). +// The maximum lengths of a DNS name and of one of its labels (RFC 1035). const ( maxHostname = 253 maxHostLabel = 63 ) -// isHostname reports whether s is a bare DNS name, for example +// isHostname reports whether s is a bare DNS name, such as // "git.example.com", with no scheme, port, or path. func isHostname(s string) bool { if len(s) > maxHostname { @@ -441,7 +441,7 @@ func isHostname(s string) bool { return true } -// isHostLabel reports whether s is one valid label of a DNS name. +// isHostLabel reports whether s is a valid label of a DNS name. func isHostLabel(s string) bool { if s == "" || len(s) > maxHostLabel || s[0] == '-' || s[len(s)-1] == '-' { return false @@ -452,12 +452,13 @@ func isHostLabel(s string) bool { }) } -// focusesAgent reports whether the focus moves to the agent after a send. +// focusesAgent reports whether focus moves to the agent after sending a +// review. func (c config) focusesAgent() bool { return c.FocusAgent == nil || *c.FocusAgent } -// choice returns an error if value is not one of allowed. key is the name -// of the value in the error. -func choice[T ~string](key string, value T, allowed []T) error { +// validateChoice returns an error if value is not in allowed. key names +// the setting in the error message. +func validateChoice[T ~string](key string, value T, allowed []T) error { if slices.Contains(allowed, value) { return nil } @@ -465,5 +466,5 @@ func choice[T ~string](key string, value T, allowed []T) error { for i, a := range allowed { want[i] = string(a) } - return invalid(key, "one of "+strings.Join(want, ", "), string(value)) + return invalidError(key, "one of "+strings.Join(want, ", "), string(value)) } diff --git a/tui/editor.go b/tui/editor.go index 6dbd3eb..54c5673 100644 --- a/tui/editor.go +++ b/tui/editor.go @@ -17,32 +17,35 @@ import ( "github.com/rednafi/rexdiff/internal/editor" "github.com/rednafi/rexdiff/internal/proc" + "github.com/rednafi/rexdiff/internal/xctx" ) -// openEditor opens the file at path, at line. It uses the editor of the -// settings, else $VISUAL, $EDITOR, or core.editor. The variables come from -// m.env. The editor runs with m.env. A terminal editor opens in a new -// block beside rexdiff in Rex. Outside Rex, a terminal editor takes the -// screen until the editor stops. A window editor starts in the background. -// The messages are the same as in herdr-reviewr. -func (m *model) openEditor(path string, line int) tea.Cmd { +// launchEditor opens the file at path on the given line. It uses the +// editor from the settings or, if none is set, $VISUAL, $EDITOR or +// core.editor, read from m.env. The editor runs with m.env. Inside Rex, a +// terminal editor opens in a new block beside rexdiff. Outside Rex, it +// takes over the screen until it exits. A GUI editor starts in the +// background. +func (m *model) launchEditor(path string, line int) tea.Cmd { abs := filepath.Join(m.repo.Root(), path) - src := editor.Sources{ + sources := editor.Sources{ Template: m.cfg.Editor, Visual: proc.Getenv(m.env, "VISUAL"), Editor: proc.Getenv(m.env, "EDITOR"), } - if src.Template == "" { - src.Git = m.repo.Editor(m.ctx) + if sources.Template == "" { + ctx, done := m.gitContext("git editor") + sources.Git = m.repo.GitEditor(ctx) + done() } - ed, err := editor.Resolve(src, abs, line) + ed, err := editor.CommandFor(sources, abs, line) if err != nil { m.flash("editor failed: " + err.Error()) return nil } - // The editor makes the file again when it saves, so do not open a - // file that is not on disk. - if fi, err := os.Stat(abs); err != nil || !fi.Mode().IsRegular() { + // Saving in the editor would create the file again, so do not open a + // file that no longer exists. + if info, err := os.Stat(abs); err != nil || !info.Mode().IsRegular() { m.flash(path + " is gone") return nil } @@ -52,46 +55,41 @@ func (m *model) openEditor(path string, line int) tea.Cmd { } m.log.Debug("editor", "command", ed.Script, "window", ed.Window) if ed.Window { - return m.runWindowEditor(path, ed.Script) + return m.runEditorWindow(path, ed.Script) } if m.rex == nil { - return m.runHere(path, ed.Script) + return m.runEditorHere(path, ed.Script) } c, ctx, root := m.rex, m.ctx, m.repo.Root() return func() tea.Msg { - ctx, cancel := context.WithCancelCause(ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, rexTimeout, - fmt.Errorf("editor split: %v timeout: %w", rexTimeout, context.DeadlineExceeded)) - defer cancelTimeout() - defer cancel(fmt.Errorf("editor split: done: %w", context.Canceled)) + ctx, cancel := xctx.WithTimeout(ctx, rexTimeout, "editor split") + defer cancel() err := c.Split(ctx, root, "sh", "-c", ed.Script) - return splitMsg{path: path, script: ed.Script, err: err} + return editorPaneMsg{path: path, script: ed.Script, err: err} } } -// onSplit tells that the editor opens in a new pane. If Rex cannot open -// the pane, the editor runs in this pane. -func (m *model) onSplit(msg splitMsg) tea.Cmd { +// onEditorPane handles the result of opening the editor in a new pane. If +// Rex could not open the pane, the editor runs in this pane instead. +func (m *model) onEditorPane(msg editorPaneMsg) tea.Cmd { if msg.err == nil { m.flash("opened " + msg.path) return nil } m.log.Debug("editor split failed", "err", msg.err) - return m.runHere(msg.path, msg.script) + return m.runEditorHere(msg.path, msg.script) } -// runHere runs the editor script in this pane, in place of rexdiff. -func (m *model) runHere(path, script string) tea.Cmd { +// runEditorHere runs the editor script in this pane, suspending rexdiff +// until it exits. +func (m *model) runEditorHere(path, script string) tea.Cmd { return tea.ExecProcess(command(m.env, "sh", "-c", script), func(err error) tea.Msg { return editorDoneMsg{path: path, err: err} }) } -// runWindowEditor starts a window editor for path. The editor gets its -// own session and no terminal, so that it cannot read the keys of rexdiff. -// If the editor fails, the returned command gives the error and the last -// line of the stderr of the editor. -func (m *model) runWindowEditor(path, script string) tea.Cmd { +// runEditorWindow starts a GUI editor for path with [detach]. +func (m *model) runEditorWindow(path, script string) tea.Cmd { wait, err := detach(m.env, "editor", "sh", "-c", script) if err != nil { m.flash("editor failed: " + err.Error()) @@ -101,11 +99,10 @@ func (m *model) runWindowEditor(path, script string) tea.Cmd { return wait } -// detach starts a program with the environment env in its own session. -// The program has no terminal, so it cannot read the keys of rexdiff. The -// returned command waits for the program. If the program fails, the -// command gives a detachedMsg with the last line of the stderr of the -// program. +// detach starts a program with the environment env in its own session, +// with no controlling terminal. The returned command waits for the program +// to exit. If the program fails, the command returns a detachedMsg that +// includes the last line of its stderr. func detach(env []string, what, name string, args ...string) (tea.Cmd, error) { cmd := command(env, name, args...) var stderr bytes.Buffer @@ -126,8 +123,9 @@ func detach(env []string, what, name string, args ...string) (tea.Cmd, error) { }, nil } -// onEditorDone shows how the editor in this pane stops. An editor that -// stops with an error code can still save the file. +// onEditorDone reports how the editor in this pane exited and renders the +// file again, because an editor that exits with an error may still have +// saved it. func (m *model) onEditorDone(msg editorDoneMsg) { exit, isExit := errors.AsType[*exec.ExitError](msg.err) switch { @@ -141,12 +139,13 @@ func (m *model) onEditorDone(msg editorDoneMsg) { m.rerender() } -// openURL opens url with the URL opener of the settings, else with -// the opener of the system. As in herdr-reviewr, it opens only an http or -// an https URL. The opener runs with the environment env, and with no -// shell. A missing opener gives the error of [proc.NotInstalled]. The -// command waits for the opener. -func openURL(env []string, cfg config, url string) (tea.Cmd, error) { +// urlOpenerCmd returns a command that opens url with the URL opener from +// the settings or, if none is set, the system opener. It accepts only http +// and https URLs. The opener runs with the environment env and without a +// shell. If the opener is not installed, it returns a +// [proc.NotInstalled]. The returned command waits for the opener to +// exit. +func urlOpenerCmd(env []string, cfg config, url string) (tea.Cmd, error) { url = strings.TrimSpace(url) if !isWebURL(url) { return nil, errors.New("unsupported link scheme") @@ -165,9 +164,9 @@ func openURL(env []string, cfg config, url string) (tea.Cmd, error) { return detach(env, "open", name, args...) } -// isWebURL reports whether url is http or https with text after the -// scheme, as herdr-reviewr checks it. A control character or a bidi -// mark can hide a part of the URL, so url must have none. +// isWebURL reports whether url is an http or https URL with something +// after the scheme. It rejects URLs that contain control characters or +// bidi marks, because these can hide part of the URL. func isWebURL(url string) bool { lower := strings.ToLower(url) web := len(url) > len("http://") && strings.HasPrefix(lower, "http://") || @@ -177,25 +176,22 @@ func isWebURL(url string) bool { }) } -// openerArgs returns the program and the arguments of the URL opener -// command, as herdr-reviewr splits it. The words split at spaces, and a -// quoted text is one word. Each {url} becomes url. Without {url}, url is -// the last argument. The check of the settings makes sure that the first -// word is a program. -func openerArgs(command, url string) []string { +// openerArgs splits spec, the URL opener command, with [splitWords] and +// returns the program followed by its arguments. Each {url} is replaced +// with url. If spec has no {url}, url is added as the last argument. +func openerArgs(spec, url string) []string { var args []string - for _, w := range splitWords(command) { + for _, w := range splitWords(spec) { args = append(args, strings.ReplaceAll(w, "{url}", url)) } - if !strings.Contains(command, "{url}") { + if !strings.Contains(spec, "{url}") { args = append(args, url) } return args } -// splitWords splits command into words at spaces, as herdr-reviewr does. -// Single or double quotes keep the spaces in a word. The quotes do not -// stay in the word. +// splitWords splits command into words at white space. Single or double +// quotes keep spaces inside a word, and the quotes themselves are removed. func splitWords(command string) []string { var words []string var quote rune @@ -222,10 +218,10 @@ func splitWords(command string) []string { return words } -// command returns the command for an editor or a URL opener, with the -// environment env. The context of the command has no end, because the -// program must not stop when rexdiff stops. For example, a kill of the -// editor can lose the edits of the user. +// command returns the command for an editor or URL opener, with the +// environment env. It uses [context.Background] so that the program keeps +// running when rexdiff exits, because killing an editor can lose the +// user's edits. func command(env []string, name string, args ...string) *exec.Cmd { return proc.Command(context.Background(), env, name, args...) } diff --git a/tui/find.go b/tui/find.go index 0228f88..3eb63cf 100644 --- a/tui/find.go +++ b/tui/find.go @@ -14,60 +14,80 @@ import ( "github.com/rednafi/rexdiff/internal/search" ) -// This file has the find band of herdr-reviewr. The band opens at the -// bottom of the screen and stays open. Enter or down goes to the next hit, -// up goes to the previous hit, and esc closes the band. +// The find bar opens at the bottom of the screen and stays open. Enter or +// Down moves to the next match, Up moves to the previous match, and Esc +// closes the bar. -// findState is the query of the find band. The band is open in modeFind. +// maxFindQuery is the most characters that the find query can hold. The +// input stops taking characters there. +const maxFindQuery = 256 + +// findState is the state of the find bar, which is open in modeFind. hits +// caches the matches of the last search, because View needs them on every +// frame. type findState struct { input textinput.Model + hits findCache +} + +// findCache holds the matches of a search together with what they +// depend on: the query, the tab, which selects the pane, and the pane's +// render count. The rows, lines, path and file texts of a pane change +// only when it is rendered again, which increments the count. +type findCache struct { + ok bool + q string + tab tab + renders int + hits []findHit } -// findHit is a line that matches the query. row is its first row in the -// pane. A line in a fold that hides it has the row of the fold, and its -// new line in line. Else line is 0. +// findHit is a line that matches the query. row is the first pane row of +// the line. For a line inside a fold, row is the fold row and line is the +// new-side line number. Otherwise line is 0. type findHit struct { row, line int } -// findable reports whether the find band can open: on a file tab, with -// a row that shows text. -func (m *model) findable() bool { +// canFind reports whether the current view has text to find, which needs +// a file tab with at least one row of searchable text. +func (m *model) canFind() bool { if m.tab == tabPR { return false } - p := m.cur() + p := m.curPane() for i := range p.rows { - if p.hasContent(i) { + if p.canFindRow(i) { return true } } return false } -// hasContent reports whether row i of p shows text that a find can match. -// A note has no such text. The text of a row of rendered markdown is the -// rendered line. -func (p *pane) hasContent(i int) bool { +// canFindRow reports whether row i of p has text that find can match. +// Rows that only show a note have no such text. For rendered Markdown, +// the text is the rendered line. +func (p *pane) canFindRow(i int) bool { r := p.rows[i] - return r.Old > 0 || r.New > 0 || r.Fold.Lines > 0 || (p.path == "" && i < len(p.lines)) + return r.Old > 0 || r.New > 0 || r.Fold.Lines > 0 || (p.path == "" && i < p.count()) } -// openFind opens the find band with an empty query. +// openFind opens the find bar with an empty query. The bar opens even +// when there is nothing to find, such as for a binary file, so that the +// keys typed next go into the query and do not run commands. func (m *model) openFind() tea.Cmd { - if !m.findable() { - return nil - } ti := textinput.New() + ti.CharLimit = maxFindQuery ti.Prompt = "find " ti.Placeholder = "Find in file…" m.find = findState{input: ti} - m.mode, m.focusList = modeFind, false - m.cur().anchor = -1 + m.mode, m.navigatorFocused = modeFind, false + m.curPane().selStart = -1 return m.find.input.Focus() } -// closeFind closes the find band and drops the query. The cursor stays. +// closeFind closes the find bar and clears the query. The cursor does not +// move. func (m *model) closeFind() { if m.mode == modeFind { m.mode = modeNormal @@ -75,8 +95,8 @@ func (m *model) closeFind() { m.find = findState{} } -// updateFind handles a key while the find band is open. As in -// herdr-reviewr, the other keys edit the query. +// updateFind handles a key press while the find bar is open. Keys other +// than Esc, Enter, Up and Down edit the query. func (m *model) updateFind(msg tea.KeyPressMsg) tea.Cmd { switch msg.Code { case tea.KeyEscape: @@ -84,9 +104,11 @@ func (m *model) updateFind(msg tea.KeyPressMsg) tea.Cmd { return nil case tea.KeyEnter, tea.KeyDown: m.stepFind(1) + m.scrollToCursor() // otherwise the view only catches up on the next tick return nil case tea.KeyUp: m.stepFind(-1) + m.scrollToCursor() return nil } var cmd tea.Cmd @@ -94,44 +116,60 @@ func (m *model) updateFind(msg tea.KeyPressMsg) tea.Cmd { return cmd } -// paneSource returns the lines of the two sides of the file in the -// current pane. -func (m *model) paneSource() (before, after []string) { +// findSource returns the lines of both sides of the file in the current +// pane. +func (m *model) findSource() (oldLines, newLines []string) { switch m.tab { case tabChanges: - if f := m.selectedChange(); f != nil { - return m.findLines.of(f.Old, f.New) + if f := m.curChangedFile(); f != nil { + return m.findSides.of(f.OldText, f.NewText) } - case tabFiles: - return m.findLines.of("", m.viewed) + case tabAllFiles: + return m.findSides.of("", m.allFiles.shownText) } return nil, nil } -// splitLines keeps the lines of the last two sides, so a find does not -// split the file again on each frame. -type splitLines struct { +// sideLines caches the split lines of the most recent pair of sides, so +// that find does not split the file again on every frame. +type sideLines struct { old, new string oldLines, newLines []string } -// of returns the lines of before and after. -func (s *splitLines) of(before, after string) ([]string, []string) { - if before != s.old || after != s.new { - *s = splitLines{before, after, diff.Lines(before), diff.Lines(after)} +// of returns the lines of oldText and newText, splitting them only if they +// differ from the cached texts. +func (s *sideLines) of(oldText, newText string) ([]string, []string) { + if oldText != s.old || newText != s.new { + *s = sideLines{oldText, newText, diff.SplitLines(oldText), diff.SplitLines(newText)} } return s.oldLines, s.newLines } -// findHits returns the lines of the pane that match q, in order. It -// matches the source text of a line, and not the line numbers. A line on -// both sides, as in the split layout, is one hit. +// findHits returns the lines of the current pane that match q, in order. +// It matches the source text, not the line numbers. A line present on +// both sides counts as one match. It searches again only when the query, +// tab, pane or lines have changed. func (m *model) findHits(q string) []findHit { - if q == "" { + if q == "" || !m.canFind() { return nil } - p := m.cur() - before, after := m.paneSource() + p := m.curPane() + next := findCache{ok: true, q: q, tab: m.tab, renders: p.renders} + c := &m.find.hits + if c.ok && c.q == next.q && c.tab == next.tab && c.renders == next.renders { + return c.hits + } + oldLines, newLines := m.findSource() + next.hits = p.findHits(q, oldLines, newLines) + *c = next + return next.hits +} + +// findHits returns the lines of p that match q, as [model.findHits] +// describes. oldLines and newLines are the lines of the two sides of the +// file. +func (p *pane) findHits(q string, oldLines, newLines []string) []findHit { line := func(ls []string, n int) string { if n >= 1 && n <= len(ls) { return ls[n-1] @@ -141,27 +179,27 @@ func (m *model) findHits(q string) []findHit { var hits []findHit for i, r := range p.rows { switch { - case p.src != nil: - // A hit in rendered markdown is on its block, as the cursor is. - if !r.Continued && slices.ContainsFunc(p.lines[i:p.end(i)+1], func(l string) bool { - return search.Match(ansi.Strip(l), q) - }) { + case p.srcRows != nil: + // In rendered Markdown, a match points at the start of its + // block, just as the cursor does. + if !r.Continued && p.blockMatches(i, q) { hits = append(hits, findHit{i, 0}) } case r.Continued: case r.Fold.Lines > 0: for n := r.Fold.New; n < r.Fold.New+r.Fold.Lines; n++ { - if search.Match(line(after, n), q) { + if search.Matches(line(newLines, n), q) { hits = append(hits, findHit{i, n}) } } case r.Old > 0 || r.New > 0: - if (r.New > 0 && search.Match(line(after, r.New), q)) || - (r.Old > 0 && search.Match(line(before, r.Old), q)) { + newHit := r.New > 0 && search.Matches(line(newLines, r.New), q) + oldHit := r.Old > 0 && search.Matches(line(oldLines, r.Old), q) + if newHit || oldHit { hits = append(hits, findHit{i, 0}) } - case p.hasContent(i): - if search.Match(ansi.Strip(p.lines[i]), q) { + case p.canFindRow(i): + if search.Matches(ansi.Strip(p.line(i)), q) { hits = append(hits, findHit{i, 0}) } } @@ -169,49 +207,104 @@ func (m *model) findHits(q string) []findHit { return hits } -// findPlace returns the number of hits before the cursor, and if the -// cursor is on a hit. -func (m *model) findPlace(hits []findHit) (before int, on bool) { - cur := m.cur().head(m.cur().cur) +// blockMatches reports whether any line of the rendered Markdown block at +// row i matches q. +func (p *pane) blockMatches(i int, q string) bool { + return slices.ContainsFunc(p.lines[i:p.lineEnd(i)+1], func(l string) bool { + return search.Matches(ansi.Strip(l), q) + }) +} + +// findPlace returns the number of matches before the cursor, and whether +// the cursor is on a match. +func (m *model) findPlace(hits []findHit) (hitsBefore int, on bool) { + cur := m.curPane().lineStart(m.curPane().cur) for _, h := range hits { switch { case h.row < cur: - before++ + hitsBefore++ case h.row == cur && h.line == 0: - return before, true + return hitsBefore, true } } - return before, false + return hitsBefore, false } -// stepFind moves the cursor to the next (d = 1) or the previous (d = -1) -// hit. It wraps around. Forward skips the hit at the cursor. A hit in a -// fold opens the fold. +// stepFind moves the cursor to the next (d = 1) or previous (d = -1) +// match, wrapping around at the ends. Moving forward skips the match under +// the cursor. Moving to a match inside a fold expands the fold. func (m *model) stepFind(d int) { hits := m.findHits(m.find.input.Value()) if len(hits) == 0 { return } - before, on := m.findPlace(hits) + hitsBefore, on := m.findPlace(hits) n := len(hits) - i := (before + n - 1) % n + i := (hitsBefore + n - 1) % n if d > 0 { - i = before % n + i = hitsBefore % n if on { - i = (before + 1) % n + i = (hitsBefore + 1) % n } } - h, p := hits[i], m.cur() + h, p := hits[i], m.curPane() if h.line == 0 { p.cur = h.row + } else { + m.expandFold(p.rows[h.row].Fold) + jumpToLine(p, diff.SideNew, h.line) + } + m.showMatch(m.find.input.Value()) +} + +// showMatch brings the first match of q in the source line at the cursor +// into view. If lines wrap and the line is taller than the screen, it +// scrolls the view down to the screen line that holds the match, as +// moveCur scrolls through such a line. If lines do not wrap, it scrolls +// the view sideways. It does nothing in rendered Markdown. +func (m *model) showMatch(q string) { + p := m.curPane() + if p.diff == nil || p.srcRows != nil { + return + } + row := p.lineStart(p.cur) + if row >= len(p.rows) { + return + } + r := p.rows[row] + oldLines, newLines := m.findSource() + side, at := diff.SideNew, -1 + if r.New >= 1 && r.New <= len(newLines) { + at = search.Index(newLines[r.New-1], q) + } + if at < 0 && r.Old >= 1 && r.Old <= len(oldLines) { + side, at = diff.SideOld, search.Index(oldLines[r.Old-1], q) + } + if at < 0 { + return + } + line, col := p.diff.Locate(row, side, at) + if m.wrap { + m.scrollToCursor() + if h := max(m.mainHeight(), 1); line >= p.top+h { + // Show the match a third of the way down, but no further than + // the end of the line. + p.top = min(max(line-h/3, row), max(p.lineEnd(row)-h+1, row)) + p.following = false + } + return + } + w, qw := p.diff.CodeWidth(side), ansi.StringWidth(q) + if w == 0 || (col >= p.scrollX && col+qw <= p.scrollX+w) { return } - m.expandFold(p.rows[h.row].Fold) - cursorToLine(p, diff.SideNew, h.line) + // Put the match a third of the way into the code. + p.scrollX = max(col-w/3, 0) + m.rerender() } -// shownHits returns the hits of the query in the find band, or nil if -// the band is closed. +// shownHits returns the matches for the query in the find bar, or nil if +// the bar is closed. func (m *model) shownHits() []findHit { if m.mode != modeFind { return nil @@ -219,25 +312,30 @@ func (m *model) shownHits() []findHit { return m.findHits(m.find.input.Value()) } -// findBand renders the find band: the query, and the count at the right. -// The count is "i/n" on a hit, n off a hit, or "no matches". hits are the -// hits of the query. +// findBand renders the find bar: the query, with the match count on the +// right. The count is "i/n" when the cursor is on a match, "n" when it is +// not, and "no matches" when nothing matches. In a view with no text to +// search, a note takes the place of the count. func (m *model) findBand(hits []findHit) string { count := "" - if m.find.input.Value() != "" { - before, on := m.findPlace(hits) + switch { + case !m.canFind(): + count = "nothing to find here" + case m.find.input.Value() != "": + hitsBefore, on := m.findPlace(hits) switch { case len(hits) == 0: count = "no matches" case on: - count = fmt.Sprintf("%d/%d", before+1, len(hits)) + count = fmt.Sprintf("%d/%d", hitsBefore+1, len(hits)) default: count = strconv.Itoa(len(hits)) } } - w := max(m.w-ansi.StringWidth(count)-1, 1) + fw := m.footerWidth() + w := max(fw-ansi.StringWidth(count)-1, 1) m.find.input.SetWidth(max(w-ansi.StringWidth(m.find.input.Prompt)-1, 1)) band := truncate(m.find.input.View(), w) - pad := strings.Repeat(" ", max(m.w-ansi.StringWidth(band)-ansi.StringWidth(count), 0)) + pad := strings.Repeat(" ", max(fw-ansi.StringWidth(band)-ansi.StringWidth(count), 0)) return band + pad + dimStyle.Render(count) } diff --git a/tui/keys.go b/tui/keys.go index 03fde7d..dce2505 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -8,15 +8,16 @@ import ( "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" ) -// keyMap uses the action names and the default keys of herdr-reviewr. -// The fields have the names of the actions in the keybindings setting. -// Focus and Update are actions of rexdiff only. The settings do not change -// the keys of the comment box and of the find. +// keyMap holds the key binding for each action. Each field is named after +// the action in the keybindings setting, such as NextHunk for next-hunk +// (see [keyActions]). The settings cannot change the keys of the comment +// box or the find bar. type keyMap struct { - // Move. + // Movement. Down, Up, PageDown, PageUp, HalfDown, HalfUp, Collapse, Expand key.Binding NextHunk, PrevHunk, NextFile, PrevFile, Focus key.Binding @@ -32,188 +33,475 @@ type keyMap struct { Search, Find, GotoLine, Rendered, Split, Wrap key.Binding NavigatorPosition, NavigatorHide, NavigatorGrow, NavigatorShrink key.Binding OpenPR, Refresh, Keys, Quit, QuitDiscard key.Binding - // Update installs a newer release. It works only while the footer - // tells about a newer release. + // Update installs a newer release. It works only while the footer is + // announcing one. Update key.Binding - // The comment box. Cancel also clears the selection. - Save, Newline, Cancel key.Binding + // Keys of the comment box. Cancel also clears the selection. + SaveComment, InsertNewline, Cancel key.Binding } -// bind returns a binding of keys, with helpKey and desc in the help. +// bind returns a binding for keys, with helpKey and desc as its help text. func bind(keys []string, helpKey, desc string) key.Binding { return key.NewBinding(key.WithKeys(keys...), key.WithHelp(helpKey, desc)) } -// hint returns a copy of b with other help text. The copy keeps the keys -// of b, because the help does not show a binding that has no keys. +// hint returns a copy of b with different help text. The copy keeps the +// keys of b, because the help view hides bindings that have no keys. func hint(b key.Binding, helpKey, desc string) key.Binding { b.SetHelp(helpKey, desc) return b } -// defaultKeys returns the keys of herdr-reviewr, with the help text of -// each action. Some actions share one help label, for example "j/k move". -// Then the label is on the first action of the pair. +// keyAction describes an action that can be bound to keys. +type keyAction struct { + // name is the action name in the keybindings setting, or "" for a + // key of the comment box. + name string + keys []string // the default keys + helpKey, help string + field func(*keyMap) *key.Binding + // setting returns the keys that the keybindings setting assigns to + // the action. It is nil if the setting cannot change them. + setting func(*keybindings) []string + // handle runs the action when no box is open. It is nil if the key + // does nothing in that state. The QuitDiscard key only answers the + // quit prompt. + handle func(*model) tea.Cmd + // keepView reports whether the view stays put, rather than scrolling + // to the cursor, after the action runs. + keepView bool +} + +// keyActions lists every key action, in the order that keybinding errors +// name them. The update action, whose key is fixed, follows the +// configurable actions, and the comment box keys come last. A help label +// shared by a group, such as "j/k move", belongs to its first action (see +// [keyMap.pairs]). +var keyActions = []keyAction{ + { + name: "down", + keys: []string{"j", "down"}, helpKey: "j/k", help: "move", + field: func(k *keyMap) *key.Binding { return &k.Down }, + setting: func(c *keybindings) []string { return c.Down }, + handle: (*model).onDown, + }, + { + name: "up", + keys: []string{"k", "up"}, helpKey: "k", help: "up", + field: func(k *keyMap) *key.Binding { return &k.Up }, + setting: func(c *keybindings) []string { return c.Up }, + handle: (*model).onUp, + }, + { + name: "next-hunk", + keys: []string{"]"}, helpKey: "]/[", help: "next/prev change", + field: func(k *keyMap) *key.Binding { return &k.NextHunk }, + setting: func(c *keybindings) []string { return c.NextHunk }, + handle: (*model).onNextHunk, + }, + { + name: "prev-hunk", + keys: []string{"["}, helpKey: "[", help: "prev change", + field: func(k *keyMap) *key.Binding { return &k.PrevHunk }, + setting: func(c *keybindings) []string { return c.PrevHunk }, + handle: (*model).onPrevHunk, + }, + { + name: "next-file", + keys: []string{"f"}, helpKey: "f/F", help: "next/prev file", + field: func(k *keyMap) *key.Binding { return &k.NextFile }, + setting: func(c *keybindings) []string { return c.NextFile }, + handle: (*model).onNextFile, + }, + { + name: "prev-file", + keys: []string{"F"}, helpKey: "F", help: "prev file", + field: func(k *keyMap) *key.Binding { return &k.PrevFile }, + setting: func(c *keybindings) []string { return c.PrevFile }, + handle: (*model).onPrevFile, + }, + { + name: "collapse", + keys: []string{"left"}, helpKey: "←/→", help: "scroll sideways", + field: func(k *keyMap) *key.Binding { return &k.Collapse }, + setting: func(c *keybindings) []string { return c.Collapse }, + handle: (*model).onCollapse, + }, + { + name: "expand", + keys: []string{"right"}, helpKey: "→", help: "scroll right", + field: func(k *keyMap) *key.Binding { return &k.Expand }, + setting: func(c *keybindings) []string { return c.Expand }, + handle: (*model).onExpand, + }, + { + name: "page-up", + keys: []string{"pgup"}, helpKey: "pgup", help: "page up", + field: func(k *keyMap) *key.Binding { return &k.PageUp }, + setting: func(c *keybindings) []string { return c.PageUp }, + handle: (*model).onPageUp, + }, + { + name: "page-down", + keys: []string{"pgdown"}, helpKey: "pgdn", help: "page down", + field: func(k *keyMap) *key.Binding { return &k.PageDown }, + setting: func(c *keybindings) []string { return c.PageDown }, + handle: (*model).onPageDown, + }, + { + name: "half-up", + keys: []string{"ctrl+u"}, helpKey: "ctrl+u", help: "half page up", + field: func(k *keyMap) *key.Binding { return &k.HalfUp }, + setting: func(c *keybindings) []string { return c.HalfUp }, + handle: (*model).onHalfUp, + }, + { + name: "half-down", + keys: []string{"ctrl+d"}, helpKey: "ctrl+d/u", help: "half page", + field: func(k *keyMap) *key.Binding { return &k.HalfDown }, + setting: func(c *keybindings) []string { return c.HalfDown }, + handle: (*model).onHalfDown, + }, + { + name: "focus", + keys: []string{"tab"}, helpKey: "tab", help: "switch pane", + field: func(k *keyMap) *key.Binding { return &k.Focus }, + setting: func(c *keybindings) []string { return c.Focus }, + handle: (*model).onFocus, + }, + { + name: "scope-uncommitted", + keys: []string{"u"}, helpKey: "u", help: "uncommitted", + field: func(k *keyMap) *key.Binding { return &k.ScopeUncommitted }, + setting: func(c *keybindings) []string { return c.ScopeUncommitted }, + handle: (*model).onScopeUncommitted, + }, + { + name: "scope-branch", + keys: []string{"b"}, helpKey: "b", help: "branch", + field: func(k *keyMap) *key.Binding { return &k.ScopeBranch }, + setting: func(c *keybindings) []string { return c.ScopeBranch }, + handle: (*model).onScopeBranch, + }, + { + name: "scope-last-turn", + keys: []string{"t"}, helpKey: "t", help: "last turn", + field: func(k *keyMap) *key.Binding { return &k.ScopeLastTurn }, + setting: func(c *keybindings) []string { return c.ScopeLastTurn }, + handle: (*model).onScopeLastTurn, + }, + { + name: "scope-commits", + keys: []string{"g"}, helpKey: "g", help: "commits", + field: func(k *keyMap) *key.Binding { return &k.ScopeCommits }, + setting: func(c *keybindings) []string { return c.ScopeCommits }, + handle: (*model).onScopeCommits, + }, + { + name: "base-pick", + keys: []string{"B"}, helpKey: "B", help: "pick base branch", + field: func(k *keyMap) *key.Binding { return &k.BasePick }, + setting: func(c *keybindings) []string { return c.BasePick }, + handle: (*model).onBasePick, + }, + { + name: "commit-pick", + keys: []string{"G"}, helpKey: "G", help: "pick commits", + field: func(k *keyMap) *key.Binding { return &k.CommitPick }, + setting: func(c *keybindings) []string { return c.CommitPick }, + handle: (*model).onCommitPick, + }, + { + name: "tab-changes", + keys: []string{"1"}, helpKey: "1/2/3", help: "changes/files/PR", + field: func(k *keyMap) *key.Binding { return &k.TabChanges }, + setting: func(c *keybindings) []string { return c.TabChanges }, + handle: (*model).onTabChanges, + }, + { + name: "tab-all-files", + keys: []string{"2"}, helpKey: "2", help: "all files", + field: func(k *keyMap) *key.Binding { return &k.TabAllFiles }, + setting: func(c *keybindings) []string { return c.TabAllFiles }, + handle: (*model).onTabAllFiles, + }, + { + name: "tab-pr", + keys: []string{"3"}, helpKey: "3", help: "PR", + field: func(k *keyMap) *key.Binding { return &k.TabPR }, + setting: func(c *keybindings) []string { return c.TabPR }, + handle: (*model).onTabPR, + }, + { + name: "wrap", + keys: []string{"w"}, helpKey: "w", help: "wrap lines", + field: func(k *keyMap) *key.Binding { return &k.Wrap }, + setting: func(c *keybindings) []string { return c.Wrap }, + handle: (*model).onWrap, + }, + { + name: "rendered", + keys: []string{"m"}, helpKey: "m", help: "rendered markdown", + field: func(k *keyMap) *key.Binding { return &k.Rendered }, + setting: func(c *keybindings) []string { return c.Rendered }, + handle: (*model).onRendered, + }, + { + name: "split", + keys: []string{"|"}, helpKey: "|", help: "side by side", + field: func(k *keyMap) *key.Binding { return &k.Split }, + setting: func(c *keybindings) []string { return c.Split }, + handle: (*model).onSplit, + }, + { + name: "navigator-position", + keys: []string{"p"}, helpKey: "p", help: "move list", + field: func(k *keyMap) *key.Binding { return &k.NavigatorPosition }, + setting: func(c *keybindings) []string { return c.NavigatorPosition }, + handle: (*model).onNavigatorPosition, + }, + { + name: "navigator-hide", + keys: []string{"z"}, helpKey: "z", help: "hide list", + field: func(k *keyMap) *key.Binding { return &k.NavigatorHide }, + setting: func(c *keybindings) []string { return c.NavigatorHide }, + handle: (*model).onNavigatorHide, + }, + { + name: "navigator-grow", + keys: []string{"<"}, helpKey: "", help: "resize list", + field: func(k *keyMap) *key.Binding { return &k.NavigatorGrow }, + setting: func(c *keybindings) []string { return c.NavigatorGrow }, + handle: (*model).onNavigatorGrow, + }, + { + name: "navigator-shrink", + keys: []string{">"}, helpKey: ">", help: "narrow list", + field: func(k *keyMap) *key.Binding { return &k.NavigatorShrink }, + setting: func(c *keybindings) []string { return c.NavigatorShrink }, + handle: (*model).onNavigatorShrink, + }, + { + name: "select", + keys: []string{"v"}, helpKey: "v", help: "select lines", + field: func(k *keyMap) *key.Binding { return &k.Select }, + setting: func(c *keybindings) []string { return c.Select }, + handle: (*model).onSelect, + }, + { + name: "comment", + keys: []string{"c"}, helpKey: "c", help: "comment", + field: func(k *keyMap) *key.Binding { return &k.Comment }, + setting: func(c *keybindings) []string { return c.Comment }, + handle: (*model).onComment, + }, + { + name: "edit", + keys: []string{"e"}, helpKey: "e", help: "edit comment/open editor", + field: func(k *keyMap) *key.Binding { return &k.Edit }, + setting: func(c *keybindings) []string { return c.Edit }, + handle: (*model).onEdit, + }, + { + name: "delete", + keys: []string{"d"}, helpKey: "d", help: "delete comment", + field: func(k *keyMap) *key.Binding { return &k.Delete }, + setting: func(c *keybindings) []string { return c.Delete }, + handle: (*model).onDelete, + }, + { + name: "next-comment", + keys: []string{"n"}, helpKey: "n/N", help: "next/prev comment", + field: func(k *keyMap) *key.Binding { return &k.NextComment }, + setting: func(c *keybindings) []string { return c.NextComment }, + handle: (*model).onNextComment, + }, + { + name: "prev-comment", + keys: []string{"N"}, helpKey: "N", help: "prev comment", + field: func(k *keyMap) *key.Binding { return &k.PrevComment }, + setting: func(c *keybindings) []string { return c.PrevComment }, + handle: (*model).onPrevComment, + }, + { + name: "comments", + keys: []string{"l"}, helpKey: "l", help: "list comments", + field: func(k *keyMap) *key.Binding { return &k.Comments }, + setting: func(c *keybindings) []string { return c.Comments }, + handle: (*model).onComments, + }, + { + name: "search", + keys: []string{"/"}, helpKey: "/", help: "search files and code", + field: func(k *keyMap) *key.Binding { return &k.Search }, + setting: func(c *keybindings) []string { return c.Search }, + handle: (*model).onSearch, + }, + { + name: "find", + keys: []string{"ctrl+f"}, helpKey: "ctrl+f", help: "find in file", + field: func(k *keyMap) *key.Binding { return &k.Find }, + setting: func(c *keybindings) []string { return c.Find }, + handle: (*model).onFind, + }, + { + name: "goto-line", + keys: []string{":"}, helpKey: ":", help: "go to line", + field: func(k *keyMap) *key.Binding { return &k.GotoLine }, + setting: func(c *keybindings) []string { return c.GotoLine }, + handle: (*model).onGotoLine, + }, + { + name: "keys", + keys: []string{"?"}, helpKey: "?", help: "all keys", + field: func(k *keyMap) *key.Binding { return &k.Keys }, + setting: func(c *keybindings) []string { return c.Keys }, + handle: (*model).onKeys, + }, + { + name: "send", + keys: []string{"s", "S"}, helpKey: "s", help: "send to agent", + field: func(k *keyMap) *key.Binding { return &k.Send }, + setting: func(c *keybindings) []string { return c.Send }, + handle: (*model).onSend, + }, + { + name: "copy", + keys: []string{"y", "Y"}, helpKey: "y", help: "copy comments", + field: func(k *keyMap) *key.Binding { return &k.Copy }, + setting: func(c *keybindings) []string { return c.Copy }, + handle: (*model).onCopy, + }, + { + name: "open-pr", + keys: []string{"o"}, helpKey: "o", help: "open PR in browser", + field: func(k *keyMap) *key.Binding { return &k.OpenPR }, + setting: func(c *keybindings) []string { return c.OpenPR }, + handle: (*model).onOpenPR, + }, + { + name: "refresh", + keys: []string{"r"}, helpKey: "r", help: "refresh", + field: func(k *keyMap) *key.Binding { return &k.Refresh }, + setting: func(c *keybindings) []string { return c.Refresh }, + handle: (*model).onRefresh, + }, + { + name: "quit", + keys: []string{"q"}, helpKey: "q", help: "quit", + field: func(k *keyMap) *key.Binding { return &k.Quit }, + setting: func(c *keybindings) []string { return c.Quit }, + handle: (*model).onQuit, + keepView: true, + }, + { + name: "quit-discard", + keys: []string{"Q"}, helpKey: "Q", help: "quit, drop comments", + field: func(k *keyMap) *key.Binding { return &k.QuitDiscard }, + setting: func(c *keybindings) []string { return c.QuitDiscard }, + }, + { + name: "update", + keys: []string{"U"}, helpKey: "U", help: "update rexdiff", + field: func(k *keyMap) *key.Binding { return &k.Update }, + handle: (*model).onUpdate, + }, + { + keys: []string{"enter"}, helpKey: "enter", help: "save", + field: func(k *keyMap) *key.Binding { return &k.SaveComment }, + }, + { + keys: []string{"shift+enter", "alt+enter", "ctrl+j"}, helpKey: "shift+enter", help: "new line", + field: func(k *keyMap) *key.Binding { return &k.InsertNewline }, + }, + { + keys: []string{"esc"}, helpKey: "esc", help: "cancel", + field: func(k *keyMap) *key.Binding { return &k.Cancel }, + handle: (*model).onCancel, + }, +} + +// defaultKeys returns the default key bindings, with the help text for +// each action. func defaultKeys() keyMap { - return keyMap{ - Down: bind([]string{"j", "down"}, "j/k", "move"), - Up: bind([]string{"k", "up"}, "k", "up"), - PageDown: bind([]string{"pgdown"}, "pgdn", "page down"), - PageUp: bind([]string{"pgup"}, "pgup", "page up"), - HalfDown: bind([]string{"ctrl+d"}, "ctrl+d/u", "half page"), - HalfUp: bind([]string{"ctrl+u"}, "ctrl+u", "half page up"), - NextHunk: bind([]string{"]"}, "]/[", "next/prev change"), - PrevHunk: bind([]string{"["}, "[", "prev change"), - NextFile: bind([]string{"f"}, "f/F", "next/prev file"), - PrevFile: bind([]string{"F"}, "F", "prev file"), - Focus: bind([]string{"tab"}, "tab", "switch pane"), - Collapse: bind([]string{"left"}, "←/→", "scroll sideways"), - Expand: bind([]string{"right"}, "→", "scroll right"), - - ScopeUncommitted: bind([]string{"u"}, "u", "uncommitted"), - ScopeBranch: bind([]string{"b"}, "b", "branch"), - ScopeLastTurn: bind([]string{"t"}, "t", "last turn"), - ScopeCommits: bind([]string{"g"}, "g", "commits"), - BasePick: bind([]string{"B"}, "B", "pick base branch"), - CommitPick: bind([]string{"G"}, "G", "pick commits"), - TabChanges: bind([]string{"1"}, "1/2/3", "changes/files/PR"), - TabAllFiles: bind([]string{"2"}, "2", "all files"), - TabPR: bind([]string{"3"}, "3", "PR"), - - Select: bind([]string{"v"}, "v", "select lines"), - Comment: bind([]string{"c"}, "c", "comment"), - Edit: bind([]string{"e"}, "e", "edit comment/open editor"), - Delete: bind([]string{"d"}, "d", "delete comment"), - NextComment: bind([]string{"n"}, "n/N", "next/prev comment"), - PrevComment: bind([]string{"N"}, "N", "prev comment"), - Comments: bind([]string{"l"}, "l", "list comments"), - Send: bind([]string{"s", "S"}, "s", "send to agent"), - Copy: bind([]string{"y", "Y"}, "y", "copy comments"), - - Search: bind([]string{"/"}, "/", "search files and code"), - Find: bind([]string{"ctrl+f"}, "ctrl+f", "find in file"), - GotoLine: bind([]string{":"}, ":", "go to line"), - Rendered: bind([]string{"m"}, "m", "rendered markdown"), - Split: bind([]string{"|"}, "|", "side by side"), - Wrap: bind([]string{"w"}, "w", "wrap lines"), - NavigatorPosition: bind([]string{"p"}, "p", "move list"), - NavigatorHide: bind([]string{"z"}, "z", "hide list"), - NavigatorGrow: bind([]string{"<"}, "", "resize list"), - NavigatorShrink: bind([]string{">"}, ">", "narrow list"), - OpenPR: bind([]string{"o"}, "o", "open PR in browser"), - Refresh: bind([]string{"r"}, "r", "refresh"), - Update: bind([]string{"U"}, "U", "update rexdiff"), - Keys: bind([]string{"?"}, "?", "all keys"), - Quit: bind([]string{"q"}, "q", "quit"), - QuitDiscard: bind([]string{"Q"}, "Q", "quit, drop comments"), - - Save: bind([]string{"enter"}, "enter", "save"), - Newline: bind( - []string{"shift+enter", "alt+enter", "ctrl+j"}, - "shift+enter", - "new line", - ), - Cancel: bind([]string{"esc"}, "esc", "cancel"), + var k keyMap + for _, a := range keyActions { + *a.field(&k) = bind(a.keys, a.helpKey, a.help) } + return k } -// overlayKeyMap has the keys of the overlays. The settings do not change -// them. +// action returns the action that msg triggers when no box is open, or +// nil. The order of the actions does not matter, because checking the +// keybindings setting ensures that each key has at most one action. +func (k *keyMap) action(msg tea.KeyPressMsg) *keyAction { + for i, a := range keyActions { + if a.handle != nil && key.Matches(msg, *a.field(k)) { + return &keyActions[i] + } + } + return nil +} + +// overlayKeyMap holds the keys used in overlays. The settings cannot +// change them. type overlayKeyMap struct { + // Up and Down move the cursor through the items. Their letter keys, k + // and j, move the cursor only in an overlay without a text input, such + // as the agent picker. A text input types them instead (see + // [overlay.isCursorKey]). Choose, Close, Up, Down key.Binding - // ListDown and ListUp work in the agent picker. - ListDown, ListUp key.Binding - // Flip switches the mode of the search. PreviewUp and PreviewDown - // scroll the preview of the search. - Flip, PreviewUp, PreviewDown key.Binding + // ToggleSearchMode switches the search mode. ScrollPreviewUp and + // ScrollPreviewDown scroll the search preview. + ToggleSearchMode, ScrollPreviewUp, ScrollPreviewDown key.Binding } -// overlayKeys has the keys of the overlays. The settings do not change -// these keys. +// overlayKeys holds the keys used in overlays. var overlayKeys = overlayKeyMap{ - Choose: key.NewBinding(key.WithKeys("enter")), - Close: key.NewBinding(key.WithKeys("esc")), - Up: key.NewBinding(key.WithKeys("up", "ctrl+p", "ctrl+k")), - Down: key.NewBinding(key.WithKeys("down", "ctrl+n", "ctrl+j")), - Flip: key.NewBinding(key.WithKeys("tab")), - // The keys of the search are the same as in herdr-reviewr. - PreviewUp: key.NewBinding(key.WithKeys("pgup")), - PreviewDown: key.NewBinding(key.WithKeys("pgdown")), - ListDown: key.NewBinding(key.WithKeys("j")), - ListUp: key.NewBinding(key.WithKeys("k")), + Choose: key.NewBinding(key.WithKeys("enter")), + Close: key.NewBinding(key.WithKeys("esc")), + Up: key.NewBinding(key.WithKeys("up", "ctrl+p", "ctrl+k", "k")), + Down: key.NewBinding(key.WithKeys("down", "ctrl+n", "ctrl+j", "j")), + ToggleSearchMode: key.NewBinding(key.WithKeys("tab")), + ScrollPreviewUp: key.NewBinding(key.WithKeys("pgup")), + ScrollPreviewDown: key.NewBinding(key.WithKeys("pgdown")), } -// binding is an action of the keybindings setting and the key binding that -// it changes. +// letterMoveKeys are the keys of overlayKeys.Up and Down that are also +// letters. +var letterMoveKeys = []string{"j", "k"} + +// binding pairs an action from the keybindings setting with the key +// binding that it changes. type binding struct { - name string // the name in the keybindings setting + name string // the action name in the keybindings setting keys []string key *key.Binding } -// bindings returns each action of c with its key binding in k, in the -// order of the table of herdr-reviewr. The check of the keybindings -// setting uses this order to name the actions of a key that two actions -// use. herdr-reviewr has no focus action. The update is last. Its key is -// fixed, and the check keeps it free. +// bindings returns each configurable action with the keys that c sets for +// it and its binding in k, in the order of keyActions. func (k *keyMap) bindings(c keybindings) []binding { - return []binding{ - {"down", c.Down, &k.Down}, - {"up", c.Up, &k.Up}, - {"next-hunk", c.NextHunk, &k.NextHunk}, - {"prev-hunk", c.PrevHunk, &k.PrevHunk}, - {"next-file", c.NextFile, &k.NextFile}, - {"prev-file", c.PrevFile, &k.PrevFile}, - {"collapse", c.Collapse, &k.Collapse}, - {"expand", c.Expand, &k.Expand}, - {"page-up", c.PageUp, &k.PageUp}, - {"page-down", c.PageDown, &k.PageDown}, - {"half-up", c.HalfUp, &k.HalfUp}, - {"half-down", c.HalfDown, &k.HalfDown}, - {"focus", c.Focus, &k.Focus}, - {"scope-uncommitted", c.ScopeUncommitted, &k.ScopeUncommitted}, - {"scope-branch", c.ScopeBranch, &k.ScopeBranch}, - {"scope-last-turn", c.ScopeLastTurn, &k.ScopeLastTurn}, - {"scope-commits", c.ScopeCommits, &k.ScopeCommits}, - {"base-pick", c.BasePick, &k.BasePick}, - {"commit-pick", c.CommitPick, &k.CommitPick}, - {"tab-changes", c.TabChanges, &k.TabChanges}, - {"tab-all-files", c.TabAllFiles, &k.TabAllFiles}, - {"tab-pr", c.TabPR, &k.TabPR}, - {"wrap", c.Wrap, &k.Wrap}, - {"rendered", c.Rendered, &k.Rendered}, - {"split", c.Split, &k.Split}, - {"navigator-position", c.NavigatorPosition, &k.NavigatorPosition}, - {"navigator-hide", c.NavigatorHide, &k.NavigatorHide}, - {"navigator-grow", c.NavigatorGrow, &k.NavigatorGrow}, - {"navigator-shrink", c.NavigatorShrink, &k.NavigatorShrink}, - {"select", c.Select, &k.Select}, - {"comment", c.Comment, &k.Comment}, - {"edit", c.Edit, &k.Edit}, - {"delete", c.Delete, &k.Delete}, - {"next-comment", c.NextComment, &k.NextComment}, - {"prev-comment", c.PrevComment, &k.PrevComment}, - {"comments", c.Comments, &k.Comments}, - {"search", c.Search, &k.Search}, - {"find", c.Find, &k.Find}, - {"goto-line", c.GotoLine, &k.GotoLine}, - {"keys", c.Keys, &k.Keys}, - {"send", c.Send, &k.Send}, - {"copy", c.Copy, &k.Copy}, - {"open-pr", c.OpenPR, &k.OpenPR}, - {"refresh", c.Refresh, &k.Refresh}, - {"quit", c.Quit, &k.Quit}, - {"quit-discard", c.QuitDiscard, &k.QuitDiscard}, - {"update", nil, &k.Update}, + var bs []binding + for _, a := range keyActions { + if a.name == "" { + continue + } + var keys []string + if a.setting != nil { + keys = a.setting(&c) + } + bs = append(bs, binding{a.name, keys, a.field(k)}) } + return bs } -// applyConfig sets the keys of each action that c sets. The check of the -// setting accepts each key, so keyName does not fail. The help shows the -// first key of a changed action, as herdr-reviewr spells it. A label of a -// pair, for example "j/k move", shows the first key of each action. +// applyConfig sets the keys of each action that c configures. c must +// already have passed validation. The help for a changed action shows its +// first key, and a shared label such as "j/k move" shows the first key of +// each action in the group. func (k *keyMap) applyConfig(c keybindings) { changed := map[*key.Binding]bool{} for _, b := range k.bindings(c) { @@ -222,7 +510,7 @@ func (k *keyMap) applyConfig(c keybindings) { } keys := make([]string, len(b.keys)) for i, s := range b.keys { - keys[i], _ = keyName(s) + keys[i], _ = keyName(s) // cannot fail: c has passed validation } b.key.SetKeys(keys...) b.key.SetHelp(firstKey(*b.key), b.key.Help().Desc) @@ -240,8 +528,8 @@ func (k *keyMap) applyConfig(c keybindings) { } } -// pairs returns the groups of actions that share one help label, for -// example "j/k move". The label is on the first action of each group. +// pairs returns the groups of actions that share one help label, such as +// "j/k move". The label belongs to the first action of each group. func (k *keyMap) pairs() [][]*key.Binding { return [][]*key.Binding{ {&k.Down, &k.Up}, @@ -255,9 +543,9 @@ func (k *keyMap) pairs() [][]*key.Binding { } } -// namedKeys are the keys that are not one character. Each has its name as -// herdr-reviewr spells it, its bubbletea name, and its label on the -// screen. +// namedKeys are the keys that are not a single character. Each has its +// name in the keybindings setting, its bubbletea name and its on-screen +// label. var namedKeys = []namedKey{ {"left", "left", "←"}, {"right", "right", "→"}, @@ -267,25 +555,25 @@ var namedKeys = []namedKey{ {"pagedown", "pgdown", "PageDown"}, } -// namedKey is a key that is not one character. +// namedKey is a key that is not a single character. type namedKey struct{ name, tea, label string } -// teaKey returns the named key whose bubbletea name is name without its -// modifier, and the modifier. ok is false if no named key has this name. +// teaKey splits the bubbletea key name into its modifier and the named +// key it refers to. ok is false if no named key matches. func teaKey(name string) (mod string, k namedKey, ok bool) { mod, rest := cutModifier(name) - i := slices.IndexFunc(namedKeys, func(k namedKey) bool { return k.tea == rest }) + i := slices.IndexFunc(namedKeys, func(nk namedKey) bool { return nk.tea == rest }) if i < 0 { return mod, namedKey{}, false } return mod, namedKeys[i], true } -// keyName returns the bubbletea name of the key s of the keybindings -// setting. It is the inverse of [herdrReviewrKeyName]. s is one character -// or a named key, alone or after "ctrl+" or "alt+", as in herdr-reviewr. -// The bubbletea names "pgup" and "pgdown" are also valid. ok is false if s -// is not a valid key. +// keyName converts the key s from the keybindings setting to its +// bubbletea name. It is the inverse of [settingKeyName]. s is a +// single character or a named key, optionally after "ctrl+" or "alt+". +// The bubbletea names "pgup" and "pgdown" are also accepted. ok is false +// if s is not a valid key. func keyName(s string) (name string, ok bool) { mod, rest := cutModifier(s) named := func(k namedKey) bool { return rest == k.name || rest == k.tea } @@ -293,25 +581,25 @@ func keyName(s string) (name string, ok bool) { return mod + namedKeys[i].tea, true } r, size := utf8.DecodeRuneInString(rest) - if size == 0 || size != len(rest) || r == utf8.RuneError || unicode.IsSpace(r) || - ansi.StringWidth(rest) == 0 { + oneRune := size > 0 && size == len(rest) && r != utf8.RuneError + if !oneRune || unicode.IsSpace(r) || ansi.StringWidth(rest) == 0 { return "", false } return s, true } -// herdrReviewrKeyName returns the bubbletea key name as herdr-reviewr -// spells it, for example "pageup" for "pgup". It is the inverse of +// settingKeyName converts a bubbletea key name to its name in the +// keybindings setting, such as "pageup" for "pgup". It is the inverse of // [keyName]. -func herdrReviewrKeyName(name string) string { +func settingKeyName(name string) string { if mod, k, ok := teaKey(name); ok { return mod + k.name } return name } -// cutModifier returns the "ctrl+" or "alt+" at the start of s, if any, and -// the rest of s. +// cutModifier splits a leading "ctrl+" or "alt+" from s. mod is "" if s +// has neither. func cutModifier(s string) (mod, rest string) { for _, m := range []string{"ctrl+", "alt+"} { if r, ok := strings.CutPrefix(s, m); ok { @@ -321,7 +609,8 @@ func cutModifier(s string) (mod, rest string) { return "", s } -// keyNames lists the named keys for an error message. +// keyNames returns a comma-separated list of the named keys, for use in +// error messages. func keyNames() string { names := make([]string, len(namedKeys)) for i, k := range namedKeys { @@ -330,37 +619,34 @@ func keyNames() string { return strings.Join(names, ", ") } -// helpKeys implements help.KeyMap for the mode and the tab of the program. +// helpKeys implements [help.KeyMap] for the current mode and tab. type helpKeys struct { - k keyMap - mode mode - tab tab - split bool - visual bool - atFold bool // the cursor is on a fold - armed int // the direction of an armed crossing, or 0 + keys keyMap + mode mode + tab tab + split bool + selecting bool + cursorOnFold bool // the cursor is on a fold + cursorInExpandedFold bool // the collapse key would collapse the expanded fold under the cursor + crossStep int // the direction of a pending jump to another file, or 0 } -// ShortHelp returns the keys of the footer. +// ShortHelp returns the keys shown in the footer. func (h helpKeys) ShortHelp() []key.Binding { - k := h.k + k := h.keys if h.split { k.Split.SetHelp(k.Split.Help().Key, "top to bottom") } switch { case h.mode == modeComment: - return []key.Binding{k.Save, k.Newline, k.Cancel} - case h.armed > 0: - return []key.Binding{ - hint(k.NextHunk, firstKey(k.NextHunk), "next file"), - k.Comment, k.Send, k.Keys, k.Quit, + return []key.Binding{k.SaveComment, k.InsertNewline, k.Cancel} + case h.crossStep != 0: + b, label := k.NextHunk, "next file" + if h.crossStep < 0 { + b, label = k.PrevHunk, "prev file" } - case h.armed < 0: - return []key.Binding{ - hint(k.PrevHunk, firstKey(k.PrevHunk), "prev file"), - k.Comment, k.Send, k.Keys, k.Quit, - } - case h.visual: + return []key.Binding{hint(b, firstKey(b), label), k.Comment, k.Send, k.Keys, k.Quit} + case h.selecting: return []key.Binding{ k.Comment, hint(k.Down, k.Down.Help().Key, "extend"), @@ -368,18 +654,22 @@ func (h helpKeys) ShortHelp() []key.Binding { } case h.tab == tabPR: return []key.Binding{k.Down, k.OpenPR, k.Refresh, k.TabChanges, k.Keys, k.Quit} - case h.atFold: + case h.cursorOnFold || h.cursorInExpandedFold: + b, label := k.Expand, "expand fold" + if !h.cursorOnFold { + b, label = k.Collapse, "collapse fold" + } return []key.Binding{ - hint(k.Expand, firstKey(k.Expand), "expand fold"), + hint(b, firstKey(b), label), k.Comment, k.Send, k.NextHunk, k.Keys, k.Quit, } } return []key.Binding{k.Comment, k.Send, k.NextHunk, k.NextFile, k.Split, k.Keys, k.Quit} } -// FullHelp returns the columns of keys that ? shows. +// FullHelp returns the columns of keys shown by the ? key. func (h helpKeys) FullHelp() [][]key.Binding { - k := h.k + k := h.keys move := []key.Binding{ k.Down, k.HalfDown, k.NextHunk, k.NextFile, k.Focus, k.Collapse, k.GotoLine, } @@ -397,19 +687,23 @@ func (h helpKeys) FullHelp() [][]key.Binding { return [][]key.Binding{move, scopes, comments, view, other} } -// newHelp styles the footer with the terminal palette. +// helpGap separates the keys in the short help. The header uses the same +// gap between scopes. +const helpGap = " " + +// newHelp returns the footer help, styled with the terminal palette. func newHelp() help.Model { h := help.New() h.Styles.ShortKey, h.Styles.FullKey = boldStyle, boldStyle h.Styles.ShortDesc, h.Styles.FullDesc = dimStyle, dimStyle h.Styles.ShortSeparator, h.Styles.FullSeparator = dimStyle, dimStyle h.Styles.Ellipsis = dimStyle - h.ShortSeparator, h.FullSeparator = " ", " " + h.ShortSeparator, h.FullSeparator = helpGap, " " return h } -// firstKey returns the label of the first key of b, as herdr-reviewr -// shows it, or "". +// firstKey returns the on-screen label of the first key of b, or "" if b +// has no keys. func firstKey(b key.Binding) string { ks := b.Keys() if len(ks) == 0 { diff --git a/tui/model.go b/tui/model.go index 8a766b8..c337023 100644 --- a/tui/model.go +++ b/tui/model.go @@ -3,12 +3,18 @@ package tui import ( "cmp" "context" + "errors" "fmt" + "hash/fnv" "image/color" "log/slog" + "maps" + "runtime" "slices" "strconv" "strings" + "sync" + "sync/atomic" "time" "charm.land/bubbles/v2/help" @@ -20,41 +26,42 @@ import ( "github.com/rednafi/rexdiff/diff" "github.com/rednafi/rexdiff/internal/termtext" "github.com/rednafi/rexdiff/internal/turn" + "github.com/rednafi/rexdiff/internal/xctx" "github.com/rednafi/rexdiff/rex" "github.com/rednafi/rexdiff/update" "github.com/rednafi/rexdiff/vcs" ) -// tab is a tab of the program: Changes, Files, or PR. +// tab is one of the program's tabs: Changes, Files or PR. type tab int const ( tabChanges tab = iota - tabFiles + tabAllFiles tabPR ) -// scopeCommits compares the commits that the user picks. It is not a -// value of Config.DefaultScope. +// scopeCommits shows the commits that the user chooses in the commit +// picker. It cannot be used as the default scope. const scopeCommits Scope = "commits" -// scopes are the scopes in the order of the header. +// scopes lists the scopes in the order the header shows them. var scopes = [...]Scope{ScopeUncommitted, ScopeBranch, ScopeLastTurn, scopeCommits} -// label returns the name of the scope in the header. +// label returns the name that the header shows for s. func (s Scope) label() string { return strings.ReplaceAll(string(s), "-", " ") } -// mode tells which part of the screen takes the keys. +// mode says which part of the screen receives key presses. type mode int const ( modeNormal mode = iota modeComment modeOverlay - modeFind // the find band + modeFind // the find bar ) -// String returns the name of the mode, for the log. +// String returns the name of the mode, for logging. func (md mode) String() string { switch md { case modeNormal: @@ -69,307 +76,404 @@ func (md mode) String() string { return "mode(" + strconv.Itoa(int(md)) + ")" } -// pane is one view that scrolls and takes comments. It has the rendered -// lines and the source lines that they show. +// pane is a scrollable view that the user can comment on. It holds the +// rendered lines and the source rows they display. type pane struct { - lines []string - rows []diff.Row // rows[i] is the source line that lines[i] shows - diff *diff.Diff // the render for mouse hits, or nil for Markdown or PR - path string // the file of the rows, or "" for a pane with no comments - src []diff.Row // the source rows of rendered markdown, or nil - cur int // the row of the cursor - top int // the first row on the screen - anchor int // the start of the visual selection, or -1 - xoff int // the sideways scroll, in columns - note string // the text that shows if there are no lines - // free is true after the wheel moves the view. Then the cursor can - // be off the screen until the next key or click. - free bool -} - -// set shows lines, and rows tells which source lines they show. It keeps -// the cursor, the top, and the selection in the new lines. + // lines holds the lines of a pane without a diff. A pane with a diff + // renders each line from diff when it is first needed. Read lines + // through line and count. + lines []string + rows []diff.Row // rows[i] is the source line that line(i) displays + diff *diff.Diff // the rendered diff, which renders the lines and maps clicks; nil for Markdown or PR + path string // the file the rows come from, or "" if the pane takes no comments + srcRows []diff.Row // the source rows behind rendered Markdown, or nil + cur int // the cursor row + top int // the first row on screen + selStart int // the first row of the visual selection, or -1 + scrollX int // the horizontal scroll, in columns + note string // the text shown when there are no lines + renders int // incremented whenever the lines change, so cached find results can tell + // following is true while the view keeps the cursor on screen. The + // mouse wheel sets it to false, which lets the cursor go off screen + // until the next key press or click. + following bool +} + +// set replaces the lines and the source rows they display. It keeps the +// cursor, top row and selection within the new lines. func (p *pane) set(lines []string, rows []diff.Row) { - p.lines, p.rows, p.diff, p.src = lines, rows, nil, nil - p.cur = min(p.cur, max(len(lines)-1, 0)) - p.top = min(p.top, max(len(lines)-1, 0)) - if p.anchor >= len(rows) { // the file is shorter than before - p.anchor = -1 + p.lines, p.rows, p.diff, p.srcRows = lines, rows, nil, nil + p.renders++ + p.clamp() +} + +// clamp moves the cursor, top row and selection back within the lines. +func (p *pane) clamp() { + p.cur = min(p.cur, p.last()) + p.top = min(p.top, p.last()) + if p.selStart >= len(p.rows) { // the file got shorter + p.selStart = -1 + } +} + +// count returns the number of lines in the pane. +func (p *pane) count() int { + if p.diff != nil { + return len(p.diff.Rows) } + return len(p.lines) } -// cont reports whether line i continues the source line of the line above. +// last returns the index of the last line in the pane, or 0 if it has none. +func (p *pane) last() int { return max(p.count()-1, 0) } + +// line returns line i of the pane. With a diff, the line is rendered the +// first time it is needed. +func (p *pane) line(i int) string { + if p.diff != nil { + return p.diff.Line(i) + } + return p.lines[i] +} + +// cont reports whether line i is a continuation of the source line above +// it, as happens with wrapped lines. func (p *pane) cont(i int) bool { return i > 0 && i < len(p.rows) && p.rows[i].Continued } -// head returns the first line of the source line that line i shows. -func (p *pane) head(i int) int { +// lineStart returns the first pane line of the source line shown at line i. +func (p *pane) lineStart(i int) int { for p.cont(i) { i-- } return i } -// end returns the last line of the source line that line i shows. -func (p *pane) end(i int) int { +// lineEnd returns the last pane line of the source line shown at line i. +func (p *pane) lineEnd(i int) int { for p.cont(i + 1) { i++ } return i } -// toStart moves the cursor and the view to the first row. It clears the -// selection and the sideways scroll. -func (p *pane) toStart() { p.cur, p.top, p.anchor, p.xoff = 0, 0, -1, 0 } +// reset moves the cursor and view to the first row, and clears the +// selection and horizontal scroll. +func (p *pane) reset() { p.cur, p.top, p.selStart, p.scrollX = 0, 0, -1, 0 } -// setDiff shows the lines of d. +// setDiff makes the pane display d. func (p *pane) setDiff(d *diff.Diff) { - p.set(d.Lines, d.Rows) - p.diff = d + p.lines, p.rows, p.diff, p.srcRows = nil, d.Rows, d, nil + p.renders++ + p.clamp() } -// side tells which side column x of the screen is on, and if x is on a line -// number. -func (p *pane) side(x int) (s diff.Side, onNumber bool) { +// side returns the diff side at screen column x, and reports whether x is +// in the line number gutter. +func (p *pane) side(x int) (side diff.Side, onGutter bool) { if p.diff == nil { return diff.SideNew, false } return p.diff.SideAt(x) } -// changedFile is a changed file with its line counts and the hash of its -// old content, which names its folds. +// changedFile is a changed file with its added and deleted line counts +// and a hash of its old content. The hash identifies the file's folds. type changedFile struct { vcs.File - adds, dels int - oldHash uint64 + added, deleted int + oldHash uint64 +} + +// stat returns the counts of the added and deleted lines of f, such as +// "+3 -1". For a file that rexdiff does not diff, it says why. +func (f changedFile) stat() string { + switch { + case errors.Is(f.Err, vcs.ErrBinary): + return dimStyle.Render("binary") + case errors.Is(f.Err, vcs.ErrTooBig): + return dimStyle.Render("too large") + } + return addedStyle.Render(fmt.Sprintf("+%d", f.added)) + " " + + deletedStyle.Render(fmt.Sprintf("-%d", f.deleted)) } -// turnState is a turn of the agents in the repository. It keeps a -// snapshot of the working tree from the start of the turn. +// turnState describes an agent turn in the repository, with a snapshot of +// the working tree taken when the turn started. type turnState struct { - app string // the name of the first agent of the turn, for example "pi" - tree string // the snapshot at the start of the turn + app string // the name of the first agent in the turn, for example "pi" + tree string // the snapshot taken when the turn started running bool - at time.Time // the start of the turn + at time.Time // when the turn started } -// dragState is a drag of the left mouse button in the main pane. +// dragState tracks a drag with the left mouse button in the main pane. type dragState struct { - active bool // the button is down - moved bool // the cursor has left the start row - gutter bool // the drag starts on the line numbers - start int // the row where the drag starts - pref diff.Side // the side of the line numbers or the code at the start - // The screen line and the code column of the press and of the pointer. - row, x, toRow, toX int - link string // the URL under the press, or "" -} - -// draft is the comment that the user writes. It is a new comment, or an -// edit of the comment with ID editID. An edit from the comment list goes -// back to the list at row listRow. Else listRow is -1. + active bool // the button is held down + moved bool // the cursor has left the starting row + gutter bool // the drag started in the line number gutter + startRow int // the row where the drag started + prefer diff.Side // the diff side where the drag started + // The screen line and code column where the button was pressed and + // where the pointer is now. + startY, startX, endY, endX int + link string // the URL under the initial press, or "" +} + +// draft is the comment that the user is writing: either a new comment or +// an edit of the comment with ID editID. If the edit started from the +// comment list, itemRow is the list row to return to. Otherwise itemRow is +// -1. type draft struct { diff.Comment editID int - listRow int + itemRow int +} + +// changesTab holds the state of the Changes tab. +type changesTab struct { + files []changedFile + // tooMany is the number of changed files when there are too many to + // show, and 0 otherwise. + tooMany int + cur int // the index of the file being shown + navigator navigator + pane pane +} + +// allFilesTab holds the state of the Files tab. +type allFilesTab struct { + entries []vcs.Entry + fileCount int // how many entries are files, for the list title + more int // how many entries the list leaves out, past maxListedFiles + listing listState + listed bool // a read of the entries has finished, even if it failed + // jump is a jump to a file that waits for the entries to load, or nil. + jump *fileJump + cur int + navigator navigator + pane pane + // shownText and shownPath are the content and path of the file being + // shown, used to detect edits. shownErr is the error from reading it. + shownText, shownPath string + shownErr error } -// prState is the content of the PR tab. -type prState struct { - pr *vcs.PullRequest - err string +// prTab holds the state of the PR tab. +type prTab struct { + pr *vcs.PR + err string + fetch prFetch + pane pane + place *resumePane // the scroll position to restore in pane after a restart + detailsExpanded bool // whether the PR's
blocks are expanded } -// agentState is what the program knows about the agent in a block. +// agentState is what rexdiff knows about the agent in a Rex block. type agentState struct { app string // the name of the agent, for example "pi" - state rex.State // "" if the program sends no status - since time.Time // when Rex got the state + state rex.State // "" if the agent reports no status + since time.Time // when Rex received the state cwd string // the working directory, or "" if Rex does not know it - hasCwd bool // the program has the answer of Rex for cwd - asking bool // a question to Rex for cwd runs + hasCwd bool // Rex has answered the request for cwd + asking bool // a request to Rex for cwd is in progress } -// model is the state of the program. It implements tea.Model. +// model is the state of the program. It implements [tea.Model]. type model struct { - // ctx comes from Run. The methods of tea.Model have fixed signatures, - // so the model keeps ctx for its git and Rex calls. + // ctx comes from Run. The model stores it for git and Rex calls, + // because the tea.Model methods do not take a context. ctx context.Context repo *vcs.Repo rex *rex.Client // nil outside Rex log *slog.Logger - env []string // for the editor, the URL opener, and the clipboard + env []string // the environment for the editor, URL opener and clipboard cfg config keys keyMap - notice string // a problem that the footer shows, or "" + notice string // a problem to show in the footer, or "" - updater *update.Updater // nil if rexdiff cannot update itself - release *update.Release // a newer release, or nil - phase updatePhase - resumePath string // the file of the resume state for the restart, or "" + installer *update.Installer // nil if rexdiff cannot update itself + release *update.Release // a newer release, or nil + updatePhase updatePhase + resumePath string // the file that saves the state across a restart, or "" - // The size of the terminal. Until the terminal tells it, w and h are - // a guess, and sized is false. + // The terminal size. Until the terminal reports it, w and h are a + // guess and sized is false. w, h int sized bool bg, fg color.Color - red color.Color // the ANSI red of the terminal, or nil - green color.Color // the ANSI green of the terminal, or nil + red color.Color // the terminal's ANSI red, or nil + green color.Color // the terminal's ANSI green, or nil ticks int - // watch tells when the files or the refs change, or is nil. If it is - // nil, a tick reloads the current tab every reloadPoll, after polled. - // After a failure of the watcher, a tick starts it again. - watch *vcs.Watcher - polled time.Time - watchRetry retryTimer - // reloadOwed is true if a change is seen but the Changes tab does not - // show it yet. A reload waits until reloadAt, after the pause of the - // last one, and until the retry, after a failure. - reloadOwed bool - filesOwed bool // the same for the list of all files - reloadAt time.Time - reloadRetry retryTimer - loads reloads - // turnChecking is true while a check of the pending turn runs. + // watch reports changes to files and refs, or is nil. While it is + // nil, a tick reloads the current tab once reloadInterval has passed + // since polled. If the watcher fails, a later tick starts it again. + watch *vcs.Watcher + watchStarting bool // a command is starting the watcher + watching bool // a check of watch is in progress + // watchReset makes the next check call [vcs.Watcher.Reset] first. The + // check calls it in its command, because Reset waits for a check in + // progress. + watchReset bool + // watchedDirs and watchedFile are the expanded ignored directories and + // the open file of the Files tab that the last check covered. + watchedDirs []string + watchedFile string + polled time.Time + watchRetry retryTimer + // reloadPending is true if the Changes tab is not yet showing a + // change. The reload waits until reloadAt and, after a failure, until + // the retry. + reloadPending bool + allFilesPending bool // like reloadPending, for the Files tab + reloadAt time.Time + reloadRetry retryTimer + reloads reloadState + changesLoaded bool // the first reload has finished + // held is the input that waits for the tab to load; see holdInput. + held []tea.Msg + // resumeChanges and resumeFiles hold the state of the previous process + // after a restart, until the first reload and the first file list have + // loaded and its places are restored. Otherwise they are nil. + resumeChanges, resumeFiles *resumeState + // turnChecking is true while the pending turn is being checked. turnChecking bool - // turnGen counts the starts and the ends of the turns. A snapshot - // from before the last start or end is stale. + // turnGen counts turn starts and ends. A snapshot taken before the + // latest start or end is stale. turnGen int tab tab scope Scope - // folds has the folds that the user expands in the Changes tab, by path. - folds map[string]*openFolds - base string - // defaultBase is the default branch at the start, for the base picker. + // expandedFolds holds the folds that the user has expanded in the + // Changes tab, keyed by path. + expandedFolds map[string]*expandedFolds + base string + // defaultBase is the default branch found at startup, for the base + // picker. defaultBase string - // baseFlag is true if the base comes from the flag. Then the user - // cannot pick another. - baseFlag bool - from string // the commits scope: the oldest commit that the user picks - to string // the commits scope: the newest commit that the user picks - - // turn is the last turn that changes a file, or nil. The last-turn - // scope compares with it. pending is a turn that starts but changes - // no file yet, or nil. - turn, pending *turnState - // raced is true if the last turn starts too close to a write. Then - // its start snapshot can have the write, and the scope shows no turn. - raced bool - tracker turn.Tracker - // sampled is true after the first list of the agents. An edge before - // it can be wrong, so the tracker gets no states before it. - sampled bool - // After a failed list, a tick lists the agents again, as in - // herdr-reviewr. + // baseFromFlag is true if the base was set by a flag, in which case + // the user cannot choose another. + baseFromFlag bool + oldest string // the oldest commit chosen for the commits scope + newest string // the newest commit chosen for the commits scope + + // turn is the latest turn that changed a file, or nil. The last-turn + // scope compares against it. pendingTurn is a turn that has started + // but not yet changed a file, or nil. + turn, pendingTurn *turnState + // turnRaced is true if the last turn started too close to a file + // write. Its starting snapshot may then include that write, so the + // scope shows no turn. + turnRaced bool + tracker turn.Tracker + // agentsSampled is true once the agents have been listed for the + // first time. State changes seen before then may be wrong, so the + // tracker receives no states until then. + agentsSampled bool + // sampleRetry schedules the next agent listing after a failed one. sampleRetry retryTimer - // agentsGen counts the reconnects to Rex. A list of the agents with - // an older gen is from before a gap. + // agentsGen counts reconnections to Rex. An agent list with an older + // generation was taken before a disconnection. agentsGen int - // agentsHere points to true if an agent works in the repository. It - // is nil if the program does not know. - agentsHere *bool - memberDirs map[string]bool // the working directories of the members - ownsErr string // the last error of owns, which the log has - - changeFiles []changedFile // the Changes tab - changeSel int // the file that shows in the Changes tab - shownTrees string // the two trees of files, to notice a change - shownScope Scope // the scope of changeFiles - changeTree fileListTree - allFiles []vcs.Entry // the Files tab - allSel int - allTree fileListTree - // viewed and viewedPath are the content and the path in the Files - // tab, to notice edits. - viewed string - viewedPath string - readErr string // the last error of the read, which the log has - - changePane, allPane, prPane pane - pr prState - prf prFetch - prPlace *resumePane // prPane's place after a restart - prDetails bool // the PR
blocks are open - - split bool // show the diff side by side - wrap bool - rendered bool // show markdown rendered - listPos position - listHidden bool - focusList bool // the keys j and k move in the file list - // armed is 1 or -1 after a ] or a [ past the last change arms a - // crossing to the file at armedPath. Else it is 0. - armed int - armedPath string - // listW is the width of the file list at a side, in columns, or 0 for - // the default share of the width. - listW int - listPct int // the share of the body rows, above or below - cache diff.Cache // the syntax colours, for a fast sideways scroll + // agentInRepo points to true if an agent is in the repository, and to + // false if none is. It is nil until this is known. + agentInRepo *bool + ownsDir map[string]bool // the answers of owns, by directory + ownsErr string // the last error from owns, already logged + + changes changesTab + shownTrees string // the two trees being compared, used to detect a change + shownScope Scope // the scope that changes.files was loaded for + allFiles allFilesTab + readErr string // the last read error, already logged + pr prTab + + split bool // show the diff side by side + wrap bool + rendered bool // show Markdown rendered + navigatorPosition position + navigatorHidden bool + navigatorFocused bool // j and k move in the file list + // crossStep is 1 or -1 when ] or [ was pressed past the last change + // and the next press in the same direction will jump to the file at + // crossPath. Otherwise it is 0. + crossStep int + crossPath string + // navigatorWidthSet is the width in columns of the file list when it + // is at the left or right, or 0 for the default share of the width. + navigatorWidthSet int + navigatorPct int // the percentage of body rows used by the file list when above or below + cache diff.Cache // the highlighted lines and diff, reused between renders + colorsTicking bool // a colorsMsg is on its way; see [model.awaitColors] comments diff.Comments - editor textarea.Model - draft *draft - mode mode - overlay *overlay - // recent has the files that the user picks in the search in this - // session, the last pick first. + // commentFiles holds the files of the comments made outside the + // Changes tab, by path, so that checking whether a comment is stale + // reads its file again only after the file changes. + commentFiles map[string]fileCopy + commentInput textarea.Model + draft *draft + mode mode + overlay *overlay + // recent holds the files that the user opened from the search in this + // session, most recent first. recent []string - grepCancel context.CancelCauseFunc // stops the code search that runs - grepGen int // the last code search + grepCancel context.CancelFunc // stops the code search in progress + grepGen int // identifies the latest code search + fileGen int // identifies the latest search of file names find findState - findLines splitLines // the lines that find searches - md mdRenderer // the last markdown renderer + findSides sideLines // the lines that find searches + markdown markdownCache // the most recently used Markdown renderer drag dragState - click clickChain + click clickState help help.Model - status string - statusTill time.Time + flashText string + flashUntil time.Time - agents map[string]*agentState // by block - sends int // the steps of the sends that run - lastSent string // the block of the last send - beside string // the agent block beside the pane + agents map[string]*agentState // keyed by block + sends int // the number of send steps in progress + lastSent string // the block that the last review was sent to + beside string // the agent block in the pane beside rexdiff } -// newModel returns the model of the program with the options o and the -// settings cfg. +// newModel returns the program's model for the options o and the settings +// cfg. func newModel(ctx context.Context, o Options, cfg config) model { ta := textarea.New() ta.ShowLineNumbers = false ta.Prompt = "│ " ta.SetHeight(3) - ta.KeyMap.InsertNewline.SetKeys(defaultKeys().Newline.Keys()...) + ta.KeyMap.InsertNewline.SetKeys(defaultKeys().InsertNewline.Keys()...) m := model{ - ctx: ctx, - repo: o.Repo, - rex: o.Rex, - log: o.Log, - env: o.Env, - notice: o.Notice, - updater: o.Updater, - w: 120, - h: 40, - base: o.Base, - baseFlag: o.Base != "", - wrap: o.Wrap, - editor: ta, - help: newHelp(), - agents: map[string]*agentState{}, + ctx: ctx, + repo: o.Repo, + rex: o.Rex, + log: o.Log, + env: o.Env, + notice: o.Notice, + installer: o.Installer, + w: 120, + h: 40, + base: o.Base, + baseFromFlag: o.Base != "", + wrap: o.Wrap, + commentInput: ta, + help: newHelp(), + agents: map[string]*agentState{}, } if m.log == nil { m.log = slog.New(slog.DiscardHandler) } - // As in herdr-reviewr, the flag wins over the saved base, and a saved - // base that does not resolve does not count. + // The flag takes priority over the saved base. A saved base that does + // not resolve is ignored. + ctx, done := m.gitContext("startup") + defer done() if m.base == "" { if b := o.Repo.SavedBase(ctx); b != "" { - if _, err := o.Repo.Resolve(ctx, b); err == nil { + if _, err := o.Repo.ResolveRev(ctx, b); err == nil { m.base = b } } @@ -382,92 +486,109 @@ func newModel(ctx context.Context, o Options, cfg config) model { m.turn = &turnState{tree: tree} } if o.Rex != nil && o.Agent != "" { - // If Rex does not know the agent, the picker selects no agent - // first. The picker still lists all the agents. - ctx, cancel := context.WithCancelCause(ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, rexTimeout, - fmt.Errorf("rex resolve: %v timeout: %w", rexTimeout, context.DeadlineExceeded)) - m.beside, _ = o.Rex.Resolve(ctx, o.Agent) - cancel(fmt.Errorf("rex resolve: done: %w", context.Canceled)) - cancelTimeout() - } - m.changePane.anchor, m.allPane.anchor, m.prPane.anchor = -1, -1, -1 + // If Rex does not know the agent, the picker starts with no agent + // selected, but still lists every agent. + ctx, cancel := xctx.WithTimeout(ctx, rexTimeout, "rex resolve") + m.beside, _ = o.Rex.ResolveBlock(ctx, o.Agent) + cancel() + } + m.changes.pane.selStart, m.allFiles.pane.selStart, m.pr.pane.selStart = -1, -1, -1 + m.followCursor() m.cfg = cfg m.keys = defaultKeys() m.keys.applyConfig(cfg.Keybindings) - m.listPos, m.listW, m.listPct = cfg.FileList, cfg.FileListWidth, listStackPct + m.navigatorPosition, m.navigatorWidthSet, m.navigatorPct = cfg.FileList, cfg.FileListWidth, navigatorStackPct m.rendered = cfg.MarkdownView == markdownViewRendered - // The flags win over the settings. + // Flags take priority over the settings. m.split = cmp.Or(o.Layout, cfg.Layout) == LayoutSplit m.scope = cmp.Or(o.Scope, cfg.DefaultScope) return m } -// These are the messages of the program. +// Messages used by the program. type ( - tickMsg struct{} + tickMsg struct{} + watchedMsg struct { + ch vcs.Change + err error + at time.Time // the tick that started the check + took time.Duration // how long the check took + // The expanded ignored directories and the open file of the Files + // tab that the check covered. + dirs []string + file string + } + + // reloadDueMsg reports that the pause after a reload has ended, so a + // change seen during the pause need not wait for the next tick. + reloadDueMsg struct{} + agentsMsg struct { agents []rex.Agent err error - send bool // true if the list is for a send, not to learn the states - gen int // the agentsGen at the start of the list + send bool // true if the list is for sending a review, not for updating states + gen int // the value of agentsGen when the listing started } sentMsg struct { agent rex.Agent - sent map[int]string // the texts of the sent comments, by ID + sent map[int]string // the text of each sent comment, keyed by ID err error } grepMsg struct { - gen int // the search whose result shows. Other searches are stale. + gen int // identifies the search; results of older searches are stale hits []vcs.Match - more bool // git finds more matches than hits has + more bool // git found more matches than hits holds err error } prMsg struct { - gen int // the read, see prFetch - pr *vcs.PullRequest + gen int // identifies the fetch; see prFetch + pr *vcs.PR err error } eventMsg rex.Event - // splitMsg is the answer of Rex to the split for the editor of path. - splitMsg struct { + // editorPaneMsg is the result of asking Rex to open a split for the + // editor of path. + editorPaneMsg struct { path, script string err error } - // cwdMsg is the answer of Rex for the working directory of an agent. + // cwdMsg is Rex's answer to a request for an agent's working + // directory. cwdMsg struct { block, cwd string err error } - // editorDoneMsg tells that the editor of path in this pane stops. + // editorDoneMsg reports that the editor for path, running in this + // pane, has exited. editorDoneMsg struct { path string err error } - // detachedMsg tells that a detached program, for what, fails. + // detachedMsg reports that a detached program failed. what describes + // the program's purpose. detachedMsg struct { what string err error } - // releaseMsg has a release that is newer than this rexdiff. + // releaseMsg carries a release that is newer than the running rexdiff. releaseMsg struct { release update.Release } - // updatedMsg reports the end of the update to the release. + // updatedMsg reports that the update to the new release has finished. updatedMsg struct { err error } ) // requestColors are the commands that ask the terminal for its colours. -// The replies change the tints of the diff. +// The replies set the colours used for the diff. var requestColors = []tea.Cmd{ tea.RequestBackgroundColor, tea.RequestForegroundColor, requestPalette, } -// tickInterval is the time between two ticks. +// tickInterval is the time between ticks. const tickInterval = time.Second // tick returns a command that sends a tickMsg after d. @@ -475,75 +596,79 @@ func tick(d time.Duration) tea.Cmd { return tea.Tick(d, func(time.Time) tea.Msg { return tickMsg{} }) } -// Init starts the ticks, asks for the colours of the terminal, and loads -// the pull request and the agents. +// Init starts the ticks and the watcher, asks the terminal for its +// colours, and loads the changes, the pull request and the agents. func (m *model) Init() tea.Cmd { - cmds := []tea.Cmd{tick(tickInterval), m.requestPR(true)} - // After a restart, the PR tab can show first. It tells that the read - // runs. + cmds := []tea.Cmd{tick(tickInterval), m.requestPR(true), m.startWatch(), m.startReload(), m.startFiles()} + // After a restart the PR tab may be the first tab shown, so render it + // now to show that loading is in progress. m.renderPR() cmds = append(cmds, requestColors...) if m.rex != nil { - cmds = append(cmds, m.listAgents(agentsMsg{})) + cmds = append(cmds, m.fetchAgents(agentsMsg{})) } - if m.updater != nil { - cmds = append(cmds, m.checkRelease()) + if m.installer != nil { + cmds = append(cmds, m.fetchRelease()) } return tea.Batch(cmds...) } -// flashTime is how long a flash shows in the footer. herdr-reviewr uses -// the same time. -const flashTime = 4 * time.Second +// flashTimeout is how long a flash message stays in the footer. +const flashTimeout = 4 * time.Second -// flash shows s in the footer for a short time. The text can come from Rex -// or from git, so flash quotes it if it has control characters. +// flash shows s in the footer for flashTimeout. It quotes s if it contains +// control characters, because s can come from Rex or git. func (m *model) flash(s string) { - m.status, m.statusTill = termtext.Quote(s), time.Now().Add(flashTime) + m.flashText, m.flashUntil = termtext.Quote(s), time.Now().Add(flashTimeout) } -// reload reads the changes of the scope again, even if the trees do not -// change. -// A reload that runs is then stale, because the view can change. -func (m *model) reload() { - l := &m.loads +// requestReload asks for the changes in the scope to be read again, even +// if the trees have not changed. Any reload in progress becomes stale. The +// watcher did not cause this reload, so its next check resets it. +func (m *model) requestReload() { + l := &m.reloads l.want, l.force, l.stale = true, true, l.running + m.watchReset = true } -// reloadIfChanged reads the changes of the scope again if the trees differ -// from the shown trees. -func (m *model) reloadIfChanged() { m.loads.want = true } +// requestReloadIfChanged asks for the changes in the scope to be read +// again if the trees differ from the ones shown. +func (m *model) requestReloadIfChanged() { m.reloads.want = true } -// reloads follows the reloads of the Changes tab. A reload runs git, so -// it runs in a command, one at a time. A request during a reload starts -// one more reload after it. -type reloads struct { - gen int // the reload whose result shows. Other reloads are stale. - running bool // the reload gen runs +// reloadState tracks reloads of the Changes tab. Each reload runs git in +// a command, one at a time. A request made during a reload starts another +// reload once it finishes. +type reloadState struct { + gen int // identifies the latest reload; results of older reloads are stale + running bool // reload gen is in progress want bool // a reload is due - // The due reload shows the changes, also for equal trees. + // The due reload shows the changes even if the trees are unchanged. force bool - // The view changes while the reload gen runs, so its result is old. + // The view changed while reload gen was running, so its result is + // out of date. stale bool } -// loadNow reads the changes at once, for the first screen. Nothing shows -// yet, so the wait blocks nothing. -func (m *model) loadNow() { - m.loads.gen++ - m.loads.want, m.loads.force = false, false - m.onChanges(m.reloadInput(true).run(m.ctx)) +// gitTimeout limits a short git command that runs on the UI goroutine, +// such as one that resolves a ref. +const gitTimeout = 5 * time.Second + +// gitContext returns a context for a short git command on the UI +// goroutine, which ends after gitTimeout. Call done when the command has +// finished. +func (m *model) gitContext(what string) (ctx context.Context, done func()) { + return xctx.WithTimeout(m.ctx, gitTimeout, what) } -// reloadTimeout is the longest time of a reload. A git that hangs then -// fails the reload, and the next tick tries again. +// reloadTimeout limits how long a reload can take. If git hangs, the +// reload fails and the next tick tries again. const reloadTimeout = time.Minute -// startReload returns a command for the reload that is due, if no reload -// runs. While the comment box is open, the reload waits until the box -// closes, as in herdr-reviewr. +// startReload returns a command for the due reload, or nil if none is due +// or one is already running. While the comment box is open, the reload +// waits until the box closes. func (m *model) startReload() tea.Cmd { - l := &m.loads + l := &m.reloads if !l.want || l.running || m.mode == modeComment { return nil } @@ -554,142 +679,171 @@ func (m *model) startReload() tea.Cmd { return func() tea.Msg { return in.run(ctx) } } -// reloadInput returns what a reload needs, copied from the model, so that -// the reload can run in a command. +// reloadInput copies what a reload needs from the model, so that the +// reload can run in a command. func (m *model) reloadInput(force bool) reloadInput { in := reloadInput{ - repo: m.repo, gen: m.loads.gen, scope: m.scope, - base: m.base, from: m.from, to: m.to, force: force, shown: m.shownTrees, - prev: make(map[string]changedFile, len(m.changeFiles)), + repo: m.repo, gen: m.reloads.gen, scope: m.scope, + base: m.base, oldest: m.oldest, newest: m.newest, force: force, shownTrees: m.shownTrees, + prevFiles: make(map[string]changedFile, len(m.changes.files)), } - for _, c := range m.changeFiles { - in.prev[c.Path] = c + for _, f := range m.changes.files { + in.prevFiles[f.Path] = f } switch { - case m.scope == scopeCommits && m.to == "": + case m.scope == scopeCommits && m.newest == "": in.note = "no commits picked: press " + firstKey(m.keys.CommitPick) + " to pick one or a range" case m.scope == ScopeLastTurn && m.turn == nil: - in.note = m.turnWaitMessage() + in.note = m.turnWaitNote() case m.scope == ScopeLastTurn: in.turnTree = m.turn.tree } return in } -// reloadInput is the input of a reload. note tells why there is nothing -// to compare, or is "". +// reloadInput is the input to a reload. note explains why there is +// nothing to compare, or is "". type reloadInput struct { - repo *vcs.Repo - gen int - scope Scope - base, from, to, note string - turnTree, shown string - force bool - prev map[string]changedFile // the shown files, by path + repo *vcs.Repo + gen int + scope Scope + base, oldest, newest, note string + turnTree, shownTrees string + force bool + prevFiles map[string]changedFile // the files currently shown, keyed by path } // changesMsg is the result of a reload. type changesMsg struct { - gen int - trees string // the two trees, as "from..to" - same bool // the trees are the shown trees, and the reload is not forced - files []changedFile - note string - err error - took time.Duration + gen int + trees string // the two trees, as "from..to" + unchanged bool // the trees match those shown and the reload was not forced + files []changedFile + note string + tooMany int // the number of changed files, if there are too many to show + err error + start time.Time // when the reload started + elapsed time.Duration // how long it took } -// run reads the changes of in. It counts the lines of each file that is -// not in in.prev with the same content. +// run reads the changes described by in. It counts added and deleted +// lines only for files whose content differs from in.prevFiles. func (in reloadInput) run(ctx context.Context) changesMsg { - start := time.Now() - ctx, cancel := context.WithCancelCause(ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, reloadTimeout, - fmt.Errorf("reload: %v timeout: %w", reloadTimeout, context.DeadlineExceeded)) - defer cancelTimeout() - defer cancel(fmt.Errorf("reload: done: %w", context.Canceled)) - msg := changesMsg{gen: in.gen, note: in.note} + msg := changesMsg{gen: in.gen, note: in.note, start: time.Now()} + ctx, cancel := xctx.WithTimeout(ctx, reloadTimeout, "reload") + defer cancel() if msg.note == "" { - from, to, err := in.trees(ctx) - msg.trees, msg.err = from+".."+to, err - msg.same = err == nil && msg.trees == in.shown && !in.force - if err == nil && !msg.same { - msg.files, msg.err = in.changedFiles(ctx, from, to) + oldTree, newTree, err := in.trees(ctx) + msg.trees, msg.err = oldTree+".."+newTree, err + msg.unchanged = err == nil && msg.trees == in.shownTrees && !in.force + if err == nil && !msg.unchanged { + msg.files, msg.err = in.changes(ctx, oldTree, newTree) + } + if e, ok := errors.AsType[*vcs.TooManyFilesError](msg.err); ok { + msg.note, msg.tooMany, msg.err = fmt.Sprintf("%d files changed — too many to show", e.Count), e.Count, nil } } - msg.took = time.Since(start) + msg.elapsed = time.Since(msg.start) return msg } -// trees returns the IDs of the two trees that the Changes tab compares in -// the scope. -func (in reloadInput) trees(ctx context.Context) (from, to string, err error) { +// trees returns the IDs of the two trees that the Changes tab compares +// for the scope. +func (in reloadInput) trees(ctx context.Context) (oldTree, newTree string, err error) { switch in.scope { case scopeCommits: - if from, err = in.repo.ParentTree(ctx, in.from); err != nil { + if oldTree, err = in.repo.ParentTree(ctx, in.oldest); err != nil { return "", "", err } - to, err = in.repo.Tree(ctx, in.to) - return from, to, err + newTree, err = in.repo.Tree(ctx, in.newest) + return oldTree, newTree, err case ScopeLastTurn: - from = in.turnTree + oldTree = in.turnTree case ScopeBranch: - from, err = in.repo.MergeBaseTree(ctx, in.base) + oldTree, err = in.repo.MergeBaseTree(ctx, in.base) default: - from, err = in.repo.HeadTree(ctx) + oldTree, err = in.repo.HeadTree(ctx) } if err != nil { return "", "", err } - to, err = in.repo.Snapshot(ctx) - return from, to, err + newTree, err = in.repo.SnapshotTree(ctx) + return oldTree, newTree, err } -// changedFiles returns the changes from the tree from to the tree to, -// with their line counts. -func (in reloadInput) changedFiles(ctx context.Context, from, to string) ([]changedFile, error) { - files, err := in.repo.Diff(ctx, from, to) +// changes returns the changed files between oldTree and newTree, with +// their line counts. +func (in reloadInput) changes(ctx context.Context, oldTree, newTree string) ([]changedFile, error) { + files, err := in.repo.Diff(ctx, oldTree, newTree) if err != nil { return nil, err } out := make([]changedFile, len(files)) + var count []int // indexes of the files whose lines need counting for i, f := range files { c := &out[i] - if p, ok := in.prev[f.Path]; ok && p.Old == f.Old && p.New == f.New { - *c = p // the same change, so the counts stay - } else if f.Skip == nil { - c.adds, c.dels = diff.Count(f.Old, f.New) - c.oldHash = hash(f.Old) + if p, ok := in.prevFiles[f.Path]; ok && p.OldText == f.OldText && p.NewText == f.NewText { + *c = p // unchanged, so reuse the previous counts + } else if f.Err == nil { + count = append(count, i) } c.File = f } + // Count in parallel on all cores. Each goroutine writes only to its + // own elements of out, so no locking is needed. + var next atomic.Int64 + var wg sync.WaitGroup + for range min(runtime.GOMAXPROCS(0), len(count)) { + wg.Go(func() { + for k := int(next.Add(1)) - 1; k < len(count); k = int(next.Add(1)) - 1 { + c := &out[count[k]] + c.added, c.deleted = diff.CountLines(c.OldText, c.NewText) + c.oldHash = textHash(c.OldText) + } + }) + } + wg.Wait() return out, nil } -// onChanges shows the result of a reload. After a failure, the next tick -// tries again after a wait. As in herdr-reviewr, a failure keeps the files -// of the scope that show, and the error shows in their place only if no -// files show. A change of the view during the reload starts one more. +// textHash returns the 64-bit FNV-1a hash of s. +func textHash(s string) uint64 { + h := fnv.New64a() + _, _ = h.Write([]byte(s)) // writing to a hash.Hash never fails + return h.Sum64() +} + +// onChanges shows the result of a reload. After a failure, a later tick +// tries again after a delay. A failure keeps the files that are shown, and +// the error replaces them only if no files are shown. If the view changed +// during the reload, another reload starts. func (m *model) onChanges(msg changesMsg) { - l := &m.loads + l := &m.reloads if msg.gen != l.gen { return } l.running = false if l.stale || m.mode == modeComment { - // As in herdr-reviewr, the view does not change under the comment - // box. The reload runs again later. + // Do not change the view while the comment box is open. The reload + // runs again later. l.want, l.stale = true, false return } - m.reloadAt = time.Now().Add(reloadPauseFactor * msg.took) + // The first load reads files and objects that are not yet cached, so + // its duration says nothing about later reloads. After a failure, the + // retry delay applies instead. + m.reloadAt = time.Time{} + if m.changesLoaded && msg.err == nil { + m.reloadAt = msg.start.Add(min(reloadPauseFactor*msg.elapsed, maxReloadPause)) + } + m.changesLoaded = true m.retry(msg.err) note, err := msg.note, msg.err - if err == nil && note == "" && msg.same { + if err == nil && note == "" && msg.unchanged { return } - if err != nil && len(m.changeFiles) > 0 && m.shownScope == m.scope { + if err != nil && len(m.changes.files) > 0 && m.shownScope == m.scope { m.flash("refresh failed: " + err.Error()) return } @@ -698,112 +852,275 @@ func (m *model) onChanges(msg changesMsg) { } m.shownScope = m.scope if note != "" { - m.changePane.note, m.changeFiles, m.shownTrees = note, nil, "" - m.buildTrees() + // A note for too many files comes with the trees, so that the next + // reload of the same trees does nothing. + m.changes.pane.note, m.changes.files, m.shownTrees = note, nil, "" + m.changes.tooMany = msg.tooMany + if err == nil { + m.shownTrees = msg.trees + } + m.buildBothNavigators() m.renderChanges() return } - var selPath string - if f := m.selectedChange(); f != nil { - selPath = f.Path + var curPath string + if f := m.curChangedFile(); f != nil { + curPath = f.Path } - m.changeFiles = msg.files - m.shownTrees, m.changePane.note = msg.trees, "" - m.changeSel = slices.IndexFunc(m.changeFiles, func(c changedFile) bool { return c.Path == selPath }) - m.buildTrees() - if f := m.selectedChange(); f == nil || f.Path != selPath { - // Another file shows, so the cursor and the selection do not apply. - m.changePane.cur, m.changePane.top, m.changePane.anchor = 0, 0, -1 + m.changes.files, m.changes.tooMany = msg.files, 0 + m.shownTrees, m.changes.pane.note = msg.trees, "" + m.changes.cur = slices.IndexFunc(m.changes.files, func(f changedFile) bool { return f.Path == curPath }) + m.buildBothNavigators() + if f := m.curChangedFile(); f == nil || f.Path != curPath { + // A different file is now shown, so reset the cursor and the + // selection, but keep the horizontal scroll. + m.changes.pane.cur, m.changes.pane.top, m.changes.pane.selStart = 0, 0, -1 } - if len(m.changeFiles) == 0 { - m.changePane.note = "no changes" + if len(m.changes.files) == 0 { + m.changes.pane.note = "no changes" } m.renderChanges() } -// loadFiles reads the list of all files if the model does not have it. -func (m *model) loadFiles() { - if m.allFiles == nil { - m.readFiles() +// ensureAllFiles starts reading the entries of the Files tab if they +// have not been read yet. +func (m *model) ensureAllFiles() { + if m.allFiles.entries == nil { + m.allFiles.listing.want = true } } -// reloadFiles reads the list of all files again. -func (m *model) reloadFiles() { - m.filesOwed = false - m.readFiles() - m.refreshViewer() +// reloadAllFiles reads the shown file of the Files tab again, and starts +// reading the entries again. +func (m *model) reloadAllFiles() { + m.allFilesPending = false + m.allFiles.listing.want = true + m.reloadShownFile() } -// readFiles reads the list of all files. The selected file stays. On an -// error, it keeps the old list and shows the error. -func (m *model) readFiles() { - files, err := m.repo.Files(m.ctx) - if err != nil { - m.flash("refresh failed: " + err.Error()) - return +// listState tracks the reads of the entries of the Files tab. Each read +// runs git in a command, one at a time. A request made during a read +// starts another read once it finishes. +type listState struct { + gen int // identifies the latest read; results of older reads are stale + running bool // read gen is in progress + want bool // a read is due +} + +// maxListedFiles is the most entries that the Files tab lists. Building +// the tree of the file list takes time in proportion to the entries. +const maxListedFiles = 200_000 + +// filesMsg is the result of reading the entries of the Files tab. more +// counts the entries left out past maxListedFiles. +type filesMsg struct { + gen int + entries []vcs.Entry + more int + err error +} + +// startFiles returns a command for the due read of the entries of the +// Files tab, or nil if none is due or one is already running. While the +// comment box is open, the read waits until the box closes. +func (m *model) startFiles() tea.Cmd { + l := &m.allFiles.listing + if !l.want || l.running || m.mode == modeComment { + return nil } - var sel string - if m.allSel < len(m.allFiles) { - sel = m.allFiles[m.allSel].Path + l.gen++ + l.running, l.want = true, false + repo, ctx, gen := m.repo, m.ctx, l.gen + expanded := maps.Clone(m.allFiles.navigator.toggled) + return func() tea.Msg { + ctx, cancel := xctx.WithTimeout(ctx, reloadTimeout, "file list") + defer cancel() + es, err := repo.Files(ctx) + if err != nil { + return filesMsg{gen: gen, err: err} + } + es = withIgnored(repo, es, expanded) + more := max(len(es)-maxListedFiles, 0) + return filesMsg{gen: gen, entries: es[:len(es)-more], more: more} } - m.allFiles = m.withIgnored(files) - m.allSel = slices.IndexFunc(m.allFiles, func(e vcs.Entry) bool { return e.Path == sel }) - m.buildFileTree() } -// buildTrees makes the rows of the file lists after a change of the -// Changes tab. The Files tab marks the directories with a change. -func (m *model) buildTrees() { - m.buildChangeTree() - if m.allFiles != nil { - m.buildFileTree() +// fileJump is a jump to a line of a file in the Files tab. +type fileJump struct { + path string + side diff.Side + line int +} + +// onFiles shows the entries read for the Files tab, keeping the current +// file selected. On error, it keeps the old list and shows the error. It +// then does the jump that waited for the entries, if any. It returns the +// command that searches the new entries if the search is open. +func (m *model) onFiles(msg filesMsg) tea.Cmd { + l, af := &m.allFiles.listing, &m.allFiles + if msg.gen != l.gen { + return nil + } + l.running, af.listed = false, true + jump := af.jump + af.jump = nil + if msg.err != nil { + m.flash("file list failed: " + msg.err.Error()) + return nil + } + var cur string + if af.cur < len(af.entries) { + cur = af.entries[af.cur].Path + } + s := m.resumeFiles + if s != nil { + cur = s.FilePath + af.navigator.cur = -1 // put the list cursor on the current file + } + af.entries, af.more = msg.entries, msg.more + if af.entries == nil { + af.entries = []vcs.Entry{} // read, though empty + } + af.cur = slices.IndexFunc(af.entries, func(e vcs.Entry) bool { return e.Path == cur }) + m.buildAllFilesNavigator() + m.reloadShownFile() + if s != nil { + m.resumeFiles = nil + af.pane.restore(s.Viewer) + } + if jump != nil { + m.jumpToFile(jump.path, jump.side, jump.line) + } + if ov := m.overlay; ov != nil && ov.kind == overlaySearch { + ov.search.paths = searchPaths(af.entries) + cmd := m.findFiles() + ov.setSearchItems() + return cmd + } + return nil +} + +// buildBothNavigators rebuilds the rows of both file lists after the +// Changes tab changes, because the Files tab marks directories that +// contain changes. +func (m *model) buildBothNavigators() { + m.buildChangesNavigator() + if m.allFiles.entries != nil { + m.buildAllFilesNavigator() } } -// Update handles a message. After an update, Update restarts the program -// when no work can get lost. +// Update handles a message. After a self-update, it restarts the program +// once no work can be lost. func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmd := m.handle(msg) - if m.phase != phaseUpdated { - return m, cmd + var cmd tea.Cmd + if m.holdInput(msg) { + m.held = append(m.held, msg) + } else { + cmd = m.handleMsg(msg) + } + // Start any reload that handling the messages requested. + cmd = tea.Batch(cmd, m.releaseInput(), m.startReload(), m.startFiles()) + // The colours only draw the screen again, so they are not pending work. + colors := m.awaitColors() + if m.updatePhase != updateDone { + return m, tea.Batch(cmd, colors) + } + // The new process runs ticks and watcher checks itself, so they do + // not count as pending work. + hasPending := cmd != nil + switch msg.(type) { + case tickMsg, watchedMsg, reloadDueMsg: + hasPending = false + } + if m.restartIfUpdated(hasPending) { + return m, tea.Quit } - // The work of a tick runs again in the new process, so it is not - // pending work. - pending := cmd - if _, ok := msg.(tickMsg); ok { - pending = nil + return m, tea.Batch(cmd, colors) +} + +// waiting reports whether the shown tab is still loading the data that +// key presses act on: the first changes, or the first list of the Files +// tab. +func (m *model) waiting() bool { + switch m.tab { + case tabChanges: + return !m.changesLoaded + case tabAllFiles: + return !m.allFiles.listed } - if m.restartIfReady(pending) { - return m, tea.Quit + return false +} + +// holdInput reports whether Update must hold the input msg until the tab +// stops [model.waiting], as if the program were busy loading. Input is +// then handled in the order it came. A key that quits or switches tabs +// does not wait if no other input is held, so the user can leave a tab +// that loads slowly. +func (m *model) holdInput(msg tea.Msg) bool { + switch msg.(type) { + case tea.KeyPressMsg, tea.PasteMsg, tea.MouseClickMsg, tea.MouseMotionMsg, + tea.MouseReleaseMsg, tea.MouseWheelMsg: + default: + return false + } + if len(m.held) > 0 { + return true + } + if !m.waiting() { + return false } - return m, cmd + k, ok := msg.(tea.KeyPressMsg) + return !ok || m.mode != modeNormal || !key.Matches(k, m.keys.Quit, m.keys.QuitDiscard, + m.keys.TabChanges, m.keys.TabAllFiles, m.keys.TabPR) } -// handle does the work of Update. Update then checks for a restart. -func (m *model) handle(msg tea.Msg) tea.Cmd { - cmd := m.handleMsg(msg) - return tea.Batch(cmd, m.startReload()) +// releaseInput handles the held input, in order, until a tab is waiting +// again. +func (m *model) releaseInput() tea.Cmd { + var cmds []tea.Cmd + for len(m.held) > 0 && !m.waiting() { + msg := m.held[0] + m.held = m.held[1:] + cmds = append(cmds, m.handleMsg(msg)) + } + return tea.Batch(cmds...) } -// handleMsg handles msg. A reload that it asks for starts after it. +// handleMsg does the work of Update for msg. func (m *model) handleMsg(msg tea.Msg) tea.Cmd { var cmd tea.Cmd switch msg := msg.(type) { case changesMsg: m.onChanges(msg) + m.restoreChanges() + if wait := time.Until(m.reloadAt); wait > 0 { + cmd = tea.Tick(wait, func(time.Time) tea.Msg { return reloadDueMsg{} }) + } + case reloadDueMsg: + m.reloadIfDue() + case filesMsg: + cmd = m.onFiles(msg) + case fileSearchMsg: + m.onFileSearch(msg) + case watchStartedMsg: + m.onWatchStarted(msg) case turnStartMsg: cmd = m.onTurnStart(msg) case turnCheckMsg: cmd = m.onTurnCheck(msg) case tea.WindowSizeMsg: - m.click = clickChain{} + m.click = clickState{} m.w, m.h, m.sized = msg.Width, msg.Height, true m.rerender() case tea.FocusMsg: - m.reloadIfChanged() + m.requestReloadIfChanged() case tickMsg: cmd = tea.Batch(tick(tickInterval), m.onTick()) + case colorsMsg: + m.colorsTicking = false // Update asks for the next one + case watchedMsg: + cmd = m.onWatched(msg) case tea.BackgroundColorMsg: if !sameColor(m.bg, msg.Color) { m.bg = msg.Color @@ -828,6 +1145,8 @@ func (m *model) handleMsg(msg tea.Msg) tea.Cmd { m.onGrep(msg) case branchesMsg: m.onBranches(msg) + case commitsMsg: + m.onCommits(msg) case releaseMsg: m.release = &msg.release case updatedMsg: @@ -838,44 +1157,48 @@ func (m *model) handleMsg(msg tea.Msg) tea.Cmd { m.flash(msg.what + " failed: " + msg.err.Error()) case cwdMsg: cmd = m.onCwd(msg) - case splitMsg: - cmd = m.onSplit(msg) + case editorPaneMsg: + cmd = m.onEditorPane(msg) case tea.MouseClickMsg: + // Find the clicked row on the current screen first, then make the + // view follow the cursor. + cmd = m.onClick(msg.Mouse()) m.followCursor() - return m.onClick(msg.Mouse()) + m.scrollToCursor() + return cmd case tea.MouseMotionMsg: m.onDrag(msg.Mouse()) case tea.MouseReleaseMsg: - cmd = m.onMouseUp(msg.Mouse()) // the comment box can open below the screen + cmd = m.onMouseUp(msg.Mouse()) // may open the comment box, which can be off screen case tea.MouseWheelMsg: m.onWheel(msg.Mouse()) case tea.PasteMsg: cmd = m.onPaste(msg.Content) case tea.KeyPressMsg: - m.click = clickChain{} + m.click = clickState{} m.followCursor() return m.onKey(msg) } - m.ensureVisible() + m.scrollToCursor() return cmd } -// followCursor makes the panes keep the cursor on the screen again. A key -// or a click can move the cursor. +// followCursor makes every pane and file list keep its cursor on screen +// again. func (m *model) followCursor() { - m.changePane.free, m.allPane.free, m.prPane.free = false, false, false - m.changeTree.free, m.allTree.free = false, false + m.changes.pane.following, m.allFiles.pane.following, m.pr.pane.following = true, true, true + m.changes.navigator.following, m.allFiles.navigator.following = true, true } -// onKey gives a key to the part of the screen that takes the keys. +// onKey passes a key press to the part of the screen that has focus. func (m *model) onKey(msg tea.KeyPressMsg) tea.Cmd { - m.log.Debug("key", "key", msg.String(), "mode", m.mode, "scope", m.scope) - // As in herdr-reviewr, a key other than ], [, or esc drops an armed - // crossing. The keys of the prompt before a quit do not. - inQuit := m.mode == modeOverlay && m.overlay != nil && m.overlay.kind == overlayQuit + m.log.Debug("key", "key", m.loggedKey(msg), "mode", m.mode, "scope", m.scope) + // Any key other than ], [ or Esc cancels a pending jump to another + // file, except keys pressed in the quit prompt. + inQuit := m.overlay != nil && m.overlay.kind == overlayQuit step := key.Matches(msg, m.keys.NextHunk, m.keys.PrevHunk) if !inQuit && !step && msg.Code != tea.KeyEscape { - m.armed = 0 + m.crossStep = 0 } switch m.mode { case modeComment: @@ -888,16 +1211,36 @@ func (m *model) onKey(msg tea.KeyPressMsg) tea.Cmd { return m.updateNormal(msg) } -// onPaste adds text to the comment box or to the input of the overlay. +// loggedKey returns the name of the key press msg for the debug log. While +// the user types into the comment box or another text input, the log must +// not hold what they type, so it names only keys that type no text, such +// as enter, and gives "text" for the others. +func (m *model) loggedKey(msg tea.KeyPressMsg) string { + typing := m.mode == modeComment || m.mode == modeFind || + m.mode == modeOverlay && m.overlay != nil && m.overlay.hasInput() + if typing && msg.Text != "" { + return "text" + } + return msg.String() +} + +// onPaste inserts pasted text into the comment box, the overlay input or +// the find bar. Line breaks become \n; the one-line inputs turn them into +// spaces. func (m *model) onPaste(text string) tea.Cmd { + text = unixLines(text) switch { case m.mode == modeComment: - m.editor.InsertString(text) - case m.mode == modeOverlay && m.overlay != nil && m.overlay.kind == overlayGoto: + // The text area scrolls its view to the cursor only in Update. + var cmd tea.Cmd + m.commentInput, cmd = m.commentInput.Update(tea.PasteMsg{Content: text}) + m.scrollToCursor() + return cmd + case m.overlay != nil && m.overlay.kind == overlayGotoLine: if n, ok := pastedLine(text); ok { m.overlay.input.SetValue(n) } - case m.mode == modeOverlay && m.overlay != nil && m.overlay.hasInput(): + case m.overlay != nil && m.overlay.hasInput(): m.overlay.input.SetValue(m.overlay.input.Value() + text) return m.inputChanged() case m.mode == modeFind: @@ -906,171 +1249,251 @@ func (m *model) onPaste(text string) tea.Cmd { return nil } -// colorTicks is the interval of the requests for the colours of the -// terminal, in ticks of one second. +// unixLines replaces each \r\n and each lone \r in s with \n. Terminals +// often paste \r\n, which the inputs would otherwise read as two line +// breaks. +func unixLines(s string) string { + return strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(s) +} + +// colorTicks is how often, in one-second ticks, rexdiff asks the terminal +// for its colours. const colorTicks = 3 -// The waits of a reload, as in herdr-reviewr. +// Delays between reloads. const ( - // reloadPauseFactor sets the pause after a reload on a tick. The - // next reload waits this many times as long as the last one took. - // The pause of the watcher in vcs has the same value, but it is a - // different pause. Thus each package has its own constant. + // reloadPauseFactor sets the pause between reloads: the next reload + // starts this many times as long as the last one took after the start + // of the last one, but at most maxReloadPause after it, so reloading + // takes at most 1/reloadPauseFactor of the time. The watcher in vcs + // has its own pause. reloadPauseFactor = 20 - retryFirst = time.Second // the first wait after a failure - retryMax = time.Minute // the longest wait after a failure + maxReloadPause = 30 * time.Second + minRetryDelay = time.Second // the first delay after a failure + maxRetryDelay = time.Minute // the longest delay after a failure ) -// reloadPoll is the time between two reloads without a watcher, as in -// herdr-reviewr. -const reloadPoll = 5 * time.Second +// reloadInterval is the time between reloads when there is no watcher. +const reloadInterval = 5 * time.Second -// retryTimer tells when to try again after a failure. Each failure -// doubles the wait, from retryFirst to retryMax. +// retryTimer schedules the next attempt after a failure. Each failure +// doubles the delay, from minRetryDelay up to maxRetryDelay. type retryTimer struct { - at time.Time // the next try, or zero - wait time.Duration // the wait after the next failure, or 0 + at time.Time // the time of the next attempt, or zero + wait time.Duration // the delay to use after the next failure, or 0 } -// fail sets the next try after a failure at now. +// fail records a failure at now and schedules the next attempt. func (r *retryTimer) fail(now time.Time) { - w := max(r.wait, retryFirst) - r.at, r.wait = now.Add(w), min(2*w, retryMax) + w := max(r.wait, minRetryDelay) + r.at, r.wait = now.Add(w), min(2*w, maxRetryDelay) } -// due reports whether a try waits and its time is at or before now. +// due reports whether an attempt is scheduled at or before now. func (r *retryTimer) due(now time.Time) bool { return !r.at.IsZero() && !now.Before(r.at) } -// retry sets the wait before the next reload after the error err. Each -// failure doubles the wait, from 1 s to 60 s. A reload with no error -// clears the wait. +// retry schedules the next reload after the failure err, with a delay +// that doubles on each failure. A nil err clears the delay. func (m *model) retry(err error) { if err == nil { m.reloadRetry = retryTimer{} return } m.reloadRetry.fail(time.Now()) - m.reloadOwed = true + m.reloadPending = true } -// dropWatch drops the watcher after the error err. A tick starts it again -// after a wait. -func (m *model) dropWatch(err error) { +// stopWatch logs err and drops the watcher. A later tick starts it again +// after a delay. +func (m *model) stopWatch(err error) { m.log.Warn("watch failed", "err", err) m.watch = nil m.watchRetry.fail(time.Now()) } -// startWatch starts the watcher of the repository. -func (m *model) startWatch() error { - w, err := m.repo.Watch(m.ctx) - if err != nil { - return err +// watchStartedMsg carries a new watcher, or the error from starting it. +type watchStartedMsg struct { + w *vcs.Watcher + err error +} + +// startWatch returns a command that starts watching the repository for +// changes. Starting lists the files, which can take seconds in a large +// repository. +func (m *model) startWatch() tea.Cmd { + m.watchStarting = true + repo, ctx := m.repo, m.ctx + return func() tea.Msg { + ctx, cancel := xctx.WithTimeout(ctx, reloadTimeout, "watch start") + defer cancel() + w, err := repo.Watch(ctx) + return watchStartedMsg{w: w, err: err} } - m.watch, m.watchRetry.at = w, time.Time{} - return nil } -// watchChanges tells what the watcher sees. Without a watcher, it tells -// that both change every reloadPoll. After an error, it drops the watcher -// and tells that both change. -func (m *model) watchChanges() vcs.Change { - all := vcs.Change{Files: true, Refs: true} - now := time.Now() - if m.watch == nil && m.watchRetry.due(now) { - if err := m.startWatch(); err != nil { - m.dropWatch(err) - } +// onWatchStarted keeps the new watcher. After an error, a later tick +// tries again. +func (m *model) onWatchStarted(msg watchStartedMsg) { + m.watchStarting = false + if msg.err != nil { + m.stopWatch(msg.err) + return } - if m.watch == nil { - if now.Sub(m.polled) < reloadPoll { - return vcs.Change{} - } + m.watch, m.watchRetry.at = msg.w, time.Time{} +} + +// watchChanges reports the changes seen on a tick. With a watcher, it +// returns a command that checks the watcher, and onWatched handles the +// result. Only one check runs at a time, and a tick during a check sees no +// change. Without a watcher, it reports that both files and refs changed +// once every reloadInterval. While the watcher starts, it reports no +// change: the first check of the new watcher reports both kinds. +func (m *model) watchChanges() (vcs.Change, tea.Cmd) { + now := time.Now() + switch { + case m.watchStarting: + return vcs.Change{}, nil + case m.watch == nil && m.watchRetry.due(now): + return vcs.Change{}, m.startWatch() + case m.watch == nil && now.Sub(m.polled) < reloadInterval: + return vcs.Change{}, nil + case m.watch == nil: m.polled = now - return all + return vcs.Change{Files: true, Refs: true}, nil + case m.watching: + return vcs.Change{}, nil + } + m.watching = true + w, ctx, reset := m.watch, m.ctx, m.watchReset + m.watchReset = false + dirs, file := m.includedPaths() + paths := dirs + if file != "" { + paths = append(slices.Clip(dirs), file) + } + return vcs.Change{}, func() tea.Msg { + ctx, cancel := xctx.WithTimeout(ctx, reloadTimeout, "watch") + defer cancel() + w.Include(paths) + if reset { + w.Reset() + } + ch, err := w.Changed(ctx) + return watchedMsg{ch: ch, err: err, at: now, took: time.Since(now), dirs: dirs, file: file} } - m.watch.Show(m.shownPaths()) - ch, err := m.watch.Changed(m.ctx) - m.log.Debug("watch", "took", time.Since(now), "files", ch.Files, "refs", ch.Refs) - if err != nil { - m.dropWatch(err) - m.polled = now - return all +} + +// onWatched handles the result of a watcher check. After an error, it +// drops the watcher and treats both files and refs as changed. +func (m *model) onWatched(msg watchedMsg) tea.Cmd { + m.watching = false + m.log.Debug("watch", "took", msg.took, "files", msg.ch.Files, "refs", msg.ch.Refs) + if msg.err != nil { + m.stopWatch(msg.err) + m.polled = msg.at + return m.onChange(vcs.Change{Files: true, Refs: true}) } m.watchRetry = retryTimer{} - return ch + // The watcher misses writes made to a path before Include added it, so + // when the watched paths change, the Files tab rereads what the new + // paths cover: just the shown file if only the shown file is new, + // otherwise the file list as well. The reload waits while another tab + // is shown or the comment box is open. + sameDirs := slices.Equal(msg.dirs, m.watchedDirs) + if !sameDirs || msg.file != m.watchedFile { + switch { + case m.tab != tabAllFiles || m.mode == modeComment: + m.allFilesPending = true + case sameDirs: + m.reloadShownFile() + default: + m.reloadAllFiles() + } + } + m.watchedDirs, m.watchedFile = msg.dirs, msg.file + return m.onChange(msg.ch) } -// onTick reloads the current tab if the watcher sees a change. It -// returns the work of the tick. Update adds the next tick. +// onTick checks for changes and does the periodic work. Update schedules +// the next tick. func (m *model) onTick() tea.Cmd { m.ticks++ - ch := m.watchChanges() - var cmds []tea.Cmd - if ch.Files && m.pending != nil { - cmds = append(cmds, m.checkTurnLater(false)) + ch, check := m.watchChanges() + if check == nil { + check = m.onChange(ch) } - m.reloadOwed = m.reloadOwed || ch.Files || ch.Refs - m.filesOwed = m.filesOwed || ch.Files + cmds := []tea.Cmd{check} now := time.Now() - m.reloadDue() - if m.status != "" && now.After(m.statusTill) { - m.status = "" - } - // Without a watcher, each tick reports a change of the refs. Then only - // the poll reads the pull request. - if ch.Refs && m.watch != nil { - cmds = append(cmds, m.refsChanged()) + if m.flashText != "" && now.After(m.flashUntil) { + m.flashText = "" } - if m.rex != nil && !m.sampled && m.sampleRetry.due(now) { - m.sampleRetry.at = time.Time{} // one list at a time - cmds = append(cmds, m.listAgents(agentsMsg{})) + if m.rex != nil && !m.agentsSampled && m.sampleRetry.due(now) { + m.sampleRetry.at = time.Time{} // only one listing at a time + cmds = append(cmds, m.fetchAgents(agentsMsg{})) } - if m.tab == tabPR && time.Since(m.prf.polled) >= prPoll { + if m.tab == tabPR && time.Since(m.pr.fetch.polled) >= prInterval { cmds = append(cmds, m.requestPR(false)) } - if m.ticks%colorTicks == 0 { // follow the changes of the terminal theme + if m.ticks%colorTicks == 0 { // pick up changes to the terminal theme cmds = append(cmds, requestColors...) } return tea.Batch(cmds...) } -// reloadDue reloads the current tab if a change waits for it. On the -// Changes tab, a reload waits for the pause after the last one, and for -// the wait after a failure. As in herdr-reviewr, the view does not change -// while the comment box is open. -func (m *model) reloadDue() { +// onChange reloads the current tab if ch reports a change or one is +// pending. Each tick calls it once. +func (m *model) onChange(ch vcs.Change) tea.Cmd { + var cmds []tea.Cmd + if ch.Files && m.pendingTurn != nil { + cmds = append(cmds, m.scheduleTurnCheck(false)) + } + m.reloadPending = m.reloadPending || ch.Files || ch.Refs + m.allFilesPending = m.allFilesPending || ch.Files + m.reloadIfDue() + // Without a watcher, every tick reports a ref change, so leave the + // pull request to its own polling. + if ch.Refs && m.watch != nil { + cmds = append(cmds, m.reloadPR()) + } + return tea.Batch(cmds...) +} + +// reloadIfDue reloads the current tab if a change is pending. On the +// Changes tab, the reload also waits for the pause after the last reload +// and for any retry delay. Nothing is reloaded while the comment box is +// open. +func (m *model) reloadIfDue() { if m.mode == modeComment { return } now := time.Now() switch { - case m.tab == tabChanges && m.reloadOwed && !m.loads.running && + case m.tab == tabChanges && m.reloadPending && !m.reloads.running && !now.Before(m.reloadAt) && !now.Before(m.reloadRetry.at): - m.reloadOwed = false - m.reloadIfChanged() // onChanges sets reloadAt after the pause - case m.tab == tabFiles && m.filesOwed: - m.reloadFiles() // reloadFiles renders the file too - case m.tab == tabFiles && m.watch == nil: - m.refreshViewer() // without a watcher, each tick reads the file + m.reloadPending = false + m.requestReloadIfChanged() // onChanges sets the next reloadAt + case m.tab == tabAllFiles && m.allFilesPending: + m.reloadAllFiles() // also renders the shown file + case m.tab == tabAllFiles && m.watch == nil: + m.reloadShownFile() // without a watcher, every tick reads the file again } } -// cur returns the pane of the current tab. -func (m *model) cur() *pane { +// curPane returns the pane of the current tab. +func (m *model) curPane() *pane { switch m.tab { - case tabFiles: - return &m.allPane + case tabAllFiles: + return &m.allFiles.pane case tabPR: - return &m.prPane + return &m.pr.pane } - return &m.changePane + return &m.changes.pane } -// sameColor reports whether a and b are the same colour. Nil is the same -// only as nil. +// sameColor reports whether a and b are the same colour, ignoring alpha. +// A nil colour equals only another nil. func sameColor(a, b color.Color) bool { if a == nil || b == nil { return a == b diff --git a/tui/mouse.go b/tui/mouse.go index ea2d547..e7edad5 100644 --- a/tui/mouse.go +++ b/tui/mouse.go @@ -1,10 +1,5 @@ package tui -// The mouse works as in herdr-reviewr. Click or drag on the line numbers to -// comment on a line or a range. Drag on the code to copy it. Double-click -// to copy a word, and triple-click to copy a line. Click a file or a tab to -// open it. Double-click a file to copy its path. Click a link to open it. - import ( "strings" "time" @@ -19,17 +14,21 @@ import ( "github.com/rednafi/rexdiff/diff" ) -// onClick handles a press of a mouse button. +// onClick handles a mouse button press. Only the left button does +// anything. A click or drag in the gutter comments on lines, a drag over +// code copies it, and a double or triple click copies a word or a line. +// A click opens a file, tab or link, and a double click copies a file's +// path. func (m *model) onClick(mouse tea.Mouse) tea.Cmd { if mouse.Button != tea.MouseLeft { return nil } - m.armed = 0 // as a key does - if m.mode == modeOverlay && m.overlay != nil { + m.crossStep = 0 // a click cancels a pending file jump, as a key press does + if m.overlay != nil { return m.clickOverlay(mouse) } if m.mode == modeComment { - // A click does nothing while the comment box is open. + // Clicks are ignored while the comment box is open. return nil } if mouse.Y == 0 { @@ -41,31 +40,32 @@ func (m *model) onClick(mouse tea.Mouse) tea.Cmd { } return nil } - if y := mouse.Y - bodyY; y > m.bodyHeight() { // the footer, under the rule + if y := mouse.Y - bodyY; y >= m.shownBodyHeight() { // the footer return m.startUpdate() } - // The window of the next click starts after the work of this one, for - // example a copy. A slow copy then does not break a triple click. + // Time the next click from the end of this one, so that a slow copy + // does not break a triple click. defer func() { m.click.at = time.Now() }() - if m.inList(mouse.X, mouse.Y) { - if y := mouse.Y - m.listY(); y >= 1 { - r := m.listStart(m.listRows()) + y - 1 - if m.countClick(mouse) >= 2 { - if path := m.rowPath(r); path != "" { - m.copyChars(path) - return nil - } - } - m.clickRow(r) + if m.inNavigator(mouse.X, mouse.Y) { + y := mouse.Y - m.navigatorY() + if y < 1 { // the title + return nil } + r := m.navigatorStart(m.navigatorRows()) + y - 1 + double := m.countClick(mouse) >= 2 + if path := m.navigatorRowPath(r); double && path != "" { + m.copyWithCount(path) + return nil + } + m.clickNavigatorRow(r) return nil } return m.clickMain(mouse) } -// clickMain handles a press of the left button on the main pane. A click -// moves the cursor and starts a drag. A double or a triple click copies -// the text. +// clickMain handles a left button press in the main pane. A single click +// moves the cursor and starts a drag. A double or triple click copies +// text. func (m *model) clickMain(mouse tea.Mouse) tea.Cmd { h := m.mainHeight() y := mouse.Y - m.mainY() @@ -76,43 +76,42 @@ func (m *model) clickMain(mouse tea.Mouse) tea.Cmd { if y >= len(rowAt) || rowAt[y] < 0 { return nil } - p := m.cur() + p := m.curPane() x := mouse.X - m.mainX() - gutter if n := m.countClick(mouse); n >= 2 { if text := m.clickText(rowAt[y], x, n); text != "" { - m.copyChars(text) + m.copyWithCount(text) return nil } } - m.focusList = false + m.navigatorFocused = false link := "" - if p.diff == nil { // a markdown view or the PR tab - if url := linkAt(p.lines[rowAt[y]], x); isWebURL(url) { + if p.diff == nil { // rendered Markdown or the PR tab + if url := linkAt(p.line(rowAt[y]), x); isWebURL(url) { link = url } } - p.cur, p.anchor = p.head(rowAt[y]), -1 - if m.atFold() { + p.cur, p.selStart = p.lineStart(rowAt[y]), -1 + if m.cursorOnFold() { m.expandFold(p.rows[p.cur].Fold) return nil } - side, onNumber := p.side(x) + side, onGutter := p.side(x) m.drag = dragState{ - active: true, - start: p.cur, - gutter: onNumber, - pref: side, - row: rowAt[y], - x: x, - toRow: rowAt[y], - toX: x, - link: link, + active: true, + startRow: p.cur, + gutter: onGutter, + prefer: side, + startY: rowAt[y], + startX: x, + endY: rowAt[y], + endX: x, + link: link, } return nil } // linkAt returns the URL of the hyperlink at column x of line, or "". -// The markdown renderer puts a hyperlink on each link. func linkAt(line string, x int) string { w := ansi.StringWidth(line) if x < 0 || x >= w { @@ -123,20 +122,20 @@ func linkAt(line string, x int) string { return buf.CellAt(x, 0).Link.URL } -// inList reports whether the screen cell at column x and line y is on -// the file list. -func (m *model) inList(x, y int) bool { - lw, ly := m.listWidth(), m.listY() - return lw > 0 && x >= m.listX() && x < m.listX()+lw && y >= ly && y < ly+m.listRows() +// inNavigator reports whether the screen cell at column x and line y is +// inside the file list. +func (m *model) inNavigator(x, y int) bool { + lw, ly := m.navigatorWidth(), m.navigatorY() + return lw > 0 && x >= m.navigatorX() && x < m.navigatorX()+lw && y >= ly && y < ly+m.navigatorRows() } -// onDrag moves the cursor with a drag. A drag past the top or the bottom -// scrolls. +// onDrag moves the cursor to follow a drag. Dragging above or below the +// pane scrolls it. func (m *model) onDrag(mouse tea.Mouse) { if !m.drag.active || mouse.Button != tea.MouseLeft { return } - p := m.cur() + p := m.curPane() h := m.mainHeight() y := mouse.Y - m.mainY() switch { @@ -144,8 +143,8 @@ func (m *model) onDrag(mouse tea.Mouse) { p.top = max(p.top-1, 0) p.cur = p.top case y >= h: - p.top = min(p.top+1, max(len(p.lines)-1, 0)) - p.cur = max(min(p.top+h-1, len(p.lines)-1), 0) + p.top = min(p.top+1, p.last()) + p.cur = max(min(p.top+h-1, p.count()-1), 0) default: _, rowAt := m.mainLines(h, nil) for yy := min(y, len(rowAt)-1); yy >= 0; yy-- { @@ -155,41 +154,47 @@ func (m *model) onDrag(mouse tea.Mouse) { } } } - m.drag.toRow, m.drag.toX = p.cur, mouse.X-m.mainX()-gutter - p.cur = p.head(p.cur) // the drag moves by source lines - m.drag.moved = p.cur != m.drag.start - if m.drag.moved || m.drag.toX != m.drag.x { - m.click = clickChain{} // a drag is not a click + m.drag.endY, m.drag.endX = p.cur, mouse.X-m.mainX()-gutter + p.cur = p.lineStart(p.cur) // the cursor moves by whole source lines + m.drag.moved = p.cur != m.drag.startRow + if m.drag.moved || m.drag.endX != m.drag.startX { + m.click = clickState{} // a drag does not count as a click } } -// clickWindow is the longest time between the clicks of a double or a -// triple click, as in herdr-reviewr. -const clickWindow = 400 * time.Millisecond +// clickInterval is the longest time allowed between the clicks of a +// double or triple click. +const clickInterval = 400 * time.Millisecond + +// maxClicks is the most presses that a click sequence counts. It makes a +// triple click. +const maxClicks = 3 -// clickChain is the last press of the left button on text. -type clickChain struct { +// clickState records the last left button press on text. +type clickState struct { at time.Time x, y int - n int // the number of presses in the chain, up to 3 + n int // the number of presses in the sequence, up to maxClicks } -// countClick adds a press to the chain and returns the count. A press on -// another cell, or after [clickWindow], starts a new chain. The time of -// the chain is the end of the last press. +// countClick adds a press to the current click sequence and returns the +// number of presses in it. A press on a different cell, or after +// [clickInterval], starts a new sequence. The interval is measured from +// the end of the previous press. func (m *model) countClick(mouse tea.Mouse) int { c, now := m.click, time.Now() n := 1 - if c.n > 0 && c.x == mouse.X && c.y == mouse.Y && now.Sub(c.at) < clickWindow { - n = min(c.n+1, 3) + if c.n > 0 && c.x == mouse.X && c.y == mouse.Y && now.Sub(c.at) < clickInterval { + n = min(c.n+1, maxClicks) } - m.click = clickChain{at: now, x: mouse.X, y: mouse.Y, n: n} + m.click = clickState{at: now, x: mouse.X, y: mouse.Y, n: n} return n } -// rowPath returns the path of row r of the file list, or "". -func (m *model) rowPath(r int) string { - t := m.list() +// navigatorRowPath returns the file or directory path of row r in the +// file list, or "". +func (m *model) navigatorRowPath(r int) string { + t := m.curNavigator() if t == nil || r < 0 || r >= len(t.rows) { return "" } @@ -199,13 +204,13 @@ func (m *model) rowPath(r int) string { return t.rows[r].Dir } -// clickText returns the text that a click of count n copies at column x -// of line i of the main pane. A double click gives the word under x, and -// a triple click gives the source line. It returns "" off a word or on an -// empty line. +// clickText returns the text that n clicks copy at column x of line i in +// the main pane. A double click copies the word under x and a triple click +// copies the source line. It returns "" if x is not on a word or the line +// is empty. func (m *model) clickText(i, x, n int) string { line, char, ok := m.textAt(i, x) - if !ok || n >= 3 { + if !ok || n >= maxClicks { return line } runes := []rune(line) @@ -223,16 +228,16 @@ func (m *model) clickText(i, x, n int) string { return string(runes[s:e]) } -// textAt returns the source line that column x of line i of the main pane -// shows, and the index of the rune under x. A pane with no source shows -// its text on the screen. +// textAt returns the source line shown at column x of line i in the main +// pane, and the index of the rune under x. For a pane without a diff, +// line is the text as shown on screen. func (m *model) textAt(i, x int) (line string, char int, ok bool) { - p := m.cur() - if i < 0 || i >= len(p.lines) { + p := m.curPane() + if i < 0 || i >= p.count() { return "", 0, false } if p.diff == nil { - line = strings.TrimRight(ansi.Strip(p.lines[i]), " ") + line = strings.TrimRight(ansi.Strip(p.line(i)), " ") c := 0 for _, r := range line { if c += ansi.StringWidth(string(r)); x < c { @@ -242,75 +247,74 @@ func (m *model) textAt(i, x int) (line string, char int, ok bool) { } return line, char, true } - s, n, char, ok := p.diff.Cell(i, x) + side, n, char, ok := p.diff.Cell(i, x) if !ok { return "", 0, false } - if lines := diff.Lines(m.contentFor(p.path, s)); n <= len(lines) { + if lines := diff.SplitLines(m.contentFor(p.path, side)); n <= len(lines) { return lines[n-1], char, true } return "", 0, false } -// copyChars copies text and then shows the count of its characters, as -// herdr-reviewr does. -func (m *model) copyChars(text string) { +// copyWithCount copies text to the clipboard and shows how many +// characters were copied. It does nothing if text is empty. +func (m *model) copyWithCount(text string) { if text != "" { m.copyText(text, "copied "+plural(utf8.RuneCountInString(text), "char"), nil) } } -// onMouseUp ends a drag at mouse. As in herdr-reviewr, a release on the -// cell of the press opens the link there. A drag on the line numbers -// opens the comment box. A drag on the code copies the text from the -// press to the pointer. +// onMouseUp ends a drag at mouse. Releasing on the cell that was pressed +// opens the link there. A drag in the line number gutter opens the comment +// box. A drag over code copies the text from the press to the pointer. func (m *model) onMouseUp(mouse tea.Mouse) tea.Cmd { d := m.drag m.drag.active = false if d.active && d.link != "" && m.click.x == mouse.X && m.click.y == mouse.Y { - return m.browse(d.link, "link") + return m.openURL(d.link, "link") } - p := m.cur() - // A reload during the drag can make the pane shorter than the start. - if !d.active || d.start >= len(p.rows) { + p := m.curPane() + // A reload during the drag can leave the pane shorter than the + // starting row. + if !d.active || d.startRow >= len(p.rows) { return nil } - if d.gutter { // a drag on the line numbers opens the comment box + if d.gutter { if d.moved { - p.anchor = d.start + p.selStart = d.startRow } - return m.startComment(d.pref) + return m.openComment(d.prefer) } - m.copyChars(m.dragText(d)) + m.copyWithCount(m.dragText(d)) return nil } -// dragText returns the text of drag d on the code, as herdr-reviewr -// copies it. The text starts at the rune under the press and ends at the -// rune under the pointer, both included. A drag that ends on its start -// rune is a click, and gives "". +// dragText returns the code text covered by drag d, from the rune under +// the press to the rune under the pointer, inclusive. A drag that ends on +// the rune where it started counts as a click and returns "". func (m *model) dragText(d dragState) string { - p := m.cur() - a, b := d.row, d.toRow // rendered markdown gives the text on the screen - if p.src == nil { - a, b = p.head(a), p.head(b) + p := m.curPane() + a, b := d.startY, d.endY // rendered Markdown copies the text as shown on screen + if p.srcRows == nil { + a, b = p.lineStart(a), p.lineStart(b) } - _, ca, _ := m.textAt(d.row, d.x) - _, cb, _ := m.textAt(d.toRow, d.toX) + _, ca, _ := m.textAt(d.startY, d.startX) + _, cb, _ := m.textAt(d.endY, d.endX) if a > b || a == b && ca > cb { a, b, ca, cb = b, a, cb, ca } - // A reload during the drag can make the pane shorter than the drag. - if a == b && ca == cb || b >= len(p.lines) { + // A reload during the drag can leave the pane shorter than the drag. + if a == b && ca == cb || b >= p.count() { return "" } var lines []string - if p.src != nil { - for _, l := range p.lines[a : b+1] { + if p.srcRows != nil { + for _, l := range p.lines[a : b+1] { // rendered Markdown keeps its lines in p.lines lines = append(lines, strings.TrimRight(ansi.Strip(l), " ")) } } else { - lines = m.sourceLines(a, p.end(b), d.pref) + lines = m.sourceLines(a, p.lineEnd(b), d.prefer) } if len(lines) == 0 { return "" @@ -322,41 +326,41 @@ func (m *model) dragText(d dragState) string { return strings.Join(lines, "\n") } -// sourceLines returns the source lines of rows lo to hi of the current -// pane. It picks the side of each row as [model.target] does with pref. A -// row with no source line gives its text on the screen. -func (m *model) sourceLines(lo, hi int, pref diff.Side) []string { - p := m.cur() +// sourceLines returns the source lines for rows lo to hi of the current +// pane. It chooses the side of each row as [model.targetAt] does with +// prefer. For a row with no source line, it uses the text shown on +// screen. +func (m *model) sourceLines(lo, hi int, prefer diff.Side) []string { + p := m.curPane() var out []string - var src [2][]string // the lines of each side, read once + var src [2][]string // the lines of each side, split on first use for i := lo; i <= hi; i++ { if p.cont(i) { continue } - t := m.target(i, pref) - if t == nil { - if i < len(p.lines) { - out = append(out, strings.TrimRight(ansi.Strip(p.lines[i]), " ")) + target := m.targetAt(i, prefer) + if target == nil { + if i < p.count() { + out = append(out, strings.TrimRight(ansi.Strip(p.line(i)), " ")) } continue } - if src[t.side] == nil { - src[t.side] = diff.Lines(m.contentFor(p.path, t.side)) + if src[target.side] == nil { + src[target.side] = diff.SplitLines(m.contentFor(p.path, target.side)) } - if lines := src[t.side]; t.line-1 < len(lines) { - out = append(out, lines[t.line-1]) + if lines := src[target.side]; target.line-1 < len(lines) { + out = append(out, lines[target.line-1]) } } return out } -// wheelStep is the number of rows that one step of the wheel scrolls. +// wheelStep is the number of rows scrolled by one step of the mouse wheel. const wheelStep = 3 -// onWheel scrolls the overlay, the file list, or the main pane. As in -// herdr-reviewr, the wheel moves the view of the file list, of the main -// pane, and of the preview of the search, and not the cursor. In the other -// parts of an overlay, it moves the cursor. +// onWheel scrolls the overlay, the file list or the main pane. In the file +// list, the main pane and the search preview, the wheel scrolls the view +// without moving the cursor. Elsewhere in an overlay, it moves the cursor. func (m *model) onWheel(mouse tea.Mouse) { var d int switch mouse.Button { @@ -367,7 +371,7 @@ func (m *model) onWheel(mouse tea.Mouse) { default: return } - if ov := m.overlay; m.mode == modeOverlay && ov != nil { + if ov := m.overlay; ov != nil { _, _, y, _ := m.overlayBox() if pv := ov.search.preview; pv != nil && mouse.Y >= y+ov.search.previewY { pv.scroll(wheelStep * d) @@ -376,23 +380,24 @@ func (m *model) onWheel(mouse tea.Mouse) { ov.move(d) return } - if t := m.list(); t != nil && m.inList(mouse.X, mouse.Y) { - h := m.listRows() - top := m.listStart(h) + wheelStep*d - t.top, t.free = min(max(top, 0), max(len(t.rows)-(h-1), 0)), true + if t := m.curNavigator(); t != nil && m.inNavigator(mouse.X, mouse.Y) { + h := m.navigatorRows() - 1 // the first row is the title + t.top = clampTop(t.start(h)+wheelStep*d, len(t.rows), max(h, 1)) + t.following = false return } - p := m.cur() + p := m.curPane() h := m.mainHeight() - p.top = min(max(p.top+wheelStep*d, 0), max(len(p.lines)-h, 0)) - // The wheel moves the view and not the cursor. - p.free = true + p.top = clampTop(p.top+wheelStep*d, p.count(), h) + // Let the cursor go off screen until the next key press or click. + p.following = false } // clickOverlay handles a click while an overlay is open. A click outside -// the box closes the overlay. The prompt before a quit does not close. +// the box closes the overlay, unless it is the quit prompt. Clicking the +// selected item chooses it. func (m *model) clickOverlay(mouse tea.Mouse) tea.Cmd { - box, x, y, listY := m.overlayBox() + box, x, y, itemsY := m.overlayBox() w, h := lipgloss.Width(box), lipgloss.Height(box) inBox := mouse.X >= x && mouse.X < x+w && mouse.Y >= y && mouse.Y < y+h if !inBox { @@ -401,34 +406,33 @@ func (m *model) clickOverlay(mouse tea.Mouse) tea.Cmd { } return nil } - idx := mouse.Y - y - listY - if idx < m.overlay.rowsFrom || idx >= min(len(m.overlay.shown), m.overlay.rowsTo) || - !m.overlay.pickable(idx) { + idx := mouse.Y - y - itemsY + onItem := idx >= m.overlay.itemsLo && idx < min(len(m.overlay.shown), m.overlay.itemsHi) + if !onItem || !m.overlay.canHaveCursor(idx) { return nil } - if idx == m.overlay.cur { // a click on the selected row picks it - return m.chooseOverlay() + if idx == m.overlay.cur { + return m.choose() } m.overlay.cur = idx return nil } -// clickHeader does action a of a click on the header. +// clickHeader runs the action a of the clicked header item. func (m *model) clickHeader(a headerAction) tea.Cmd { switch { case a >= headerTabChanges && a <= headerTabPR: m.setTab(tab(a - headerTabChanges)) case a >= headerScope0 && a < headerBase: s := scopes[a-headerScope0] - if s == scopeCommits && m.to == "" { - m.openCommitPicker() - } else { - m.setScope(s) + if s == scopeCommits && m.newest == "" { + return m.openCommitPicker() } + m.setScope(s) case a == headerBase: return m.openBasePicker() case a == headerCommits: - m.openCommitPicker() + return m.openCommitPicker() case a == headerAgent: return m.startSend() } diff --git a/tui/nav.go b/tui/nav.go index b29f82b..f88f305 100644 --- a/tui/nav.go +++ b/tui/nav.go @@ -1,323 +1,498 @@ package tui import ( - "hash/fnv" "math" "slices" "strconv" "strings" + "time" - "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "github.com/rednafi/rexdiff/diff" "github.com/rednafi/rexdiff/vcs" ) -// These are the steps of the keys that move or resize. +// Step sizes for the keys that scroll or resize. const ( - scrollStep = 8 // the sideways scroll, in columns - // listStep resizes the file list. It is in columns at a side, and in - // percent of the body rows above or below. - listStep = 4 + scrollStep = 8 // the horizontal scroll step, in columns + // navigatorWidthStep is the resize step for the file list: columns + // when it is at the left or right, and a percentage of the body rows + // when it is above or below. + navigatorWidthStep = 4 ) -// updateNormal handles a key when no box is open. As in herdr-reviewr, -// the key of QuitDiscard does nothing here. It only answers the prompt -// before a quit. +// updateNormal handles a key press when no box is open. It runs the +// handler of the key's action and then, unless the action keeps the view, +// scrolls the view to the cursor. func (m *model) updateNormal(msg tea.KeyPressMsg) tea.Cmd { - k := m.keys - var cmd tea.Cmd - switch { - case key.Matches(msg, k.Quit): - if m.comments.Len() > 0 { - m.openConfirmQuit() - return nil - } - return tea.Quit - case key.Matches(msg, k.Cancel): - m.escape() - case key.Matches(msg, k.Keys): - m.help.ShowAll = !m.help.ShowAll - default: - cmd = m.actionKey(msg) + a := m.keys.action(msg) + if a == nil { + m.scrollToCursor() + return nil + } + cmd := a.handle(m) + if !a.keepView { + m.scrollToCursor() } - m.ensureVisible() return cmd } -// escape drops one layer with each press, as in herdr-reviewr: the -// selection, then the armed crossing, then the list of all keys. -func (m *model) escape() { - if p := m.cur(); m.tab != tabPR { - switch { - case p.anchor >= 0: - p.anchor = -1 - return - case m.armed != 0: - m.armed = 0 - return - } +// colorsTick is how often the screen is drawn again while the shown file +// is being highlighted, so that lines drawn without colours get them +// soon. It is about one frame. +const colorsTick = 16 * time.Millisecond + +// colorsMsg asks for the screen to be drawn again, because lines that +// were drawn without colours may have them now. +type colorsMsg struct{} + +// awaitColors returns a command that sends colorsMsg after colorsTick if +// the last file shown with syntax highlighting is still being +// highlighted. It returns nil if it is not, or if a colorsMsg is already +// on its way. Update calls it after every message. +func (m *model) awaitColors() tea.Cmd { + if m.colorsTicking || !m.cache.Highlighting() { + return nil } - m.help.ShowAll = false + m.colorsTicking = true + return tea.Tick(colorsTick, func(time.Time) tea.Msg { return colorsMsg{} }) } -// actionKey gives a key to the handlers of the actions, by topic. The -// first handler that takes the key wins, as in one switch. -func (m *model) actionKey(msg tea.KeyPressMsg) tea.Cmd { - handlers := []func(tea.KeyPressMsg) (tea.Cmd, bool){ - m.scopeKey, - m.moveKey, - m.commentKey, - m.viewKey, +// onQuit quits, asking first if there are comments. +func (m *model) onQuit() tea.Cmd { + if m.comments.Len() > 0 { + m.openQuit() + return nil } - for _, handle := range handlers { - if cmd, ok := handle(msg); ok { - return cmd + return tea.Quit +} + +// onCancel undoes one thing per press: first the selection, then a +// pending jump to another file, then the full key list. +func (m *model) onCancel() tea.Cmd { + if p := m.curPane(); m.tab != tabPR { + switch { + case p.selStart >= 0: + p.selStart = -1 + return nil + case m.crossStep != 0: + m.crossStep = 0 + return nil } } + m.help.ShowAll = false return nil } -// scopeKey handles a key that changes the tab or the scope. It reports -// whether it takes the key. As in herdr-reviewr, the keys of the scopes -// and of the pickers do nothing on the PR tab. -func (m *model) scopeKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { - k := m.keys - onPR := m.tab == tabPR && key.Matches(msg, k.ScopeUncommitted, k.ScopeBranch, - k.ScopeLastTurn, k.ScopeCommits, k.BasePick, k.CommitPick) - switch { - case onPR: - case key.Matches(msg, k.TabChanges): - m.setTab(tabChanges) - case key.Matches(msg, k.TabAllFiles): - m.setTab(tabFiles) - case key.Matches(msg, k.TabPR): - m.setTab(tabPR) - case key.Matches(msg, k.ScopeUncommitted): +// onKeys shows or hides the full key list. +func (m *model) onKeys() tea.Cmd { + m.help.ShowAll = !m.help.ShowAll + return nil +} + +// onTabChanges shows the Changes tab. +func (m *model) onTabChanges() tea.Cmd { + m.setTab(tabChanges) + return nil +} + +// onTabAllFiles shows the Files tab. +func (m *model) onTabAllFiles() tea.Cmd { + m.setTab(tabAllFiles) + return nil +} + +// onTabPR shows the PR tab. +func (m *model) onTabPR() tea.Cmd { + m.setTab(tabPR) + return nil +} + +// The scope and picker keys do nothing on the PR tab. + +// onScopeUncommitted shows the uncommitted changes. +func (m *model) onScopeUncommitted() tea.Cmd { + if m.tab != tabPR { m.setScope(ScopeUncommitted) - case key.Matches(msg, k.ScopeBranch): + } + return nil +} + +// onScopeBranch shows the changes on the branch. +func (m *model) onScopeBranch() tea.Cmd { + if m.tab != tabPR { m.setScope(ScopeBranch) - case key.Matches(msg, k.ScopeLastTurn): + } + return nil +} + +// onScopeLastTurn shows the changes made in the last agent turn. +func (m *model) onScopeLastTurn() tea.Cmd { + if m.tab != tabPR { m.setScope(ScopeLastTurn) - case key.Matches(msg, k.ScopeCommits) && m.to == "": - m.openCommitPicker() - case key.Matches(msg, k.ScopeCommits): - m.setScope(scopeCommits) - case key.Matches(msg, k.BasePick): - return m.openBasePicker(), true - case key.Matches(msg, k.CommitPick): - m.openCommitPicker() - default: - return nil, false } - return nil, true + return nil } -// moveKey handles a key that moves the cursor, the file selection, or -// the sideways scroll. The wrap is here because it ends the sideways -// scroll. moveKey reports whether it takes the key. -func (m *model) moveKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { - k, p := m.keys, m.cur() - last := max(len(p.lines)-1, 0) - inList := m.focusList && m.tab != tabPR +// onScopeCommits shows the chosen commits. If no commits have been +// chosen, it opens the commit picker. +func (m *model) onScopeCommits() tea.Cmd { switch { - case key.Matches(msg, k.Focus) && m.listHiddenHere(): - // As in herdr-reviewr, tab shows a hidden list with the focus. - m.listHidden, m.focusList = false, true + case m.tab == tabPR: + case m.newest == "": + return m.openCommitPicker() + default: + m.setScope(scopeCommits) + } + return nil +} + +// onBasePick opens the base picker. +func (m *model) onBasePick() tea.Cmd { + if m.tab == tabPR { + return nil + } + return m.openBasePicker() +} + +// onCommitPick opens the commit picker. +func (m *model) onCommitPick() tea.Cmd { return m.openCommitPicker() } + +// onFocus moves focus between the file list and the main pane. If the +// file list is hidden, it shows it and gives it focus. +func (m *model) onFocus() tea.Cmd { + if m.navigatorHidden && m.tab != tabPR { + m.navigatorHidden, m.navigatorFocused = false, true m.rerender() - case key.Matches(msg, k.Focus): - m.focusList = !m.focusList - case key.Matches(msg, k.Down) && inList: - m.stepRow(1) - case key.Matches(msg, k.Down): - p.cur = min(p.end(p.cur)+1, last) - case key.Matches(msg, k.Up) && inList: - m.stepRow(-1) - case key.Matches(msg, k.Up): - p.cur = max(p.cur-1, 0) - case key.Matches(msg, k.PageDown): - p.cur = min(p.cur+m.mainHeight(), last) - case key.Matches(msg, k.PageUp): - p.cur = max(p.cur-m.mainHeight(), 0) - case key.Matches(msg, k.HalfDown): - p.cur = min(p.cur+m.mainHeight()/2, last) - case key.Matches(msg, k.HalfUp): - p.cur = max(p.cur-m.mainHeight()/2, 0) - case key.Matches(msg, k.NextHunk): - m.stepChange(1) - case key.Matches(msg, k.PrevHunk): - m.stepChange(-1) - case key.Matches(msg, k.NextFile): - m.stepFile(1) - case key.Matches(msg, k.PrevFile): - m.stepFile(-1) - case key.Matches(msg, k.Expand) && m.atDir(): - m.setDir(true) - case key.Matches(msg, k.Collapse) && m.atDir(): - m.setDir(false) - case key.Matches(msg, k.Expand) && m.atFold(): + return nil + } + m.navigatorFocused = !m.navigatorFocused + return nil +} + +// onDown moves the cursor down one line, or down one row in the file list. +func (m *model) onDown() tea.Cmd { + if m.navigatorFocused && m.tab != tabPR { + m.stepNavigatorRow(1) + } else { + m.moveCur(1) + } + return nil +} + +// onUp moves the cursor up one line, or up one row in the file list. +func (m *model) onUp() tea.Cmd { + if m.navigatorFocused && m.tab != tabPR { + m.stepNavigatorRow(-1) + } else { + m.moveCur(-1) + } + return nil +} + +// onPageDown moves the cursor down one screen. +func (m *model) onPageDown() tea.Cmd { return m.moveCur(m.mainHeight()) } + +// onPageUp moves the cursor up one screen. +func (m *model) onPageUp() tea.Cmd { return m.moveCur(-m.mainHeight()) } + +// onHalfDown moves the cursor down half a screen. +func (m *model) onHalfDown() tea.Cmd { return m.moveCur(m.mainHeight() / 2) } + +// onHalfUp moves the cursor up half a screen. +func (m *model) onHalfUp() tea.Cmd { return m.moveCur(-m.mainHeight() / 2) } + +// moveCur moves the cursor of the current pane by d rows, stopping at the +// first and last rows. A move down always leaves the cursor line. The +// cursor stays on the first row of a wrapped line, so a line taller than +// the screen scrolls instead: the view moves through its rows until it +// shows the end of the line going down, or its start going up. Moving up +// onto such a line shows its end. +func (m *model) moveCur(d int) tea.Cmd { + p := m.curPane() + h := max(m.mainHeight(), 1) + first, last := p.lineStart(p.cur), p.lineEnd(p.cur) + if end := last - h + 1; end > first { // the top row that shows the end of the line + top := min(max(p.top, first), end) + if d > 0 && top < end || d < 0 && top > first { + p.top, p.following = min(max(top+d, first), end), false + return nil + } + } + to := min(max(p.cur+d, 0), p.last()) + if d > 0 { + if last == p.last() { // no line below, so the view stays where it is + p.following = p.top <= first + return nil + } + to = max(to, last+1) + } + p.cur, p.following = to, true // the view follows the cursor again + if first, last := p.lineStart(to), p.lineEnd(to); d < 0 && last-first+1 > h { + p.cur, p.top, p.following = first, last-h+1, false + } + return nil +} + +// onNextHunk moves the cursor to the next hunk. +func (m *model) onNextHunk() tea.Cmd { + m.stepHunk(1) + return nil +} + +// onPrevHunk moves the cursor to the previous hunk. +func (m *model) onPrevHunk() tea.Cmd { + m.stepHunk(-1) + return nil +} + +// onNextFile shows the next file. +func (m *model) onNextFile() tea.Cmd { + m.stepFile(1) + return nil +} + +// onPrevFile shows the previous file. +func (m *model) onPrevFile() tea.Cmd { + m.stepFile(-1) + return nil +} + +// onExpand expands the directory or fold at the cursor. On the PR tab, it +// expands every
block. Otherwise, when lines are not wrapped, it +// scrolls right. +func (m *model) onExpand() tea.Cmd { + p := m.curPane() + switch { + case m.cursorOnDir(): + m.setDirExpanded(true) + case m.cursorOnFold(): m.expandFold(p.rows[p.cur].Fold) - case key.Matches(msg, k.Expand, k.Collapse) && m.tab == tabPR: - // As in herdr-reviewr, right opens all the
blocks of - // the PR tab, and left closes them. - m.prDetails = key.Matches(msg, k.Expand) + case m.tab == tabPR: + m.pr.detailsExpanded = true m.renderPR() - case key.Matches(msg, k.Expand) && !m.wrap: - p.xoff += scrollStep - m.rerender() - case key.Matches(msg, k.Collapse) && !m.wrap: - p.xoff = max(p.xoff-scrollStep, 0) + case !m.wrap: + p.scrollX += scrollStep m.rerender() - case key.Matches(msg, k.Wrap) && p.diff != nil: - // The wrap key works only on a diff, because rendered markdown - // has its own wrap. The sideways scroll works only without wrap. - m.wrap = !m.wrap - m.changePane.xoff, m.allPane.xoff = 0, 0 + } + return nil +} + +// onCollapse collapses the directory at the cursor, or the expanded fold +// at the cursor once the view is scrolled fully left. On the PR tab, it +// collapses every
block. Otherwise, when lines are not wrapped, +// it scrolls left. +func (m *model) onCollapse() tea.Cmd { + p := m.curPane() + switch { + case m.cursorOnDir(): + m.setDirExpanded(false) + case m.canCollapseFold(): + m.collapseFold() + case m.tab == tabPR: + m.pr.detailsExpanded = false + m.renderPR() + case !m.wrap: + p.scrollX = max(p.scrollX-scrollStep, 0) m.rerender() - default: - return nil, false } - return nil, true + return nil } -// commentKey handles a key that adds, changes, or sends comments. It -// reports whether it takes the key. -func (m *model) commentKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { - k, p := m.keys, m.cur() +// onWrap turns wrapping of long diff lines on or off and resets the +// horizontal scroll. It does nothing outside a diff, because rendered +// Markdown does its own wrapping. +func (m *model) onWrap() tea.Cmd { + if m.curPane().diff == nil { + return nil + } + m.wrap = !m.wrap + m.changes.pane.scrollX, m.allFiles.pane.scrollX = 0, 0 + m.rerender() + return nil +} + +// onSelect starts a visual selection at the cursor, or clears the current +// one. +func (m *model) onSelect() tea.Cmd { + p := m.curPane() switch { - case key.Matches(msg, k.Select) && p.anchor >= 0: - p.anchor = -1 - case key.Matches(msg, k.Select): - if m.target(p.cur, diff.SideNew) != nil { - p.anchor = p.cur - } - case key.Matches(msg, k.Comment) && !m.focusList: - // As in herdr-reviewr, a new comment and a delete need the focus - // on the diff. - return m.startComment(diff.SideNew), true - case key.Matches(msg, k.Edit): - return m.editOrOpen(), true - case key.Matches(msg, k.Delete) && !m.focusList: - if c := m.commentAtCursor(); c != nil { - m.comments.Remove(c.ID) - m.flash("comment deleted") - } - case key.Matches(msg, k.NextComment): - m.stepComment(1) - case key.Matches(msg, k.PrevComment): - m.stepComment(-1) - case key.Matches(msg, k.Comments): - m.openCommentList() - case key.Matches(msg, k.Send): - return m.startSend(), true - case key.Matches(msg, k.Copy): - m.copyComments() - return nil, true - default: - return nil, false + case p.selStart >= 0: + p.selStart = -1 + case m.targetAt(p.cur, diff.SideNew) != nil: + p.selStart = p.cur } - return nil, true + return nil +} + +// onComment opens the comment box for a new comment. It does nothing +// while the file list has focus. +func (m *model) onComment() tea.Cmd { + if m.navigatorFocused { + return nil + } + return m.openComment(diff.SideNew) +} + +// onDelete deletes the comment on the cursor line. It does nothing while +// the file list has focus. +func (m *model) onDelete() tea.Cmd { + if m.navigatorFocused { + return nil + } + if c := m.curComment(); c != nil { + m.comments.Delete(c.ID) + m.flash("comment deleted") + } + return nil +} + +// onNextComment moves the cursor to the next comment. +func (m *model) onNextComment() tea.Cmd { + m.stepComment(1) + return nil +} + +// onPrevComment moves the cursor to the previous comment. +func (m *model) onPrevComment() tea.Cmd { + m.stepComment(-1) + return nil +} + +// onComments opens the comment list. +func (m *model) onComments() tea.Cmd { + m.openComments() + return nil +} + +// onSend starts sending the comments to an agent. +func (m *model) onSend() tea.Cmd { return m.startSend() } + +// onCopy copies the comments to the clipboard. +func (m *model) onCopy() tea.Cmd { + m.copyComments() + return nil +} + +// onSearch opens the search over file names and code. +func (m *model) onSearch() tea.Cmd { + m.ensureAllFiles() + m.openSearch() + return nil } -// viewKey handles a key that searches, changes the view, or opens or -// reloads something. It reports whether it takes the key. -func (m *model) viewKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { - k := m.keys +// onFind opens the find bar. +func (m *model) onFind() tea.Cmd { return m.openFind() } + +// onGotoLine opens the go-to-line prompt. +func (m *model) onGotoLine() tea.Cmd { + m.openInput(overlayGotoLine, "go to line (:0 first, :$ last)", "") + return nil +} + +// onRendered switches a Markdown file between rendered and source views. +// It does nothing unless the current file is Markdown with text to render. +func (m *model) onRendered() tea.Cmd { + src, ok := m.markdownSource() switch { - case key.Matches(msg, k.Search): - m.loadFiles() - m.openSearch() - case key.Matches(msg, k.Find): - return m.openFind(), true - case key.Matches(msg, k.GotoLine): - m.openInput(overlayGoto, "go to line (:0 first, :$ last)", "") - case key.Matches(msg, k.Rendered): - // As in herdr-reviewr, m acts only on a markdown file with text. - src, ok := m.markdownSource() - switch { - case !ok: - case m.rendersNothing(src): - m.flash("nothing here renders") - default: - m.rendered = !m.rendered - m.rerender() - } - case key.Matches(msg, k.Split): - // As in herdr-reviewr, | acts only if the file can show in split - // at this width. Only a pane that is too narrow tells why. - shown := m.splitShown() - switch { - case shown || m.codeWidth() >= splitMinWidth && m.splitAllowed(): - m.split = !shown - m.rerender() - case m.splitAllowed(): - m.flash("too narrow for split") - } - case key.Matches(msg, k.NavigatorPosition): - m.moveList() - case key.Matches(msg, k.NavigatorHide): - m.hideList() - case key.Matches(msg, k.NavigatorGrow): - m.resizeList(listStep) - case key.Matches(msg, k.NavigatorShrink): - m.resizeList(-listStep) - case key.Matches(msg, k.OpenPR): - return m.openPR(), true - case key.Matches(msg, k.Refresh): - return m.refresh(), true - case m.release != nil && key.Matches(msg, k.Update): - return m.startUpdate(), true + case !ok: + case m.rendersNothing(src): + m.flash("nothing here renders") default: - return nil, false + m.rendered = !m.rendered + m.rerender() } - return nil, true + return nil } -// listHiddenHere reports whether the user hides the file list on this -// tab. The PR tab never hides it. -func (m *model) listHiddenHere() bool { return m.listHidden && m.tab != tabPR } +// onSplit switches the diff between the split and unified layouts. It +// turns split on only if the file allows it and the pane is wide enough. +// It shows a message only when the pane is too narrow. +func (m *model) onSplit() tea.Cmd { + shown := m.splitShown() + switch { + case shown || m.codeWidth() >= minSplitWidth && m.splitAllowed(): + m.split = !shown + m.rerender() + case m.splitAllowed(): + m.flash("too narrow for split") + } + return nil +} + +// onNavigatorPosition moves the file list to its next position. +func (m *model) onNavigatorPosition() tea.Cmd { + m.moveNavigator() + return nil +} + +// onNavigatorHide hides or shows the file list. +func (m *model) onNavigatorHide() tea.Cmd { + m.toggleNavigator() + return nil +} + +// onNavigatorGrow makes the file list larger. +func (m *model) onNavigatorGrow() tea.Cmd { + m.resizeNavigator(navigatorWidthStep) + return nil +} + +// onNavigatorShrink makes the file list smaller. +func (m *model) onNavigatorShrink() tea.Cmd { + m.resizeNavigator(-navigatorWidthStep) + return nil +} -// moveList moves the file list clockwise around the main pane. A hidden -// list does not move. -func (m *model) moveList() { - if m.listHiddenHere() { +// onUpdate starts updating to the newer release. It does nothing unless +// the footer is announcing one. +func (m *model) onUpdate() tea.Cmd { + if m.release == nil { + return nil + } + return m.startUpdate() +} + +// moveNavigator moves the file list clockwise around the main pane. A +// hidden file list does not move. +func (m *model) moveNavigator() { + if m.navigatorHidden && m.tab != tabPR { return } - m.listPos = m.listPos.next() + m.navigatorPosition = m.navigatorPosition.next() m.rerender() } -// hideList hides the file list and gives the focus to the main pane, or -// shows the list again. The PR tab does not hide it. -func (m *model) hideList() { +// toggleNavigator hides the file list and gives focus to the main pane, +// or shows the file list again. It does nothing on the PR tab. +func (m *model) toggleNavigator() { if m.tab == tabPR { return } - m.listHidden = !m.listHidden - if m.listHidden { - m.focusList = false + m.navigatorHidden = !m.navigatorHidden + if m.navigatorHidden { + m.navigatorFocused = false } m.rerender() } -// resizeList makes the file list larger by d, or smaller for a negative -// d. A hidden list keeps its size. -func (m *model) resizeList(d int) { - if m.listHiddenHere() { +// resizeNavigator grows the file list by d, or shrinks it if d is +// negative. A hidden file list keeps its size. +func (m *model) resizeNavigator(d int) { + if m.navigatorHidden && m.tab != tabPR { return } - if m.listPos.stacked() { - m.listPct = min(max(m.listPct+d, listMinPct), listMaxStackPct) + if m.navigatorPosition.stacked() { + m.navigatorPct = min(max(m.navigatorPct+d, minNavigatorPct), maxNavigatorStackPct) } else { - m.listW = m.clampSide(m.sideWidth() + d) + m.navigatorWidthSet = m.clampNavigatorWidth(m.navigatorSideWidth() + d) } m.rerender() } -// openPR opens the pull request in the browser, and tells it as -// herdr-reviewr does. -func (m *model) openPR() tea.Cmd { +// onOpenPR opens the pull request in the browser. +func (m *model) onOpenPR() tea.Cmd { pr := m.pr.pr if pr == nil || pr.URL == "" { return nil @@ -326,13 +501,13 @@ func (m *model) openPR() tea.Cmd { if pr.Forge == vcs.ForgeGitLab { kind = "MR" } - return m.browse(pr.URL, kind) + return m.openURL(pr.URL, kind) } -// browse opens url in the browser, and tells that it opened what, as -// herdr-reviewr does. -func (m *model) browse(url, what string) tea.Cmd { - wait, err := openURL(m.env, m.cfg, url) +// openURL opens url in the browser and shows a message naming what was +// opened. +func (m *model) openURL(url, what string) tea.Cmd { + wait, err := urlOpenerCmd(m.env, m.cfg, url) if err != nil { m.flash("open failed: " + err.Error()) return nil @@ -341,11 +516,11 @@ func (m *model) browse(url, what string) tea.Cmd { return wait } -// refresh reads the current tab again. -func (m *model) refresh() tea.Cmd { - m.reload() - if m.tab == tabFiles { - m.reloadFiles() +// onRefresh reloads the current tab. +func (m *model) onRefresh() tea.Cmd { + m.requestReload() + if m.tab == tabAllFiles { + m.reloadAllFiles() } if m.tab != tabPR { return nil @@ -355,206 +530,247 @@ func (m *model) refresh() tea.Cmd { return cmd } -// setTab shows tab t. +// setTab switches to tab t. func (m *model) setTab(t tab) { m.tab = t m.drag = dragState{} - // As in herdr-reviewr, the PR tab has no find band. + // The PR tab has no find bar. if t == tabPR { m.closeFind() } + if t != tabAllFiles { + // No other tab shows the file that the Files tab highlights. + m.cache.StopHighlighting() + } switch t { - case tabFiles: - if m.allFiles == nil || m.filesOwed { - m.reloadFiles() + case tabAllFiles: + if m.allFiles.entries == nil || m.allFilesPending { + m.reloadAllFiles() } case tabChanges: - m.reloadIfChanged() + m.requestReloadIfChanged() } - // The layout, the wrap, or the terminal colours can change in another - // tab. + // The layout, wrapping or terminal colours may have changed while + // another tab was shown. m.rerender() } -// setScope shows the Changes tab in scope s, from its first row. +// setScope switches to the Changes tab with scope s, starting at the +// first row. func (m *model) setScope(s Scope) { - // The files of the last scope do not show while the new one fails. - m.tab, m.scope, m.folds, m.shownTrees = tabChanges, s, nil, "" - m.reload() - m.changePane.toStart() - m.armed = 0 -} - -// atFold reports whether the cursor is on a fold of the Changes tab. -func (m *model) atFold() bool { - p := m.cur() + // If loading the new scope fails, the error replaces the files of + // the previous scope. + m.tab, m.scope, m.expandedFolds, m.shownTrees = tabChanges, s, nil, "" + m.cache.StopHighlighting() + m.requestReload() + m.changes.pane.reset() + m.crossStep = 0 +} + +// cursorOnFold reports whether the cursor is on a fold in the Changes tab. +func (m *model) cursorOnFold() bool { + p := m.curPane() return m.tab == tabChanges && p.cur < len(p.rows) && p.rows[p.cur].Fold.Lines > 0 } -// openFolds has the folds that the user expands in one file, as ranges of -// old line numbers. The old side does not change while the agent edits the -// file, so an edit keeps the lines of a range open. The ranges are for the -// old content with the hash Old. A different old content, for example -// after a commit, closes the ranges. -type openFolds struct { +// expandedFolds records the folds that the user has expanded in one file, +// as ranges of old line numbers, which stay valid while the agent edits +// the file. They apply only while the old content has the hash Old. The +// JSON names are used in the resume state. +type expandedFolds struct { Old uint64 `json:"old"` - Ranges [][2]int `json:"ranges"` // the first and the last line of each range -} - -// hash returns the 64-bit FNV-1a hash of s. -func hash(s string) uint64 { - h := fnv.New64a() - _, _ = h.Write([]byte(s)) // a hash.Hash does not fail - return h.Sum64() + Ranges [][2]int `json:"ranges"` // the first and last line of each range } -// expanded reports whether the lines of fold f of the file at path show. -// They show if the user expands the fold, or if a comment is on one of -// them. -func (m *model) expanded(path string, comments []*diff.Comment, f diff.Fold) bool { - // A fold that overlaps a range shows. An undo of a change next to a - // range makes a larger fold, which shows as a whole. - if o := m.folds[path]; o != nil && slices.ContainsFunc(o.Ranges, func(r [2]int) bool { - return f.Old <= r[1] && f.Old+f.Lines-1 >= r[0] +// foldExpanded reports whether the lines of fold in the file at path are +// shown. They are shown if the user expanded the fold or if one of the +// comments is on one of its lines. +func (m *model) foldExpanded(path string, comments []*diff.Comment, fold diff.Fold) bool { + // A fold that overlaps an expanded range is shown. Undoing a change + // next to a range creates a larger fold, which is then shown in full. + if o := m.expandedFolds[path]; o != nil && slices.ContainsFunc(o.Ranges, func(r [2]int) bool { + return fold.Old <= r[1] && fold.Old+fold.Lines-1 >= r[0] }) { return true } for _, c := range comments { - lo := f.New + lo := fold.New if c.Side == diff.SideOld { - lo = f.Old + lo = fold.Old } - if c.Path == path && c.Start < lo+f.Lines && c.End >= lo { + if c.Path == path && c.Start < lo+fold.Lines && c.End >= lo { return true } } return false } -// expandFold shows the lines of fold f in the Changes tab, and moves the -// cursor to the first of them. The selection stays on its lines. -func (m *model) expandFold(f diff.Fold) { - p := &m.changePane - file := m.selectedChange() - if file == nil { +// expandFold shows the hidden lines of fold in the Changes tab and moves +// the cursor to the first of them. The selection stays on the same lines. +func (m *model) expandFold(fold diff.Fold) { + p := &m.changes.pane + f := m.curChangedFile() + if f == nil { return } - if m.folds == nil { - m.folds = map[string]*openFolds{} + if m.expandedFolds == nil { + m.expandedFolds = map[string]*expandedFolds{} } - o := m.folds[file.Path] + o := m.expandedFolds[f.Path] if o == nil { - o = &openFolds{Old: file.oldHash} - m.folds[file.Path] = o + o = &expandedFolds{Old: f.oldHash} + m.expandedFolds[f.Path] = o } - o.Ranges = append(o.Ranges, [2]int{f.Old, f.Old + f.Lines - 1}) + o.Ranges = append(o.Ranges, [2]int{fold.Old, fold.Old + fold.Lines - 1}) m.rerender() - p.cur = max(p.rowOf(diff.SideNew, f.New), 0) + p.cur = max(p.rowOf(diff.SideNew, fold.New), 0) } -// listSel returns the index of the selected file in the file list. -func (m *model) listSel() int { - if m.tab == tabFiles { - return m.allSel +// cursorInExpandedFold reports whether the cursor is on a line of a fold +// that the user has expanded in the Changes tab. +func (m *model) cursorInExpandedFold() bool { + p := m.curPane() + f := m.curChangedFile() + if m.tab != tabChanges || f == nil || p.cur >= len(p.rows) { + return false } - return m.changeSel + o := m.expandedFolds[f.Path] + row := p.rows[p.cur] + line := row.Old + if o == nil || line == 0 || row.Kind != diff.KindEqual || row.Fold.Lines > 0 { + return false + } + return slices.ContainsFunc(o.Ranges, func(r [2]int) bool { return inRange(r, line) }) +} + +// canCollapseFold reports whether the collapse key would collapse the +// expanded fold at the cursor. That needs the cursor to be in an expanded +// fold and the view to be scrolled fully left. +func (m *model) canCollapseFold() bool { + return m.curPane().scrollX == 0 && m.cursorInExpandedFold() } -// pickFile selects file i of the file list of the current tab. The -// cursor of the list goes to the row of the file, if the row shows. The -// pick drops an armed crossing. -func (m *model) pickFile(i int) { - if t := m.list(); t != nil { +// collapseFold hides the expanded lines around the cursor again and moves +// the cursor to the fold that replaces them. A fold with a comment on it +// stays open. The caller must check canCollapseFold first. +func (m *model) collapseFold() { + p := m.curPane() + f := m.curChangedFile() + o := m.expandedFolds[f.Path] + line := p.rows[p.cur].Old + o.Ranges = slices.DeleteFunc(o.Ranges, func(r [2]int) bool { return inRange(r, line) }) + if len(o.Ranges) == 0 { + delete(m.expandedFolds, f.Path) + } + p.selStart = -1 // the selection could end inside the new fold + m.rerender() + if i := slices.IndexFunc(p.rows, func(r diff.Row) bool { return foldHas(r.Fold, diff.SideOld, line) }); i >= 0 { + p.cur = i + } +} + +// curFileIndex returns the index of the current file in the current tab. +func (m *model) curFileIndex() int { + if m.tab == tabAllFiles { + return m.allFiles.cur + } + return m.changes.cur +} + +// setCurFile selects file i in the current tab. If the file's row is +// visible in the file list, the list cursor moves to it. Any pending jump +// to another file is cancelled. +func (m *model) setCurFile(i int) { + if t := m.curNavigator(); t != nil { if r := t.rowOf(i); r >= 0 { t.cur = r } } - m.armed = 0 + m.crossStep = 0 switch m.tab { case tabChanges: - if i < 0 || i >= len(m.changeFiles) { + if i < 0 || i >= len(m.changes.files) { return } - m.changeSel = i - m.changePane.toStart() + m.changes.cur = i + m.changes.pane.reset() m.renderChanges() - case tabFiles: - if i < 0 || i >= len(m.allFiles) { + case tabAllFiles: + if i < 0 || i >= len(m.allFiles.entries) { return } - m.allSel = i - m.allPane.toStart() - m.renderViewer() + m.allFiles.cur = i + m.allFiles.pane.reset() + m.renderAllFiles() } m.drag = dragState{} } -// stepChange moves the cursor to the next (d = 1) or the previous (d = -1) -// block of changed rows, as in herdr-reviewr. Past the last block, one -// press arms a crossing to the next file with a change in that direction. -// A second press the same way crosses, and lands on the first block in -// that direction. A selection holds the cursor. -func (m *model) stepChange(d int) { - armed := m.armed == d - m.armed = 0 - p := &m.changePane - if m.tab != tabChanges || p.anchor >= 0 { +// stepHunk moves the cursor to the next (d = 1) or previous (d = -1) hunk. +// When there are no more hunks in that direction, the first press arms a +// jump to the nearest file with changes, and a second press in the same +// direction opens that file at its first hunk in that direction. It does +// nothing while a selection is active. +func (m *model) stepHunk(d int) { + armed := m.crossStep == d + m.crossStep = 0 + p := &m.changes.pane + if m.tab != tabChanges || p.selStart >= 0 { return } - if i := changeRow(p.rows, p.cur, d); i >= 0 { + if i := nextHunkRow(p.rows, p.cur, d); i >= 0 { p.cur = i return } - target := m.crossTarget(d) + target := m.crossFile(d) if !armed { if target >= 0 { - m.armed, m.armedPath = d, m.changeFiles[target].Path + m.crossStep, m.crossPath = d, m.changes.files[target].Path } return } - // A reload can drop the armed file. - isArmed := func(f changedFile) bool { return f.Path == m.armedPath } - if i := slices.IndexFunc(m.changeFiles, isArmed); i >= 0 { + // A reload may have removed the target file or changed its index. + crossing := func(f changedFile) bool { return f.Path == m.crossPath } + if i := slices.IndexFunc(m.changes.files, crossing); i >= 0 { target = i } if target < 0 { return } - m.pickFile(target) + m.setCurFile(target) from := -1 if d < 0 { from = len(p.rows) } - p.cur = max(changeRow(p.rows, from, d), 0) + p.cur = max(nextHunkRow(p.rows, from, d), 0) } -// changeRow returns the first row of a block of changed rows, or -1. It -// starts at row from and goes in the direction d. A d of 1 finds the next -// block, and a d of -1 finds the previous block. -func changeRow(rows []diff.Row, from, d int) int { - isChange := func(i int) bool { +// nextHunkRow returns the first row of the next (d = 1) or previous +// (d = -1) hunk after row from, or -1 if there is none. +func nextHunkRow(rows []diff.Row, from, d int) int { + isChanged := func(i int) bool { k := rows[i].Kind - return k == diff.KindInsert || k == diff.KindDelete || k == diff.KindChange + return k == diff.KindAdded || k == diff.KindDeleted || k == diff.KindChanged } for i := from + d; i >= 0 && i < len(rows); i += d { - if isChange(i) && (i == 0 || !isChange(i-1)) { + if isChanged(i) && (i == 0 || !isChanged(i-1)) { return i } } return -1 } -// crossTarget returns the index of the nearest file in direction d that -// has a changed line, or -1. It goes in the order of the rows of the file -// list, from the row of the file that shows. -func (m *model) crossTarget(d int) int { - t := &m.changeTree - from := t.rowOf(m.changeSel) +// crossFile returns the index of the nearest file in direction d that has +// a changed line, or -1. It follows the order of the file list, starting +// from the row of the shown file. +func (m *model) crossFile(d int) int { + t := &m.changes.navigator + from := t.rowOf(m.changes.cur) if from < 0 { from = t.cur } for r := t.nextFile(from, d); r >= 0; r = t.nextFile(r, d) { - if i := t.rows[r].File; m.changeFiles[i].adds+m.changeFiles[i].dels > 0 { + if i := t.rows[r].File; m.changes.files[i].added+m.changes.files[i].deleted > 0 { return i } } @@ -562,12 +778,11 @@ func (m *model) crossTarget(d int) int { } // stepComment moves the cursor to the next (d = 1) or previous (d = -1) -// comment of the open file, by row. It wraps around in the file. As in -// herdr-reviewr, n and N step only through the comments on the rows that -// show. +// comment in the open file, in row order, wrapping around at the ends. It +// skips comments whose lines are not shown. func (m *model) stepComment(d int) { - p := m.cur() - a := m.anchor() + p := m.curPane() + a := m.curAnchor() var stops []int // the first row of each comment for _, c := range m.comments.All() { if c.Anchor != a || c.Path != p.path || p.path == "" { @@ -588,89 +803,111 @@ func (m *model) stepComment(d int) { k = i - 1 } } - m.focusList = false - p.cur, p.anchor = stops[k], -1 + m.navigatorFocused = false + p.cur, p.selStart = stops[k], -1 } -// jumpToComment shows comment c in the tab of its anchor. +// jumpToComment shows comment c in the tab that it was made on. func (m *model) jumpToComment(c *diff.Comment) { if c.Anchor.InDiff { m.jumpTo(c.Path, c.Side, c.Start) - return + } else { + m.jumpToFile(c.Path, c.Side, c.Start) } - m.jumpToFile(c.Path, c.Side, c.Start) + m.jumpToNearLine(c) } // jumpTo shows path with the cursor on line. It uses the Changes tab if -// the file has changes, else the Files tab. -func (m *model) jumpTo(path string, s diff.Side, line int) { +// the file has changes, and the Files tab otherwise. +func (m *model) jumpTo(path string, side diff.Side, line int) { inChanges := func(f changedFile) bool { return f.Path == path } - if i := slices.IndexFunc(m.changeFiles, inChanges); i >= 0 { + if i := slices.IndexFunc(m.changes.files, inChanges); i >= 0 { m.tab = tabChanges + m.cache.StopHighlighting() m.revealFile(path) - m.pickFile(i) - m.openFoldAt(s, line) - cursorToLine(&m.changePane, s, line) + m.setCurFile(i) + m.expandFoldAt(side, line) + jumpToLine(&m.changes.pane, side, line) return } - m.jumpToFile(path, s, line) + m.jumpToFile(path, side, line) } -// openFoldAt opens the fold of the Changes tab that has line on side s. -func (m *model) openFoldAt(s diff.Side, line int) { - for _, r := range m.changePane.rows { - if f := r.Fold; f.Lines > 0 && line >= foldStart(f, s) && line <= foldEnd(f, s) { - m.expandFold(f) +// expandFoldAt expands the fold in the Changes tab that hides line on +// side. +func (m *model) expandFoldAt(side diff.Side, line int) { + for _, r := range m.changes.pane.rows { + if foldHas(r.Fold, side, line) { + m.expandFold(r.Fold) return } } } // jumpToFile shows path in the Files tab with the cursor on line. -func (m *model) jumpToFile(path string, s diff.Side, line int) { - m.loadFiles() - i := slices.IndexFunc(m.allFiles, func(e vcs.Entry) bool { return e.Path == path }) +func (m *model) jumpToFile(path string, side diff.Side, line int) { + if m.allFiles.entries == nil { + // Jump once the entries have loaded. + m.ensureAllFiles() + m.allFiles.jump = &fileJump{path, side, line} + return + } + i := slices.IndexFunc(m.allFiles.entries, func(e vcs.Entry) bool { return e.Path == path }) if i < 0 { m.flash("no such file: " + path) return } - m.tab = tabFiles + m.tab = tabAllFiles m.revealFile(path) - m.pickFile(i) - if s == diff.SideOld { // the Files tab shows only the new side + m.setCurFile(i) + if side == diff.SideOld { // the Files tab shows only the new side m.flash("the removed line is not in the file now") return } - cursorToLine(&m.allPane, diff.SideNew, line) + jumpToLine(&m.allFiles.pane, diff.SideNew, line) } -// cursorToLine puts the cursor of p on the row that shows line on side s. -func cursorToLine(p *pane, s diff.Side, line int) { - if i := p.rowOf(s, line); i >= 0 { +// jumpToLine moves the cursor of p to the row that shows line on side. It +// does nothing if no row shows it. +func jumpToLine(p *pane, side diff.Side, line int) { + if i := p.rowOf(side, line); i >= 0 { p.cur = i } } -// rowOf returns the first row of p that shows line on side s, or -1. In -// rendered markdown, a line in a block is on the first row of the block, -// as in gotoLine. -func (p *pane) rowOf(s diff.Side, line int) int { - i := slices.IndexFunc(p.rows, func(r diff.Row) bool { return r.Line(s) == line }) - if i < 0 && p.src != nil && slices.ContainsFunc(p.src, func(r diff.Row) bool { return r.Line(s) == line }) { - return p.head(lineRow(p.rows, s, line)) +// rowOf returns the first row of p that shows line on side, or -1. In +// rendered Markdown, a line inside a block maps to the first row of the +// block, as in gotoLine. +func (p *pane) rowOf(side diff.Side, line int) int { + shows := func(r diff.Row) bool { return r.Line(side) == line } + if i := slices.IndexFunc(p.rows, shows); i >= 0 { + return i + } + i := slices.IndexFunc(p.srcRows, shows) + if i < 0 { + return -1 } - return i + // Rendered Markdown shows only the new side. An old line maps to the + // first new line from it on, or to the last new line before it. + newLine := 1 + if j := slices.IndexFunc(p.srcRows[i:], func(r diff.Row) bool { return r.New > 0 }); j >= 0 { + newLine = p.srcRows[i+j].New + } else { + for _, r := range p.srcRows[:i] { + newLine = max(newLine, r.New) + } + } + return p.lineStart(lineRow(p.rows, diff.SideNew, newLine)) } -// gotoLine moves the cursor to the line in text, as herdr-reviewr does. -// text is digits or "$" for the last line. The line is clamped to the -// file, so "0" is the first line. The line numbers are of the new side, -// or of the old side if no row has a new line. gotoLine opens the fold -// that has the line. In rendered markdown, the cursor goes to the block -// of the line. Without line numbers, text is the row on the screen. +// gotoLine moves the cursor to the line number in text: digits, clamped to +// the file, or "$" for the last line. The number is on the new side if any +// row has one, else the old side, or a row number in a pane without line +// numbers. It expands a fold that hides the line, and in rendered Markdown +// goes to the line's block. func (m *model) gotoLine(text string) { - p := m.cur() - n, ok := lineNumber(text) + p := m.curPane() + n, ok := parseLineNum(text) if !ok || len(p.rows) == 0 { return } @@ -688,14 +925,14 @@ func (m *model) gotoLine(text string) { } n = min(max(n, 1), last) if m.tab == tabChanges { - m.openFoldAt(side, n) + m.expandFoldAt(side, n) } - p.cur = p.head(lineRow(p.rows, side, n)) + p.cur = p.lineStart(lineRow(p.rows, side, n)) } -// lineRow returns the row of line on side. If no row has the line, it -// returns the first row with a larger line, else the last row with a -// line. +// lineRow returns the row that shows line on side. If no row shows it, it +// returns the first row with a larger line number or, failing that, the +// last row that has a line number on side. func lineRow(rows []diff.Row, side diff.Side, line int) int { if i := slices.IndexFunc(rows, func(r diff.Row) bool { return r.Line(side) == line }); i >= 0 { return i @@ -711,25 +948,35 @@ func lineRow(rows []diff.Row, side diff.Side, line int) int { return len(rows) - 1 } -// foldStart returns the first line of fold f on side. -func foldStart(f diff.Fold, side diff.Side) int { +// foldStart returns the first line that fold hides on side. +func foldStart(fold diff.Fold, side diff.Side) int { if side == diff.SideOld { - return f.Old + return fold.Old } - return f.New + return fold.New } -// foldEnd returns the last line of fold f on side, or 0 for no fold. -func foldEnd(f diff.Fold, side diff.Side) int { - if f.Lines == 0 { +// foldEnd returns the last line that fold hides on side, or 0 if fold is +// empty. +func foldEnd(fold diff.Fold, side diff.Side) int { + if fold.Lines == 0 { return 0 } - return foldStart(f, side) + f.Lines - 1 + return foldStart(fold, side) + fold.Lines - 1 } -// lineNumber returns the line in s, the text of the go-to-line prompt. "$" -// gives the largest int. ok is false if s is not digits or "$". -func lineNumber(s string) (n int, ok bool) { +// foldHas reports whether fold hides line on side. +func foldHas(fold diff.Fold, side diff.Side, line int) bool { + return fold.Lines > 0 && line >= foldStart(fold, side) && line <= foldEnd(fold, side) +} + +// inRange reports whether line is in the range r of expanded lines, +// including both ends. +func inRange(r [2]int, line int) bool { return line >= r[0] && line <= r[1] } + +// parseLineNum parses s, the text of the go-to-line prompt. "$" returns +// the largest int. ok is false unless s is all digits or "$". +func parseLineNum(s string) (n int, ok bool) { if s == "$" { return math.MaxInt, true } @@ -738,13 +985,13 @@ func lineNumber(s string) (n int, ok bool) { } n, err := strconv.Atoi(s) if err != nil { - n = math.MaxInt // too many digits + n = math.MaxInt // too many digits for an int } return n, true } -// isLineKey reports whether the go-to-line prompt takes the key msg. It -// takes a digit, "$", and the keys that move in the text or delete. +// isLineKey reports whether the go-to-line prompt accepts msg: a digit, +// "$", or a key that moves the text cursor or deletes. func isLineKey(msg tea.KeyPressMsg) bool { if msg.Mod&^tea.ModShift != 0 { return false @@ -756,11 +1003,11 @@ func isLineKey(msg tea.KeyPressMsg) bool { return len(msg.Text) == 1 && strings.ContainsAny(msg.Text, "0123456789$") } -// pastedLine returns the line in pasted text for the go-to-line prompt. -// It removes the quotes, the brackets, and the punctuation around the -// text. ok is false if the rest is not digits or "$". +// pastedLine extracts a line number from text pasted into the go-to-line +// prompt. It strips surrounding quotes, brackets and punctuation. ok is +// false unless what remains is all digits or "$". func pastedLine(text string) (line string, ok bool) { line = strings.Trim(strings.TrimSpace(text), "`'\"“”‘’()[]<>:.,;") - _, ok = lineNumber(line) + _, ok = parseLineNum(line) return line, ok } diff --git a/tui/navigator.go b/tui/navigator.go new file mode 100644 index 0000000..b410d4c --- /dev/null +++ b/tui/navigator.go @@ -0,0 +1,337 @@ +package tui + +// The navigator is the file list. It shows the files of a tab as a tree of +// directories, and its cursor moves over both directories and files. The +// main pane shows the last file that the cursor was on. + +import ( + "slices" + "strings" + + "github.com/rednafi/rexdiff/internal/tree" + "github.com/rednafi/rexdiff/vcs" +) + +// navigator is the file list of a tab. +type navigator struct { + entries []tree.Entry + rows []tree.Row + cur int // the cursor row + // top is the first row on screen. It moves only as far as needed to + // keep the cursor visible. following is true while the view keeps the + // cursor on screen. The mouse wheel sets it to false, which lets the + // cursor go off screen until the next key press or click. + top int + following bool + // expandAll is true if directories start expanded. toggled holds the + // directories that the user has switched away from that default. + expandAll bool + toggled map[string]bool +} + +// build rebuilds the rows from es. The cursor stays on the same directory +// or file. If that row is gone, or the cursor is -1, the cursor moves to +// the file with index cur or, failing that, to the first file. +func (t *navigator) build(es []tree.Entry, expandAll bool, cur int) { + var dir, path string + if t.cur >= 0 && t.cur < len(t.rows) { + if r := t.rows[t.cur]; r.File < 0 { + dir = r.Dir + } else { + path = t.entries[r.File].Path + } + } + t.entries, t.expandAll = es, expandAll + t.rows = tree.Build(es, t.toggled, expandAll) + t.cur = slices.IndexFunc(t.rows, func(r tree.Row) bool { + if r.File < 0 { + return dir != "" && r.Dir == dir + } + return path != "" && es[r.File].Path == path + }) + if t.cur < 0 { + t.cur = t.rowOf(cur) + } + if t.cur < 0 { + t.cur = t.nextFile(-1, 1) + } + t.cur = max(t.cur, 0) +} + +// scrollToCursor scrolls a list h rows high as little as possible so that +// the cursor is visible. If the list is not following the cursor, it only +// keeps top within the rows. +func (t *navigator) scrollToCursor(h int) { + h = max(h, 1) + if t.following { + t.top = keepInView(t.top, t.cur, h) + } + t.top = t.start(h) +} + +// keepInView returns the new top row of a view h rows high, moved as +// little as possible from top so that row cur is visible. h must be at +// least 1. +func keepInView(top, cur, h int) int { return min(cur, max(top, cur-h+1)) } + +// clampTop clamps top for a view h rows high over n rows, keeping the last +// screen full when possible. +func clampTop(top, n, h int) int { return max(min(top, n-h), 0) } + +// start returns the first visible row in a view h rows high: top, clamped +// to the rows. +func (t *navigator) start(h int) int { + return clampTop(t.top, len(t.rows), max(h, 1)) +} + +// curFile returns the index of the file at the cursor, or -1 if the +// cursor is on a directory. +func (t *navigator) curFile() int { + if t.cur >= len(t.rows) { + return -1 + } + return t.rows[t.cur].File +} + +// fileOr returns the index of the file at the cursor, or cur if the +// cursor is on a directory. It never returns less than 0. +func (t *navigator) fileOr(cur int) int { + if i := t.curFile(); i >= 0 { + return i + } + return max(cur, 0) +} + +// curDir returns the row at the cursor and true if it is a directory. +func (t *navigator) curDir() (tree.Row, bool) { + if t.cur >= len(t.rows) || t.rows[t.cur].File >= 0 { + return tree.Row{}, false + } + return t.rows[t.cur], true +} + +// rowOf returns the row of the file with index i, or -1 if it has no +// visible row. +func (t *navigator) rowOf(i int) int { + if i < 0 { + return -1 + } + return slices.IndexFunc(t.rows, func(r tree.Row) bool { return r.File == i }) +} + +// nextFile returns the first file row after row from in direction d (1 or +// -1), or -1 if there is none. +func (t *navigator) nextFile(from, d int) int { + for r := from + d; r >= 0 && r < len(t.rows); r += d { + if t.rows[r].File >= 0 { + return r + } + } + return -1 +} + +// setExpanded expands or collapses the directory dir. It reports whether +// anything changed. +func (t *navigator) setExpanded(dir string, expanded bool) bool { + // A toggled directory is in the opposite state to expandAll. + toggled := t.expandAll != expanded + if t.toggled[dir] == toggled { + return false + } + if toggled { + if t.toggled == nil { + t.toggled = map[string]bool{} + } + t.toggled[dir] = true + } else { + delete(t.toggled, dir) + } + return true +} + +// reveal expands every directory above path. It reports whether any of +// them changed. +func (t *navigator) reveal(path string) bool { + changed := false + for dir, _, ok := strings.CutLast(path, "/"); ok; dir, _, ok = strings.CutLast(dir, "/") { + changed = t.setExpanded(dir, true) || changed + } + return changed +} + +// curNavigator returns the file list of the current tab, or nil on the PR +// tab. +func (m *model) curNavigator() *navigator { + switch m.tab { + case tabChanges: + return &m.changes.navigator + case tabAllFiles: + return &m.allFiles.navigator + } + return nil +} + +// buildChangesNavigator rebuilds the file list of the Changes tab. If the +// cursor ends up on a file, that file becomes the current file. +func (m *model) buildChangesNavigator() { + es := make([]tree.Entry, len(m.changes.files)) + for i, f := range m.changes.files { + es[i] = tree.Entry{Path: f.Path, Changed: true} + } + t := &m.changes.navigator + t.build(es, true, m.changes.cur) + m.changes.cur = t.fileOr(m.changes.cur) +} + +// buildAllFilesNavigator rebuilds the file list of the Files tab. Files +// that are in the Changes tab are marked as changed. If the cursor ends up +// on a file, that file becomes the current file. +func (m *model) buildAllFilesNavigator() { + changed := make(map[string]bool, len(m.changes.files)) + for _, f := range m.changes.files { + changed[f.Path] = true + } + es := make([]tree.Entry, len(m.allFiles.entries)) + m.allFiles.fileCount = 0 + for i, e := range m.allFiles.entries { + p, isDir := strings.CutSuffix(e.Path, "/") + es[i] = tree.Entry{Path: p, Changed: changed[p], Ignored: e.Ignored, Dir: isDir} + if !isDir { + m.allFiles.fileCount++ + } + } + t := &m.allFiles.navigator + t.build(es, false, m.allFiles.cur) + m.allFiles.cur = min(t.fileOr(m.allFiles.cur), max(len(m.allFiles.entries)-1, 0)) +} + +// buildNavigator rebuilds the file list of the current tab. +func (m *model) buildNavigator() { + switch m.tab { + case tabChanges: + m.buildChangesNavigator() + case tabAllFiles: + m.buildAllFilesNavigator() + } +} + +// withIgnored adds to es the contents of each ignored directory in +// expanded, the directories that the user has expanded in the Files tab, +// and sorts es by path. +func withIgnored(repo *vcs.Repo, es []vcs.Entry, expanded map[string]bool) []vcs.Entry { + // The loop also visits the entries it appends, so that expanded + // ignored directories nested inside others are read too. + for i := 0; i < len(es); i++ { + if dir, ok := expandedIgnoredDir(es[i], expanded); ok { + es = append(es, repo.IgnoredDir(dir)...) + } + } + slices.SortStableFunc(es, func(a, b vcs.Entry) int { return strings.Compare(a.Path, b.Path) }) + return es +} + +// includedPaths returns the paths to pass to [vcs.Watcher.Include] while +// the Files tab is shown: the ignored directories that the user has +// expanded, and the open file. On other tabs, it returns nil and "". +func (m *model) includedPaths() (dirs []string, file string) { + if m.tab != tabAllFiles { + return nil, "" + } + for _, e := range m.allFiles.entries { + if dir, ok := expandedIgnoredDir(e, m.allFiles.navigator.toggled); ok { + dirs = append(dirs, dir) + } + } + if m.allFiles.cur < len(m.allFiles.entries) { + if e := m.allFiles.entries[m.allFiles.cur]; !strings.HasSuffix(e.Path, "/") { + file = e.Path + } + } + return dirs, file +} + +// expandedIgnoredDir returns the path of e without its trailing "/", and +// reports whether e is an ignored directory in expanded, the directories +// that the user has expanded in the Files tab. +func expandedIgnoredDir(e vcs.Entry, expanded map[string]bool) (string, bool) { + dir, isDir := strings.CutSuffix(e.Path, "/") + return dir, isDir && e.Ignored && expanded[dir] +} + +// cursorOnDir reports whether the file list has focus and its cursor is on +// a directory. +func (m *model) cursorOnDir() bool { + if t := m.curNavigator(); t != nil && m.navigatorFocused { + _, ok := t.curDir() + return ok + } + return false +} + +// setDirExpanded expands or collapses the directory at the file list +// cursor. Expanding an ignored directory reads its contents. +func (m *model) setDirExpanded(expanded bool) { + t := m.curNavigator() + r, ok := t.curDir() + if !ok || !t.setExpanded(r.Dir, expanded) { + return + } + if expanded && m.tab == tabAllFiles && r.Ignored { + m.allFiles.listing.want = true + } + m.buildNavigator() +} + +// stepNavigatorRow moves the file list cursor by d rows. If the cursor +// lands on a file, the main pane shows it. +func (m *model) stepNavigatorRow(d int) { + t := m.curNavigator() + if t == nil || len(t.rows) == 0 { + return + } + t.cur = min(max(t.cur+d, 0), len(t.rows)-1) + if i := t.curFile(); i >= 0 && i != m.curFileIndex() { + m.setCurFile(i) + } +} + +// stepFile shows the next (d = 1) or previous (d = -1) file in the file +// list, skipping directories. It starts from the file list cursor when the +// file list has focus, and from the shown file otherwise. +func (m *model) stepFile(d int) { + t := m.curNavigator() + if t == nil { + return + } + from := t.cur + if r := t.rowOf(m.curFileIndex()); !m.navigatorFocused && r >= 0 { + from = r + } + if r := t.nextFile(from, d); r >= 0 { + m.setCurFile(t.rows[r].File) + } +} + +// clickNavigatorRow handles a click on row r of the file list. Clicking a +// file shows it, and clicking a directory expands or collapses it. +func (m *model) clickNavigatorRow(r int) { + t := m.curNavigator() + if t == nil || r < 0 || r >= len(t.rows) { + return + } + m.navigatorFocused = true + t.cur = r + if dir, ok := t.curDir(); ok { + m.setDirExpanded(!dir.Expanded) + return + } + m.setCurFile(t.rows[r].File) +} + +// revealFile expands the directories above path in the current tab's file +// list, so that its row is visible. +func (m *model) revealFile(path string) { + if t := m.curNavigator(); t != nil && t.reveal(path) { + m.buildNavigator() + } +} diff --git a/tui/overlay.go b/tui/overlay.go index 857ece3..6266baf 100644 --- a/tui/overlay.go +++ b/tui/overlay.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "slices" "strings" "charm.land/bubbles/v2/key" @@ -16,7 +17,7 @@ import ( "github.com/rednafi/rexdiff/rex" ) -// overlayKind tells what an overlay is for. +// overlayKind says what an overlay is for. type overlayKind int const ( @@ -25,173 +26,205 @@ const ( overlayCommits overlayComments overlaySearch - overlayGoto + overlayGotoLine overlayQuit ) -// These are the steps of the page keys in the commit picker, in rows, as -// in herdr-reviewr. +// How many rows the page and half-page keys move the cursor in the commit +// picker and the comment list. The page keys also scroll the search +// preview by itemsPage rows. const ( - pickerPage = 15 - pickerHalf = 8 + itemsPage = 15 + itemsHalf = 8 ) -// pickItem is one row of the list of an overlay. -type pickItem struct { +// overlayItem is one row in an overlay's list. +type overlayItem struct { label string - detail string // the dim text after the label - tag string // a short mark at the end, for example "current" - // id is what the row picks, for example a path, a ref, a commit, or a - // block. In the search, a row with no id cannot have the cursor. - id string - line int // the search: the line of a code match - head bool // the search: the header of the code matches of a file - comment int // the comment list: the ID of the comment + detail string // dim text shown after the label + tag string // a short marker at the end, for example "current" + // id identifies what choosing the row selects, such as a path, ref, + // commit or block. In the search, a row without an id cannot have the + // cursor. + id string + line int // search only: the line number of a code match + isHeader bool // search only: the heading above a file's code matches + comment int // comment list only: the ID of the comment } -// overlay is a modal box. It shows a list to pick from, a search, or a +// overlay is a modal box that shows a list to choose from, a search or a // prompt. type overlay struct { - kind overlayKind - title string - input textinput.Model - items []pickItem - shown []int // the indices of the items that match the input - cur int - mark int // the commit picker: the item at the start of the range, or -1 - agents []rex.Agent - search searchState - // rowsFrom and rowsTo are the index of the first row that shows and - // the index after the last one. - rowsFrom, rowsTo int + kind overlayKind + title string + input textinput.Model + items []overlayItem + shown []int // the indexes of the items that match the input + cur int + rangeStart int // commit picker only: the item at the start of the range, or -1 + // loading is true while the commit picker waits for its commits, and + // stop stops the listing of them. + loading bool + stop context.CancelFunc + agents []rex.Agent + search searchState + // itemsLo is the index of the first visible row, and itemsHi is one + // past the last. + itemsLo, itemsHi int } -// newOverlay returns an overlay. If its kind has an input, the input has -// the focus. +// newOverlay returns a new overlay of the given kind. If the kind has a +// text input, the input has focus. func newOverlay(kind overlayKind, title string) *overlay { ti := textinput.New() ti.Prompt = "› " - ov := &overlay{kind: kind, title: title, input: ti, mark: -1} + ov := &overlay{kind: kind, title: title, input: ti, rangeStart: -1} if ov.hasInput() { ov.input.Focus() } return ov } -// hasInput reports whether the overlay has a text input. The lists of -// agents, comments, and commits, and the prompt before a quit, have none. -func (o *overlay) hasInput() bool { - switch o.kind { +// hasInput reports whether the overlay has a text input. The agent, +// comment and commit lists and the quit prompt do not. +func (ov *overlay) hasInput() bool { + switch ov.kind { case overlayAgents, overlayComments, overlayCommits, overlayQuit: return false } return true } -// setItems sets the items, and shows the ones that match the input. -func (o *overlay) setItems(items []pickItem) { - o.items = items - o.filter() +// setItems replaces the items and shows those that match the input. +func (ov *overlay) setItems(items []overlayItem) { + ov.items = items + ov.filter() } -// filter shows the items that match the input. The search shows all its -// items, because they match already. -func (o *overlay) filter() { - q := o.input.Value() - o.shown = o.shown[:0] - if q == "" || o.kind == overlaySearch { - for i := range o.items { - o.shown = append(o.shown, i) +// filter shows the items that match the input. The search shows all of +// its items, because they are already matches. +func (ov *overlay) filter() { + q := ov.input.Value() + ov.shown = ov.shown[:0] + if q == "" || ov.kind == overlaySearch { + for i := range ov.items { + ov.shown = append(ov.shown, i) } } else { - labels := make([]string, len(o.items)) - for i, it := range o.items { + labels := make([]string, len(ov.items)) + for i, it := range ov.items { labels[i] = it.label + " " + it.detail } - o.shown = append(o.shown, search.Indices(q, labels)...) + ov.shown = append(ov.shown, search.Indices(q, labels)...) } - o.cur = min(o.cur, max(len(o.shown)-1, 0)) + ov.cur = min(ov.cur, max(len(ov.shown)-1, 0)) } -// selected returns the item at the cursor, or nil. -func (o *overlay) selected() *pickItem { - if o.cur < 0 || o.cur >= len(o.shown) { +// curItem returns the item at the cursor, or nil. +func (ov *overlay) curItem() *overlayItem { + if ov.cur < 0 || ov.cur >= len(ov.shown) { return nil } - return &o.items[o.shown[o.cur]] + return &ov.items[ov.shown[ov.cur]] } -// move moves the cursor of the list by d rows. It skips the rows that -// cannot have the cursor, and it stops at the ends. -func (o *overlay) move(d int) { +// move moves the list cursor by d rows, skipping rows that cannot have the +// cursor and stopping at the ends. +func (ov *overlay) move(d int) { step := 1 if d < 0 { step = -1 } for ; d != 0; d -= step { - i := o.cur + step - for i >= 0 && i < len(o.shown) && !o.pickable(i) { + i := ov.cur + step + for i >= 0 && i < len(ov.shown) && !ov.canHaveCursor(i) { i += step } - if i < 0 || i >= len(o.shown) { + if i < 0 || i >= len(ov.shown) { return } - o.cur = i + ov.cur = i } } -// pickable reports whether row i of the list can have the cursor. In the -// search, the headers of the files and the "… more" row cannot. -func (o *overlay) pickable(i int) bool { - return o.kind != overlaySearch || o.items[o.shown[i]].id != "" +// canHaveCursor reports whether row i of the list can have the cursor. In +// the search, file headings and the "… more" row cannot. +func (ov *overlay) canHaveCursor(i int) bool { + return ov.kind != overlaySearch || ov.items[ov.shown[i]].id != "" } -// open shows ov and gives the keys to it. -func (m *model) open(ov *overlay) { +// openOverlay shows ov and sends key presses to it. It ends any drag, so +// that releasing the button does not open the comment box behind the +// overlay. +func (m *model) openOverlay(ov *overlay) { m.overlay, m.mode = ov, modeOverlay + m.drag = dragState{} } -// closeOverlay closes the overlay and gives the keys back to the panes. +// closeOverlay closes the overlay and sends key presses to the panes +// again. Closing the search stops the background highlighting of its +// preview. func (m *model) closeOverlay() { + if ov := m.overlay; ov != nil && ov.stop != nil { + ov.stop() + } + if ov := m.overlay; ov != nil && ov.kind == overlaySearch && ov.search.preview != nil && ov.search.preview.file != nil { + // The preview was the last file rendered, so the cache holds its + // highlighter. A pane that shows the same file starts it again + // for the lines that it draws. + m.cache.StopHighlighting() + } m.overlay, m.mode = nil, modeNormal - m.stopGrep(fmt.Errorf("code search: the search closes: %w", context.Canceled)) + m.stopGrep() } -// openInput opens a prompt of kind with the text value. +// openInput opens a prompt of the given kind, with value as its initial +// text. func (m *model) openInput(kind overlayKind, title, value string) { ov := newOverlay(kind, title) ov.input.SetValue(value) - m.open(ov) + m.openOverlay(ov) } -// openConfirmQuit asks the user to confirm a quit that loses comments. -func (m *model) openConfirmQuit() { +// openQuit asks the user to confirm quitting, which discards the comments. +func (m *model) openQuit() { title := plural(m.comments.Len(), "unsent comment") + " will be lost" - m.open(newOverlay(overlayQuit, title)) + m.openOverlay(newOverlay(overlayQuit, title)) } -// updateOverlay handles a key while an overlay is open. +// updateOverlay handles a key press while an overlay is open. In the +// search, it renders the preview of the selected result right away, so +// that the screen is drawn again while its colours arrive. func (m *model) updateOverlay(msg tea.KeyPressMsg) tea.Cmd { + cmd := m.updateOverlayKey(msg) + if ov := m.overlay; ov != nil && ov.kind == overlaySearch { + m.searchPreview(m.searchInner()) + } + return cmd +} + +// updateOverlayKey does the work of updateOverlay. +func (m *model) updateOverlayKey(msg tea.KeyPressMsg) tea.Cmd { ov, k := m.overlay, overlayKeys switch ov.kind { case overlayQuit: return m.updateQuit(msg) case overlayComments: - return m.updateCommentList(msg) + return m.updateComments(msg) case overlayCommits: return m.updateCommitPicker(msg) case overlaySearch: pv := ov.search.preview switch { - case key.Matches(msg, k.Flip): - ov.search.code = !ov.search.code - ov.showSearch() + case key.Matches(msg, k.ToggleSearchMode): + ov.search.grep = !ov.search.grep + ov.setSearchItems() return nil - case key.Matches(msg, k.PreviewDown) && pv != nil: - pv.scroll(pickerPage) + case key.Matches(msg, k.ScrollPreviewDown) && pv != nil: + pv.scroll(itemsPage) return nil - case key.Matches(msg, k.PreviewUp) && pv != nil: - pv.scroll(-pickerPage) + case key.Matches(msg, k.ScrollPreviewUp) && pv != nil: + pv.scroll(-itemsPage) return nil } } @@ -200,39 +233,47 @@ func (m *model) updateOverlay(msg tea.KeyPressMsg) tea.Cmd { m.closeOverlay() return nil case key.Matches(msg, k.Choose): - return m.chooseOverlay() - case key.Matches(msg, k.Up): + return m.choose() + case ov.isCursorKey(msg, k.Up): ov.move(-1) return nil - case key.Matches(msg, k.Down): + case ov.isCursorKey(msg, k.Down): ov.move(1) return nil } if !ov.hasInput() { - return m.updateList(msg) + m.updateItems(msg) + return nil } - if ov.kind == overlayGoto { + if ov.kind == overlayGotoLine { if !isLineKey(msg) { return nil } - // "$" stands alone, and a digit after "$" starts a number. + // "$" replaces any number, and a digit typed after "$" starts a + // new number. if msg.Text == "$" || (msg.Text != "" && ov.input.Value() == "$") { ov.input.SetValue("") } } var cmd tea.Cmd - before := ov.input.Value() + prev := ov.input.Value() ov.input, cmd = ov.input.Update(msg) - if ov.input.Value() != before { + if ov.input.Value() != prev { return tea.Batch(cmd, m.inputChanged()) } return cmd } -// updateQuit handles a key in the prompt before a quit, as herdr-reviewr -// does. The key of QuitDiscard quits. The key of Quit does nothing, so a -// held q does not quit. The keys of Send and Copy close the prompt and -// send or copy. Any other key closes the prompt. +// isCursorKey reports whether msg matches the cursor binding b. In an +// overlay with a text input, the letter keys of b type text instead. +func (ov *overlay) isCursorKey(msg tea.KeyPressMsg, b key.Binding) bool { + return key.Matches(msg, b) && (!ov.hasInput() || !slices.Contains(letterMoveKeys, msg.String())) +} + +// updateQuit handles a key press in the quit prompt. The QuitDiscard key +// quits. The Quit key does nothing, so that holding q down does not quit. +// The Send and Copy keys close the prompt and send or copy the comments. +// Any other key just closes the prompt. func (m *model) updateQuit(msg tea.KeyPressMsg) tea.Cmd { k := m.keys switch { @@ -251,43 +292,38 @@ func (m *model) updateQuit(msg tea.KeyPressMsg) tea.Cmd { return nil } -// updateList handles a key in an overlay that has no input. In the agent -// picker, the digits 1 to 9 move the cursor to an agent, as in -// herdr-reviewr. Only enter sends. -func (m *model) updateList(msg tea.KeyPressMsg) tea.Cmd { - ov, k, s := m.overlay, overlayKeys, msg.String() +// updateItems handles a key press in an overlay without a text input. In +// the agent picker, the digits 1 to 9 move the cursor to that agent; only +// Enter sends. +func (m *model) updateItems(msg tea.KeyPressMsg) { + ov, s := m.overlay, msg.String() digit := len(s) == 1 && s[0] >= '1' && s[0] <= '9' - switch { - case key.Matches(msg, k.ListDown): - ov.move(1) - case key.Matches(msg, k.ListUp): - ov.move(-1) - case ov.kind == overlayAgents && digit: + if ov.kind == overlayAgents && digit { if i := int(s[0] - '1'); i < len(ov.shown) { ov.cur = i } } - return nil } -// inputChanged updates the overlay after a change of its input. +// inputChanged updates the overlay after its input text changes. func (m *model) inputChanged() tea.Cmd { ov := m.overlay switch ov.kind { case overlaySearch: - return m.runSearch() + return m.startGrep() case overlayBase: ov.filter() } return nil } -// chooseOverlay acts on the selected item or on the input. -func (m *model) chooseOverlay() tea.Cmd { +// choose acts on the selected item or on the input text, depending on the +// kind of overlay. +func (m *model) choose() tea.Cmd { ov := m.overlay - it := ov.selected() + it := ov.curItem() switch ov.kind { - case overlayGoto: + case overlayGotoLine: m.closeOverlay() m.gotoLine(ov.input.Value()) case overlaySearch: @@ -303,7 +339,10 @@ func (m *model) chooseOverlay() tea.Cmd { m.flash("no such ref: " + ref) return nil } - if err := m.repo.SaveBase(m.ctx, ref); err != nil { + ctx, done := m.gitContext("base save") + err := m.repo.SaveBase(ctx, ref) + done() + if err != nil { m.flash("base save failed: " + err.Error()) return nil } @@ -314,7 +353,7 @@ func (m *model) chooseOverlay() tea.Cmd { return nil } m.closeOverlay() - m.from, m.to = ov.commitRange(it) + m.oldest, m.newest = ov.commitRange(it) m.setScope(scopeCommits) case overlayComments: m.closeOverlay() @@ -327,44 +366,52 @@ func (m *model) chooseOverlay() tea.Cmd { case overlayAgents: m.closeOverlay() if a, ok := ov.agent(it); ok { - return m.deliver(a) + return m.pasteReview(a) } } - m.ensureVisible() + m.scrollToCursor() return nil } -// overlayBox renders the box of the overlay, and returns its position. -// Row i of the list shows at line listY+i of the box, for mouse hits. -func (m *model) overlayBox() (box string, x, y, listY int) { +// searchInner returns the width of the search box inside its border and +// padding. +func (m *model) searchInner() int { return m.w - 8 } + +// overlayBox renders the overlay box and returns its screen position. +// Row i of the list is at line itemsY+i of the box, which mouse clicks use. +func (m *model) overlayBox() (box string, x, y, itemsY int) { ov := m.overlay w := min(max(m.w*2/3, 50), m.w-4) if ov.kind == overlaySearch { - w = m.w - 4 + w = m.searchInner() + 4 } inner := w - 4 - // The title takes one line, for listY. + // The title takes one line, which itemsY accounts for. lines := []string{truncate(headerStyle.Render(ov.title), inner)} switch ov.kind { case overlayQuit: lines = append(lines, "", dimStyle.Render(m.quitHint())) case overlaySearch: - lines, listY = m.searchLines(inner) + lines, itemsY = m.searchLines(inner) default: if ov.hasInput() { ov.input.SetWidth(inner - 2) lines = append(lines, ov.input.View()) } - rows, start := m.overlayRows(inner, m.h-10) - listY = len(lines) + 1 - start // 1 for the border + rows, start := m.overlayItems(inner, m.h-10) + itemsY = len(lines) + 1 - start // + 1 for the top border lines = append(lines, rows...) switch ov.kind { case overlayComments: - lines = append(lines, "", truncate(dimStyle.Render(m.listHint()), inner)) + lines = append(lines, "", truncate(dimStyle.Render(m.commentsHint()), inner)) case overlayCommits: lines = append(lines, "", truncate(dimStyle.Render(m.commitHint()), inner)) } } + // On a short terminal, cut the content so that the whole box, with its + // borders, stays on the screen. + lines = strings.Split(lipgloss.NewStyle().Width(inner).Render(strings.Join(lines, "\n")), "\n") + lines = lines[:min(len(lines), max(m.h-2, 1))] box = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(dimColor). @@ -372,30 +419,31 @@ func (m *model) overlayBox() (box string, x, y, listY int) { Width(w). Render(strings.Join(lines, "\n")) x = (m.w - lipgloss.Width(box)) / 2 - y = max((m.h-lipgloss.Height(box))/3, 1) - return box, x, y, listY + // The box leaves the header line free if there is room. + free := m.h - lipgloss.Height(box) + y = max(free/3, min(free, 1), 0) + return box, x, y, itemsY } -// quitHint returns the keys of the prompt before a quit, as the footer -// of herdr-reviewr shows them. +// quitHint returns the key hints for the quit prompt. func (m *model) quitHint() string { k, n := m.keys, m.comments.Len() return fmt.Sprintf("%s quit (%d pending) · esc cancel · %s send %d · %s copy", firstKey(k.QuitDiscard), n, firstKey(k.Send), n, firstKey(k.Copy)) } -// overlayRows renders at most limit rows of the list of the overlay, -// inner columns wide. start is the index of the first row. -func (m *model) overlayRows(inner, limit int) (rows []string, start int) { +// overlayItems renders at most limit rows of the overlay's list, inner +// columns wide. start is the index of the first rendered row. +func (m *model) overlayItems(inner, limit int) (rows []string, start int) { ov := m.overlay maxRows := min(len(ov.shown), max(limit, 1)) start = max(0, ov.cur-maxRows+1) - ov.rowsFrom, ov.rowsTo = start, start+maxRows + ov.itemsLo, ov.itemsHi = start, start+maxRows for i := start; i < len(ov.shown) && i < start+maxRows; i++ { it := ov.items[ov.shown[i]] - if !ov.pickable(i) { + if !ov.canHaveCursor(i) { style := dimStyle - if it.head { + if it.isHeader { style = headerStyle } rows = append(rows, " "+truncate(style.Render(termtext.Quote(it.label)), inner-2)) @@ -403,15 +451,15 @@ func (m *model) overlayRows(inner, limit int) (rows []string, start int) { } tag := "" if it.tag != "" { - tag = " " + cursorStyle.Render(it.tag) + tag = " " + accentStyle.Render(it.tag) } - if ov.kind == overlayCommits && ov.mark == ov.shown[i] { - tag += " " + cursorStyle.Render("range start") + if ov.kind == overlayCommits && ov.rangeStart == ov.shown[i] { + tag += " " + accentStyle.Render("range start") } text := termtext.Quote(it.label) + " " + dimStyle.Render(termtext.Quote(it.detail)) + tag row := truncate(text, inner-2) if i == ov.cur { - row = m.selStyle().Width(inner).Render("▶ " + row) + row = m.highlightStyle().Width(inner).Render("▶ " + row) } else { row = " " + row } @@ -419,16 +467,18 @@ func (m *model) overlayRows(inner, limit int) (rows []string, start int) { } switch { case ov.search.err != "": - rows = append(rows, delStyle.Render(" "+termtext.Quote(ov.search.err))) + rows = append(rows, deletedStyle.Render(" "+termtext.Quote(ov.search.err))) + case ov.loading: + rows = append(rows, dimStyle.Render(" loading commits…")) case len(ov.shown) == 0 && ov.kind == overlayCommits: rows = append(rows, dimStyle.Render(" no commits yet")) case len(ov.shown) == 0 && ov.kind == overlaySearch: - // As in herdr-reviewr, an empty query finds no code, and that is - // not a miss. - if !ov.search.code || ov.input.Value() != "" { + // An empty code search query matches nothing, which is not worth + // reporting. + if !ov.search.grep || ov.input.Value() != "" { rows = append(rows, dimStyle.Render(" no matches")) } - case len(ov.shown) == 0 && ov.kind != overlayGoto: + case len(ov.shown) == 0 && ov.kind != overlayGotoLine: rows = append(rows, dimStyle.Render(" no matches")) } return rows, start diff --git a/tui/palette.go b/tui/palette.go index c81503f..7f45221 100644 --- a/tui/palette.go +++ b/tui/palette.go @@ -9,16 +9,23 @@ import ( "github.com/charmbracelet/x/ansi" ) -// requestPalette asks the terminal for its ANSI red and green (OSC 4), as -// the system theme of pi does. Bubble Tea has no command for this request. -// Bubble Tea gives the replies to Update as unknown OSC events. +// The ANSI palette indices of red and green, which [requestPalette] asks +// for. +const ( + paletteRed = 1 + paletteGreen = 2 +) + +// requestPalette asks the terminal for its ANSI red and green (OSC 4). +// Bubble Tea has no command for this, and passes the replies to Update as +// unknown OSC events. func requestPalette() tea.Msg { return tea.RawMsg{Msg: "\x1b]4;1;?\x07\x1b]4;2;?\x07"} } -// paletteReply returns the index and the colour in an OSC 4 reply, such -// as "\x1b]4;1;rgb:ffff/0000/0000\x07". If s is not such a reply, ok is -// false. +// paletteReply returns the palette index and colour in an OSC 4 reply, +// such as "\x1b]4;1;rgb:ffff/0000/0000\x07". ok is false if s is not such +// a reply. func paletteReply(s string) (i int, c color.Color, ok bool) { s, ok = strings.CutPrefix(s, "\x1b]4;") if !ok { @@ -39,16 +46,17 @@ func paletteReply(s string) (i int, c color.Color, ok bool) { return i, c, true } -// onPalette takes the red or the green from an OSC 4 reply. +// onPalette stores the red or green colour from an OSC 4 reply and renders +// again if it changed. func (m *model) onPalette(reply string) { i, c, ok := paletteReply(reply) if !ok { return } switch { - case i == 1 && !sameColor(m.red, c): + case i == paletteRed && !sameColor(m.red, c): m.red = c - case i == 2 && !sameColor(m.green, c): + case i == paletteGreen && !sameColor(m.green, c): m.green = c default: return diff --git a/tui/pr.go b/tui/pr.go index e43ee61..400c792 100644 --- a/tui/pr.go +++ b/tui/pr.go @@ -13,57 +13,58 @@ import ( "charm.land/lipgloss/v2" "github.com/rednafi/rexdiff/diff" + "github.com/rednafi/rexdiff/internal/xctx" "github.com/rednafi/rexdiff/vcs" ) -// The PR tab shows the pull request of the branch. The tab cannot change -// the pull request. +// The PR tab shows the pull request for the current branch. It is +// read-only. -// The times of the reads of the pull request, as in herdr-reviewr. +// Timing for fetching the pull request. const ( - // prPoll is the time between two reads while the PR tab shows. A - // change on the forge has no local sign. - prPoll = time.Minute - // prTimeout is the longest time of a read. + // prInterval is the time between fetches while the PR tab is shown. + // Polling is needed because changes on the forge leave no trace in + // the local repository. + prInterval = time.Minute + // prTimeout limits how long one fetch can take. prTimeout = time.Minute ) -// prFetch follows the reads of the pull request. +// prFetch tracks fetches of the pull request. type prFetch struct { - gen int // the read whose result shows. Other reads are stale. - inFlight bool // the read gen runs - polled time.Time // the last request, for prPoll - cancel context.CancelCauseFunc - // trailing is true if an ambient request comes while a read runs. - // Then one more read follows that read. + gen int // identifies the latest fetch; results of older fetches are stale + inFlight bool // fetch gen is in progress + polled time.Time // the time of the last request, for prInterval + cancel context.CancelFunc + // trailing is true if a non-forced request arrived during a fetch, + // so another fetch follows when it finishes. trailing bool - branch string // the branch at the last change of the refs - seen bool // the watcher reports the refs once + branch string // the branch at the last ref change + seen bool // the watcher has reported refs at least once } -// requestPR asks for a read of the pull request. A forced request starts -// a read at once. An ambient request during a read starts one more read -// after it. A read that hangs ends after prTimeout. -func (m *model) requestPR(forced bool) tea.Cmd { - f := &m.prf +// requestPR asks for the pull request to be fetched. A forced request +// starts a fetch at once. A non-forced request made during a fetch starts +// another fetch after it finishes. A fetch that hangs is cancelled after +// prTimeout. +func (m *model) requestPR(force bool) tea.Cmd { + f := &m.pr.fetch f.polled = time.Now() - if !forced && f.inFlight { + if !force && f.inFlight { f.trailing = true return nil } - return m.loadPR() + return m.fetchPR() } -// loadPR stops the read that runs, and returns a command that reads the -// pull request of the branch. -func (m *model) loadPR() tea.Cmd { - f := &m.prf +// fetchPR cancels any fetch in progress and returns a command that +// fetches the pull request for the branch. +func (m *model) fetchPR() tea.Cmd { + f := &m.pr.fetch if f.cancel != nil { - f.cancel(fmt.Errorf("pull request read: a newer read replaces it: %w", context.Canceled)) + f.cancel() } - ctx, cancel := context.WithCancelCause(m.ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, prTimeout, - fmt.Errorf("pull request read: %v timeout: %w", prTimeout, context.DeadlineExceeded)) + ctx, cancel := xctx.WithTimeout(m.ctx, prTimeout, "pull request read") f.gen++ f.inFlight, f.trailing, f.cancel = true, false, cancel repo, gen := m.repo, f.gen @@ -73,30 +74,31 @@ func (m *model) loadPR() tea.Cmd { Azure: m.cfg.AzureHost, } return func() tea.Msg { - defer cancelTimeout() - defer cancel(fmt.Errorf("pull request read: done: %w", context.Canceled)) - pr, err := repo.PullRequest(ctx, hosts) + defer cancel() + pr, err := repo.PR(ctx, hosts) return prMsg{gen: gen, pr: pr, err: err} } } -// refsChanged reads the pull request again after a change of the refs. -// After a switch to another branch, the PR tab is blank until the read -// ends. A detached HEAD keeps the branch, so a rebase is not a switch. -func (m *model) refsChanged() tea.Cmd { - f := &m.prf - b, err := m.repo.Branch(m.ctx) +// reloadPR fetches the pull request again after the refs change. After a +// branch switch, which a detached HEAD during a rebase is not, the PR tab +// is blank until the fetch finishes. +func (m *model) reloadPR() tea.Cmd { + f := &m.pr.fetch + ctx, done := m.gitContext("branch") + b, err := m.repo.Branch(ctx) + done() if err != nil || b == "HEAD" { b = f.branch } - old := f.branch + oldBranch := f.branch f.branch = b - if !f.seen { // the first report is the start, and Init reads the PR + if !f.seen { // the first report comes at startup, and Init already fetches the PR f.seen = true return nil } - if old != "" && b != old { - m.pr = prState{} + if oldBranch != "" && b != oldBranch { + m.pr.pr, m.pr.err = nil, "" cmd := m.requestPR(true) m.renderPR() return cmd @@ -104,40 +106,23 @@ func (m *model) refsChanged() tea.Cmd { return m.requestPR(false) } -// prStateStyle returns the style that shows the state of a pull request. +// prStateStyle returns the style used to show the pull request state s. func prStateStyle(s vcs.PRState) lipgloss.Style { switch s { case vcs.PROpen: - return addStyle + return addedStyle case vcs.PRMerged: return headerStyle case vcs.PRClosed: - return delStyle + return deletedStyle } return dimStyle } -// checkSummary counts the checks that pass, fail, or are pending, and -// returns the names of the checks that fail. A skipped check counts as a -// pass. -func checkSummary(checks []vcs.Check) (pass, fail, pend int, failed []string) { - for _, c := range checks { - switch c.Result { - case vcs.ResultPass, vcs.ResultSkip: - pass++ - case vcs.ResultFail: - fail++ - failed = append(failed, c.Name) - default: - pend++ - } - } - return pass, fail, pend, failed -} - -// renderPR shows the pull request in the PR tab. +// renderPR renders the pull request, or a note explaining its absence, +// into the PR tab. func (m *model) renderPR() { - p := &m.prPane + p := &m.pr.pane p.path, p.note = "", "" pr := m.pr.pr switch { @@ -146,61 +131,190 @@ func (m *model) renderPR() { p.set(nil, nil) return case pr == nil: - if !m.prf.inFlight { - p.note = vcs.ErrNoPullRequest.Error() + if !m.pr.fetch.inFlight { + p.note = vcs.ErrNoPR.Error() } p.set(nil, nil) return } - md := m.markdown(m.codeWidth() - 4) - lines := append([]string{""}, prHeader(pr)...) - lines = append(lines, checkLines(pr.Checks)...) + r := m.markdownRenderer(m.codeWidth() - 4) + lines := slices.Concat([]string{""}, prHeader(pr)) + lines = append(lines, checkLines(pr)...) lines = append(lines, "") + // A poll usually finds the same texts, so the texts rendered last time + // come from a cache. + mc := &m.markdown + cached := mc.pr + if r == nil || mc.r != r { + cached = nil + } + used := map[string][]string{} + truncated := dimStyle.Render(" … truncated · " + firstKey(m.keys.OpenPR) + " to open in browser") + work := 0 // the work of the texts rendered so far, as markdownWork counts it + render := func(text string) []string { + src, cut := m.prText(text) + note, w := prTextNote(src) + if note == "" && work+w > maxMarkdownWork { + note = notePRTextTooBig + } + out, ok := cached[src] + switch { + case note != "": + out = sourceLines(note, src, m.codeWidth()-4) + case !ok: + out = renderMarkdown(r, src) + } + if note == "" { + work += w + used[src] = out + } + if cut { + out = append(slices.Clip(out), "", truncated) + } + return out + } if strings.TrimSpace(pr.Body) != "" { - lines = append(lines, renderMarkdown(md, m.details(pr.Body))...) + lines = append(lines, render(pr.Body)...) } now := time.Now() for _, c := range pr.Comments { author := headerStyle.Render(c.Author) + " " + dimStyle.Render(age(c.When, now)) lines = append(lines, "", " "+author) - lines = append(lines, renderMarkdown(md, m.details(c.Body))...) + lines = append(lines, render(c.Body)...) + } + if r != nil && mc.r == r { + mc.pr = used } p.set(lines, make([]diff.Row, len(lines))) } -// detailsRE matches a
block with its summary and its body. -// detailsOpenRE matches the start of a block. +// Limits on the PR text that rexdiff renders, because a long or hostile +// body would make each poll slow. A body or comment longer than +// maxPRTextBytes is cut, with a note to open the PR in the browser. Only +// the first maxDetails
tags in each text become summary lines. +// A text is also subject to the limits on a block of Markdown in a file, +// and the texts of a PR to the limit on the work of a file: a text over a +// limit shows as its source. +const ( + maxPRTextBytes = 16 << 10 + maxDetails = 64 +) + +// Notes for PR text that rexdiff does not render. +const ( + notePRTextTooBig = " text too large to render · source below" + notePRTextTooDeep = " text nested too deeply to render · source below" +) + +// prTextNote returns the note to show instead of the rendered PR text +// src, or "" if it can be rendered. work is the work of rendering it, as +// markdownWork counts it, or 0 if it is not rendered. +func prTextNote(src string) (note string, work int) { + switch note, work := blockNote(src); note { + case "": + return "", work + case noteBlockTooDeep: + return notePRTextTooDeep, 0 + } + return notePRTextTooBig, 0 +} + +// prText returns the Markdown to render for src, a PR body or comment, +// with its
blocks replaced by renderDetails. A src longer than +// maxPRTextBytes is cut at the last line break before the limit, and cut +// is true. +func (m *model) prText(src string) (md string, cut bool) { + if len(src) > maxPRTextBytes { + src, cut = strings.ToValidUTF8(src[:maxPRTextBytes], ""), true + if i := strings.LastIndexByte(src, '\n'); i > 0 { + src = src[:i] + } + } + return m.renderDetails(src), cut +} + +// detailsTagRE matches an opening or closing
tag. summaryRE +// matches the summary at the start of a block, capturing its text. var ( - detailsRE = regexp.MustCompile( - `(?is)\A]*>\s*]*>(.*?)
(.*?)
`) - detailsOpenRE = regexp.MustCompile(`(?i)]*>`) + detailsTagRE = regexp.MustCompile(`(?i)]*>|
`) + summaryRE = regexp.MustCompile(`(?is)\A\s*]*>(.*?)`) ) -// details returns src with each
block as its summary line, as -// herdr-reviewr shows it. The line starts with ▸ for a closed block, and -// with ▾ for an open block. The body of an open block follows the line. -// The last block is done first, so an inner block is done before the -// block that holds it. -func (m *model) details(src string) string { - for _, o := range slices.Backward(detailsOpenRE.FindAllStringIndex(src, -1)) { - b := src[o[0]:] - sub := detailsRE.FindStringSubmatchIndex(b) - if sub == nil { +// renderDetails returns src with each
block replaced by its +// summary line. The line starts with ▸ for a closed block and ▾ for an +// open block. The body of an open block follows the line. A block must +// start with a . A nested block is replaced before the block +// around it, and a closing tag ends the innermost open block that has a +// summary. Tags that do not form a block, and the tags after the first +// maxDetails opening tags, stay as they are. +func (m *model) renderDetails(src string) string { + // open holds the opening tag and the text so far of each block that + // has not been closed, innermost last. Text outside any block goes in + // out. + type block struct { + tag string + text strings.Builder + } + var open []*block + var out strings.Builder + emit := func(s string) { + if len(open) == 0 { + out.WriteString(s) + } else { + open[len(open)-1].text.WriteString(s) + } + } + at, opened := 0, 0 + for _, t := range detailsTagRE.FindAllStringIndex(src, -1) { + tag := src[t[0]:t[1]] + closing := strings.HasPrefix(tag, " 0 { + b := open[len(open)-1] + open = open[:len(open)-1] + text := b.text.String() + if sub := summaryRE.FindStringSubmatchIndex(text); sub != nil { + emit(m.detailsLine(text[sub[2]:sub[3]], text[sub[1]:])) + done = true + } else { + emit(b.tag + text) // not a block, so its tag is text + } + } + if !done { + emit(tag) } - src = src[:o[0]] + block + b[sub[1]:] } - return src + emit(src[at:]) + for _, b := range open { // blocks that are never closed are text + out.WriteString(b.tag) + out.WriteString(b.text.String()) + } + return out.String() } -// age returns the time from when to now, as herdr-reviewr shows it, for -// example "5m" or "3d". when is an RFC 3339 time. A bad time gives "". A -// time after now gives "0s". +// detailsLine returns the Markdown that replaces a
block with +// the given summary and body. +func (m *model) detailsLine(summary, body string) string { + summary = strings.Join(strings.Fields(summary), " ") + if m.pr.detailsExpanded { + return "\n\n▾ " + summary + "\n\n" + body + "\n\n" + } + return "\n\n▸ " + summary + "\n\n" +} + +// age returns the time elapsed from when to now in short form, such as +// "5m" or "3d". when is an RFC 3339 time. It returns "" if when cannot be +// parsed, and "0s" if when is after now. func age(when string, now time.Time) string { t, err := time.Parse(time.RFC3339, when) if err != nil { @@ -219,8 +333,8 @@ func age(when string, now time.Time) string { return fmt.Sprintf("%ds", int64(d/time.Second)) } -// prHeader returns the title line and the state line of pr. -func prHeader(pr *vcs.PullRequest) []string { +// prHeader returns the title line and the status line of pr. +func prHeader(pr *vcs.PR) []string { title := boldStyle.Render(pr.Title) number := dimStyle.Render(fmt.Sprintf("#%d", pr.Number)) state := prStateStyle(pr.State).Render("● " + string(pr.State)) @@ -237,54 +351,63 @@ func prHeader(pr *vcs.PullRequest) []string { } } -// checkLines returns the counts of the checks, and the names of the -// checks that fail. With no checks, it returns no lines. -func checkLines(checks []vcs.Check) []string { - if len(checks) == 0 { +// checkLines returns a line with the counts of all the checks, followed +// by the names of the failed checks that the pull request lists and a +// note of how many failed checks it leaves out. A skipped check counts as +// passed. It returns nil if there are no checks. +func checkLines(pr *vcs.PR) []string { + t := pr.Tally + if t.Total() == 0 { return nil } - pass, fail, pend, failed := checkSummary(checks) lines := []string{fmt.Sprintf( " checks: %s %s %s", - addStyle.Render(fmt.Sprintf("✓ %d", pass)), - delStyle.Render(fmt.Sprintf("✗ %d", fail)), - noteStyle.Render(fmt.Sprintf("◌ %d", pend)), + addedStyle.Render(fmt.Sprintf("✓ %d", t.Pass+t.Skip)), + deletedStyle.Render(fmt.Sprintf("✗ %d", t.Fail)), + noteStyle.Render(fmt.Sprintf("◌ %d", t.Pending)), )} - for _, f := range failed { - lines = append(lines, " "+delStyle.Render("✗ ")+f) + listed := 0 + for _, c := range pr.Checks { + if c.Result == vcs.ResultFail { + lines = append(lines, " "+deletedStyle.Render("✗ ")+c.Name) + listed++ + } + } + if n := t.Fail - listed; n > 0 { + lines = append(lines, dimStyle.Render(" … "+plural(n, "more failed check"))) } return lines } -// onPR shows the pull request that loadPR reads, unless a later read -// replaces it. After a read with a trailing request, onPR starts the next -// read. +// onPR shows the pull request that fetchPR fetched, unless a newer fetch +// has started since. If a request arrived during the fetch, it starts +// another fetch. func (m *model) onPR(msg prMsg) tea.Cmd { - f := &m.prf + f := &m.pr.fetch if msg.gen != f.gen { return nil } f.inFlight, f.cancel = false, nil var next tea.Cmd if f.trailing { - next = m.loadPR() + next = m.fetchPR() } - // As in herdr-reviewr, a failed read keeps the last good pull request. - // The end of the pull request is not a failure. - if msg.err != nil && m.pr.pr != nil && !errors.Is(msg.err, vcs.ErrNoPullRequest) { + // If a fetch fails, keep showing the last pull request that was + // fetched. ErrNoPR does not count as a failure. + if msg.err != nil && m.pr.pr != nil && !errors.Is(msg.err, vcs.ErrNoPR) { m.flash("refresh failed: " + msg.err.Error()) m.renderPR() return next } - m.pr = prState{pr: msg.pr} + m.pr.pr, m.pr.err = msg.pr, "" if msg.err != nil { m.pr.err = msg.err.Error() } m.renderPR() - // prPlace is from before a restart. - if p := m.prPlace; p != nil && msg.err == nil && msg.pr != nil { - m.prPlace = nil - m.prPane.restore(*p) + // Restore the scroll position saved before a restart. + if p := m.pr.place; p != nil && msg.err == nil && msg.pr != nil { + m.pr.place = nil + m.pr.pane.restore(*p) } return next } diff --git a/tui/preview.go b/tui/preview.go index ca4b9af..1c34673 100644 --- a/tui/preview.go +++ b/tui/preview.go @@ -9,44 +9,45 @@ import ( "github.com/rednafi/rexdiff/diff" ) -// searchShare is the percent of the space of the search that the results -// take. The preview takes the rest. The value comes from herdr-reviewr. -const searchShare = 50 +// searchPct is the percentage of the search height used for the results. +// The preview gets the rest. +const searchPct = 50 -// preview is the file of the selected result of the search. +// preview shows the file of the selected search result. type preview struct { path string - line int // the line of the code match, or 0 for a file + line int // the line of the code match, or 0 for a file name match width int - lines []string // the file with line numbers, or one note - hit int // the index in lines of the line of the match, or -1 - top int // the first line that shows - // center is true until the first draw. That draw puts the match in - // the middle. + lines []string // a single note, or nil + file *diff.Diff // the file rendered with line numbers, or nil + hit int // the index of the matching line, or -1 + top int // the first visible line + // center is true until the first draw, which puts the match in the + // middle. center bool } -// searchLines renders the search, inner columns wide, as herdr-reviewr -// lays it out. The first line has the input and the chips. Below it are -// the results, a rule with the name of the file, and the preview of the -// file. Row i of the list shows at line listY+i of the box. -func (m *model) searchLines(inner int) (lines []string, listY int) { +// searchLines renders the search, inner columns wide. The first line holds +// the input and the mode chips. Below it come the results, a rule with the +// file name, and the preview of the file. Row i of the list is at line +// itemsY+i of the box. +func (m *model) searchLines(inner int) (lines []string, itemsY int) { ov := m.overlay lines = []string{ov.searchBand(inner)} - // avail is the height of the results and the preview. It leaves out - // the box, the band, and the rule of the preview. + // avail is the height left for the results and the preview, after the + // box border, the input line and the preview rule. avail := max(m.h-4, 6) - 2 - results := splitAxis(avail, searchShare) + results := shareOf(avail, searchPct) lines = append(lines, paneRule("results", inner)) - rows, start := m.overlayRows(inner, results-1) - listY = len(lines) + 1 - start // 1 for the border + rows, start := m.overlayItems(inner, results-1) + itemsY = len(lines) + 1 - start // + 1 for the top border lines = append(lines, rows[:min(len(rows), results-1)]...) for len(lines) < results+1 { lines = append(lines, "") } pv := m.searchPreview(inner) h := avail - results - ov.search.previewY = len(lines) + 1 // 1 for the border + ov.search.previewY = len(lines) + 1 // + 1 for the top border if pv == nil { lines = append(lines, paneRule("preview", inner), dimStyle.Render(" no preview")) } else { @@ -56,41 +57,41 @@ func (m *model) searchLines(inner int) (lines []string, listY int) { for len(lines) < avail+2 { lines = append(lines, "") } - return lines, listY + return lines, itemsY } -// splitAxis returns the part of n that pct percent takes, as -// herdr-reviewr splits a pane. Each part gets at least 3 if n has room. -func splitAxis(n, pct int) int { +// shareOf returns pct percent of n. If n is 6 or more, both the share and +// the rest are at least 3. +func shareOf(n, pct int) int { if n < 6 { return n / 2 } return min(max(n*pct/100, 3), n-3) } -// paneRule returns a rule "─ label ───" with label, w columns wide. +// paneRule returns a rule "─ label ───" containing label, w columns wide. func paneRule(label string, w int) string { label = " " + label + " " rest := max(w-lipgloss.Width(label)-1, 0) return truncate(dimStyle.Render("─")+label+dimStyle.Render(strings.Repeat("─", rest)), w) } -// searchBand returns the first line of the search, inner columns wide. -// It has the input, and the chips at the right end. -func (o *overlay) searchBand(inner int) string { - chips := o.chips() - o.input.SetWidth(max(inner-lipgloss.Width(chips)-4, 1)) - in := o.input.View() +// searchBand returns the first line of the search, inner columns wide: the +// input, with the mode chips at the right end. +func (ov *overlay) searchBand(inner int) string { + chips := ov.chips() + ov.input.SetWidth(max(inner-lipgloss.Width(chips)-4, 1)) + in := ov.input.View() pad := max(inner-lipgloss.Width(in)-lipgloss.Width(chips), 1) return truncate(in+strings.Repeat(" ", pad)+chips, inner) } -// searchPreview returns the preview of the selected result of the search, -// width columns wide, or nil if no result is selected. It reads the file -// again only for another result or another width. +// searchPreview returns the preview of the selected search result, width +// columns wide, or nil if no result is selected. It reads the file again +// only when the result or the width changes. func (m *model) searchPreview(width int) *preview { s := &m.overlay.search - it := m.overlay.selected() + it := m.overlay.curItem() if it == nil || it.id == "" { s.preview = nil return nil @@ -107,10 +108,9 @@ func (m *model) searchPreview(width int) *preview { case err != nil || content == "": pv.lines = []string{dimStyle.Render(" no preview")} default: - o := m.diffOptions(0) - o.Width, o.Wrap = width-2, false // 2 for the mark of the match + o := diff.FileOptions{Width: width - 2, Cache: &m.cache} // - 2 for the match marker d := diff.RenderFile(it.id, content, o) - pv.lines = d.Lines + pv.file = d if it.line > 0 { pv.hit = slices.IndexFunc(d.Rows, func(r diff.Row) bool { return r.New == it.line && !r.Continued @@ -121,25 +121,33 @@ func (m *model) searchPreview(width int) *preview { return pv } -// show returns the h lines of pv that show. The line of the match has a -// mark. At the first draw, the match is in the middle. +// show returns the h visible lines of pv, with a marker on the matching +// line. On the first draw, the match is centred. func (pv *preview) show(h int) []string { if pv.center { pv.top, pv.center = max(pv.hit-h/2, 0), false } - pv.top = max(min(pv.top, len(pv.lines)-h), 0) + n := len(pv.lines) + if pv.file != nil { + n = len(pv.file.Rows) + } + pv.top = clampTop(pv.top, n, h) var out []string - for i := pv.top; i < len(pv.lines) && len(out) < h; i++ { + for i := pv.top; i < n && len(out) < h; i++ { mark := " " if i == pv.hit { - mark = cursorStyle.Render("▶ ") + mark = accentStyle.Render("▶ ") + } + if pv.file != nil { + out = append(out, mark+pv.file.Line(i)) + } else { + out = append(out, mark+pv.lines[i]) } - out = append(out, mark+pv.lines[i]) } return out } -// scroll moves the preview of the search by d lines. +// scroll scrolls the search preview by d lines. func (pv *preview) scroll(d int) { pv.center = false pv.top = max(pv.top+d, 0) diff --git a/tui/release.go b/tui/release.go index 01b4120..a1ead31 100644 --- a/tui/release.go +++ b/tui/release.go @@ -6,25 +6,25 @@ import ( tea "charm.land/bubbletea/v2" ) -// The footer tells the user about a newer release. A key or a click -// starts the update to it in the background. Then rexdiff starts again in -// the same pane, with the comments and the place of the user. The update -// package installs the release. This file shows the state of the update. +// When a newer release is available, the footer announces it. A key press +// or a click installs it in the background, and rexdiff then restarts in +// the same pane, keeping the comments and the user's position. Package +// update does the installing. -// updatePhase is the step of the update of rexdiff. +// updatePhase is the stage that a self-update has reached. type updatePhase int const ( - phaseIdle updatePhase = iota - phaseUpdating // the release installs in the background - phaseUpdated // rexdiff restarts when the user is ready + updateIdle updatePhase = iota + updateRunning // the release is installing in the background + updateDone // rexdiff restarts once the user is ready ) -// checkRelease returns a command that asks for the latest release. Its -// message is a releaseMsg if the release is newer. A failure shows nothing, -// because rexdiff works the same without the check. -func (m *model) checkRelease() tea.Cmd { - u, ctx := m.updater, m.ctx +// fetchRelease returns a command that checks for the latest release. The +// command returns a releaseMsg if that release is newer, and nil otherwise, +// including on failure. +func (m *model) fetchRelease() tea.Cmd { + u, ctx := m.installer, m.ctx return func() tea.Msg { r, newer, err := u.Latest(ctx) if err != nil || !newer { @@ -34,51 +34,52 @@ func (m *model) checkRelease() tea.Cmd { } } -// releaseBadge returns the text of the footer about m.release. +// releaseBadge returns the footer text about m.release. func (m *model) releaseBadge() string { - switch m.phase { - case phaseUpdating: + switch m.updatePhase { + case updateRunning: return fmt.Sprintf("updating rexdiff to %s…", m.release.Tag) - case phaseUpdated: + case updateDone: return fmt.Sprintf("rexdiff %s is installed. It starts when you are done", m.release.Tag) } return fmt.Sprintf("rexdiff %s is out: press %s or click here to update", m.release.Tag, firstKey(m.keys.Update)) } -// startUpdate starts the update to the release in the background. +// startUpdate starts installing the new release in the background. It +// does nothing if there is no release or an update has already started. func (m *model) startUpdate() tea.Cmd { - if m.release == nil || m.phase != phaseIdle { + if m.release == nil || m.updatePhase != updateIdle { return nil } - m.phase = phaseUpdating - u, ctx, r := m.updater, m.ctx, *m.release + m.updatePhase = updateRunning + u, ctx, r := m.installer, m.ctx, *m.release return func() tea.Msg { return updatedMsg{err: u.Install(ctx, r)} } } -// onUpdated prepares the restart, or tells why the update fails. After -// a failure, the user can try again. +// onUpdated marks the update as done so that rexdiff can restart, or shows +// why the update failed. After a failure, the user can try again. func (m *model) onUpdated(msg updatedMsg) { if msg.err != nil { - m.phase = phaseIdle + m.updatePhase = updateIdle m.flash("update failed: " + msg.err.Error()) return } - m.phase = phaseUpdated + m.updatePhase = updateDone } -// restartIfReady saves the resume state for the restart, and keeps its -// file in m.resumePath. It reports whether rexdiff can quit for the -// restart. It waits while the user writes a comment or uses an overlay. -// It also waits while a send runs, and while pending (the command of the -// last message) runs. Thus no work is lost. -func (m *model) restartIfReady(pending tea.Cmd) bool { - if m.mode != modeNormal || pending != nil || m.sends > 0 { +// restartIfUpdated saves the state to restore after the restart and +// stores its path in m.resumePath. It reports whether rexdiff can quit to +// restart. It returns false while the user is writing a comment or using +// an overlay, while a review is being sent, and while hasPending reports +// that the command returned for the last message is still to run. +func (m *model) restartIfUpdated(hasPending bool) bool { + if m.mode != modeNormal || hasPending || m.sends > 0 { return false } path, err := m.saveResume() if err != nil { - m.phase, m.release = phaseIdle, nil + m.updatePhase, m.release = updateIdle, nil m.flash("updated, but the save of your comments failed: " + err.Error() + ". Open rexdiff again for the new version") return false diff --git a/tui/render.go b/tui/render.go index 02cec37..ab6a881 100644 --- a/tui/render.go +++ b/tui/render.go @@ -1,7 +1,6 @@ package tui -// This file turns files into the lines of a pane. It renders diffs, the -// file viewer, and Markdown. +// Rendering of pane lines: diffs, files in the Files tab, and Markdown. import ( "cmp" @@ -9,117 +8,179 @@ import ( "fmt" "iter" "regexp" + "runtime" "slices" "strings" + "sync" + "sync/atomic" "charm.land/glamour/v2" "github.com/charmbracelet/x/ansi" + "github.com/yuin/goldmark/util" "github.com/rednafi/rexdiff/diff" + "github.com/rednafi/rexdiff/internal/diffview" "github.com/rednafi/rexdiff/internal/termtext" "github.com/rednafi/rexdiff/vcs" ) -// gutter is the width of the column for the cursor and the marks, at the -// left of the main pane. -const gutter = 2 +// gutter is the width of the column at the left of the main pane that +// holds the cursor and other markers. +const gutter = 1 -// The sizes of the file list, as in herdr-reviewr. At a side, the list -// takes listSidePct of the screen width by default, and stays between -// listMinPct and listMaxSidePct. Above or below, the list takes -// listStackPct of the body rows by default, and stays between listMinPct -// and listMaxStackPct. +// Sizes of the file list. At the left or right, it takes navigatorSidePct +// of the screen width by default, kept between minNavigatorPct and +// maxNavigatorSidePct. Above or below, it takes navigatorStackPct of the +// body rows by default, kept between minNavigatorPct and +// maxNavigatorStackPct. const ( - listSidePct = 32 - listStackPct = 25 - listMinPct = 15 - listMaxSidePct = 60 - listMaxStackPct = 50 + navigatorSidePct = 22 + maxNavigatorSideWidth = 40 // caps the default width, unless minNavigatorPct requires more + navigatorStackPct = 25 + minNavigatorPct = 15 + maxNavigatorSidePct = 60 + maxNavigatorStackPct = 50 ) -// listShown reports whether the file list shows. The PR tab has no file -// list. -func (m *model) listShown() bool { return !m.listHidden && m.tab != tabPR } +// navigatorShown reports whether the file list is visible. The PR tab has +// no file list. +func (m *model) navigatorShown() bool { return !m.navigatorHidden && m.tab != tabPR } -// sideWidth returns the width of the file list at a side, in columns. -// Without a width in columns, the list takes listSidePct of the screen. -func (m *model) sideWidth() int { - return m.clampSide(cmp.Or(m.listW, m.w*listSidePct/100)) +// navigatorSideWidth returns the width in columns of the file list when it +// is at the left or right. The default is navigatorSidePct of the screen, +// at most maxNavigatorSideWidth, and always within the bounds of +// clampNavigatorWidth. +func (m *model) navigatorSideWidth() int { + return m.clampNavigatorWidth(cmp.Or(m.navigatorWidthSet, min(m.w*navigatorSidePct/100, maxNavigatorSideWidth))) } -// clampSide returns w within the bounds of the width of the file list at -// a side. -func (m *model) clampSide(w int) int { - return min(max(w, m.w*listMinPct/100), m.w*listMaxSidePct/100) +// clampNavigatorWidth clamps w to the allowed width of the file list when +// it is at the left or right. +func (m *model) clampNavigatorWidth(w int) int { + return min(max(w, m.w*minNavigatorPct/100), m.w*maxNavigatorSidePct/100) } -// listWidth returns the width of the file list, or 0 if the list does not -// show. -func (m *model) listWidth() int { +// navigatorWidth returns the width of the file list, or 0 if it is not +// visible. +func (m *model) navigatorWidth() int { switch { - case !m.listShown(): + case !m.navigatorShown(): return 0 - case m.listPos.stacked(): + case m.navigatorPosition.stacked(): return m.w } - return m.sideWidth() + return m.navigatorSideWidth() } -// listHeight returns the number of body rows that the file list takes -// above or below the main pane, with its rule. The body has h rows. It -// returns 0 if the list does not show there, or if the rule leaves no -// row for the list. -func (m *model) listHeight(h int) int { - if !m.listShown() || !m.listPos.stacked() { +// navigatorHeight returns the number of body rows, including its rule, +// that the file list takes above or below the main pane, for a body of h +// rows. It returns 0 if the file list is not above or below, or if the +// rule and title would leave no room for a file. +func (m *model) navigatorHeight(h int) int { + if !m.navigatorShown() || !m.navigatorPosition.stacked() { return 0 } - if lh := splitAxis(h, m.listPct); lh > 1 { + if lh := shareOf(h, m.navigatorPct); lh > 2 { return lh } return 0 } -// mainMinWidth is the width of the main pane, with its gutter, if the -// file list leaves less, or if the terminal is narrower. -const mainMinWidth = 24 +// minMainWidth is the smallest width of the main pane, including its +// gutter. The pane keeps this width even if the file list or a narrow +// terminal leaves less room. +const minMainWidth = 24 -// mainWidth returns the width of the main pane, with its gutter. +// mainWidth returns the width of the main pane, including its gutter. func (m *model) mainWidth() int { w := m.w - if lw := m.listWidth(); lw > 0 && !m.listPos.stacked() { - w -= lw + sepWidth + if m.navigatorShown() && !m.navigatorPosition.stacked() { + w -= m.navigatorSideWidth() + sepWidth } - return max(w, mainMinWidth) + return max(w, minMainWidth) } -// mainHeight returns the number of rows of the main pane. +// mainHeight returns the number of rows in the main pane. func (m *model) mainHeight() int { return m.mainHeightOf(m.bodyHeight()) } -// mainHeightOf returns the number of rows of the main pane in a body of h -// rows. -func (m *model) mainHeightOf(h int) int { return h - m.listHeight(h) } +// mainHeightOf returns the number of rows in the main pane, not counting +// its title, for a body of h rows. +func (m *model) mainHeightOf(h int) int { return max(h-m.navigatorHeight(h)-m.titleHeight(), 0) } -// codeWidth returns the width of the main pane, without its gutter. +// titleHeight returns the number of lines in the title of the main pane. +// The PR tab has no title, because it shows no file. +func (m *model) titleHeight() int { + if m.tab == tabPR { + return 0 + } + return 1 +} + +// mainTitle renders the title of the main pane: the full path of the file +// being shown and, in the Changes tab, its added and deleted line counts. +// In rendered view, it notes a Markdown file that is too large to render. +// A path that is too long is cut at the start. +func (m *model) mainTitle() string { + var name, stat string + switch m.tab { + case tabChanges: + f := m.curChangedFile() + if f == nil { + return "" + } + name = f.Path + if f.OldPath != "" { + name = f.OldPath + " → " + f.Path + } + stat = f.stat() + case tabAllFiles: + // The pane has no path for a file that it cannot show, such as a + // binary file, but the title still names the file. + if m.allFiles.cur < len(m.allFiles.entries) { + name = m.allFiles.entries[m.allFiles.cur].Path + } + } + if name == "" { + return "" + } + if src, ok := m.markdownSource(); ok && m.rendered && !m.renderableMarkdown(src) { + stat = strings.TrimSpace(stat + " " + dimStyle.Render(noteMarkdownTooBig)) + } + room := max(m.mainWidth()-ansi.StringWidth(stat)-3, 1) + return " " + titleStyle.Render(cutStart(name, room)) + " " + stat +} + +// cutStart fits p into room columns. If p is too long, it cuts the start +// so that the file name stays visible. +func cutStart(p string, room int) string { + p = termtext.Quote(p) + if ansi.StringWidth(p) > room { + p = "…" + lastColumns(p, room-1) + } + return p +} + +// codeWidth returns the width of the main pane, not counting its gutter. func (m *model) codeWidth() int { return m.mainWidth() - gutter } -// rerender renders the current pane again, for example after a change of -// the layout, the wrap, or the width. The cursor and the selection stay on -// their source lines. A cursor on a header stays above the first line -// below the header. +// rerender renders the current pane again, for example after the layout, +// wrapping or width changes. The cursor and the selection stay on the same +// source lines. A cursor on a header row keeps the same distance above the +// first source line below it. func (m *model) rerender() { - m.sizeEditor() - p := m.cur() + m.sizeCommentInput() + p := m.curPane() next := p.cur - for next < len(p.rows) && m.target(next, diff.SideNew) == nil { + for next < len(p.rows) && m.targetAt(next, diff.SideNew) == nil { next++ } up := next - p.cur - cur, anchor := m.target(next, diff.SideNew), m.target(p.anchor, diff.SideNew) + cur, selStart := m.targetAt(next, diff.SideNew), m.targetAt(p.selStart, diff.SideNew) switch m.tab { case tabChanges: m.renderChanges() - case tabFiles: - m.renderViewer() + case tabAllFiles: + m.renderAllFiles() case tabPR: m.renderPR() } @@ -128,55 +189,62 @@ func (m *model) rerender() { p.cur = max(i-up, 0) } } - if anchor != nil { - if i := p.rowOf(anchor.side, anchor.line); i >= 0 { - p.anchor = i + if selStart != nil { + if i := p.rowOf(selStart.side, selStart.line); i >= 0 { + p.selStart = i } } } -// sizeEditor fits the comment box to the width of the main pane. -func (m *model) sizeEditor() { m.editor.SetWidth(max(m.mainWidth()-8, 20)) } +// sizeCommentInput sets the comment box width to fit the main pane. +func (m *model) sizeCommentInput() { m.commentInput.SetWidth(max(m.mainWidth()-8, 20)) } -// splitMinWidth is the narrowest code width that shows the split layout. -// It is the same as in herdr-reviewr: two halves of 60 columns, each with -// its line numbers, and a column between them. -const splitMinWidth = 135 +// minSplitWidth is the narrowest code width that allows the split layout: +// two halves of 60 columns, each with its line numbers, and a separator +// column. +const minSplitWidth = 135 -// splitAllowed reports whether the current file can show in the split -// layout. The file must be a diff in the Changes tab, with lines on both -// sides and a change. Rendered markdown cannot split. +// splitAllowed reports whether the current file can be shown in the split +// layout. It must be a diff in the Changes tab with content on both sides +// and an actual change. Rendered Markdown cannot be split. func (m *model) splitAllowed() bool { if m.tab != tabChanges { return false } - f := m.selectedChange() - return f != nil && f.Skip == nil && !m.isRendered(f) && - f.Old != "" && f.New != "" && f.Old != f.New + f := m.curChangedFile() + return f != nil && f.Err == nil && !m.renderedShown(f) && + f.OldText != "" && f.NewText != "" && f.OldText != f.NewText } -// splitShown reports whether the current file shows in the split layout. -// The user must choose split, and the pane must be wide enough. The choice -// stays, so a wider pane shows split again. +// splitShown reports whether the current file is shown in the split +// layout. The user must have chosen split, and the pane must be wide +// enough. The choice is kept, so the split returns when the pane is wide +// enough again. func (m *model) splitShown() bool { - return m.split && m.codeWidth() >= splitMinWidth && m.splitAllowed() + return m.split && m.codeWidth() >= minSplitWidth && m.splitAllowed() +} + +// renderedShown reports whether the Changes tab shows f as rendered +// Markdown. +func (m *model) renderedShown(f *changedFile) bool { + return m.rendered && isMarkdown(f.Path) && f.Status != vcs.StatusDeleted && m.renderableMarkdown(f.NewText) } -// isRendered reports whether the Changes tab shows f as rendered -// markdown. -func (m *model) isRendered(f *changedFile) bool { - return m.rendered && isMarkdown(f.Path) && f.Status != vcs.StatusDeleted +// fileOptions returns the options for rendering a file scrolled scrollX +// columns to the right. +func (m *model) fileOptions(scrollX int) diff.FileOptions { + return diff.FileOptions{Width: m.codeWidth(), ScrollX: scrollX, Wrap: m.wrap, Cache: &m.cache} } -// diffOptions returns the options to render a diff with sideways scroll -// xoff. -func (m *model) diffOptions(xoff int) diff.Options { +// diffOptions returns the options for rendering a diff scrolled scrollX +// columns to the right. +func (m *model) diffOptions(scrollX int) diff.Options { return diff.Options{ - Width: m.codeWidth(), - Offset: xoff, - Wrap: m.wrap, - Split: m.splitShown(), - Signs: m.cfg.DiffSigns, + Width: m.codeWidth(), + ScrollX: scrollX, + Wrap: m.wrap, + Split: m.splitShown(), + Signs: m.cfg.DiffSigns, Theme: diff.Theme{ Background: m.bg, Foreground: m.fg, @@ -187,21 +255,20 @@ func (m *model) diffOptions(xoff int) diff.Options { } } -// isMarkdown reports whether path names a markdown file. +// isMarkdown reports whether path names a Markdown file. func isMarkdown(path string) bool { p := strings.ToLower(path) return strings.HasSuffix(p, ".md") || strings.HasSuffix(p, ".markdown") } -// The notes in place of a file that rexdiff does not show. They are the -// same as in herdr-reviewr. +// Notes shown instead of a file that rexdiff cannot display. const ( noteBinary = "binary file · no line comments" noteTooBig = "file too large to show" ) -// fileNote returns the note for a file that vcs does not read, or "" if -// err has no note. err is the error of ReadFile, or File.Skip. +// fileNote returns the note for a file that vcs did not read, or "" if +// err has no matching note. err comes from ReadFile or File.Err. func fileNote(err error) string { switch { case errors.Is(err, vcs.ErrBinary): @@ -212,10 +279,10 @@ func fileNote(err error) string { return "" } -// renderChanges shows the selected file in the Changes tab. +// renderChanges renders the current file in the Changes tab. func (m *model) renderChanges() { - p := &m.changePane - f := m.selectedChange() + p := &m.changes.pane + f := m.curChangedFile() if f == nil { p.path = "" p.set(nil, nil) @@ -223,118 +290,546 @@ func (m *model) renderChanges() { } p.path = f.Path switch { - case f.Skip != nil: - p.setNote(fileNote(f.Skip)) - case f.Old == f.New: - // The change is a rename with no edit, or a change of the file mode. + case f.Err != nil: + p.setNote(fileNote(f.Err)) + case f.OldText == f.NewText: + // The change is a rename without edits, or a file mode change. note := "no change to the content" - if f.From != "" { - note = "renamed from " + termtext.Quote(f.From) + ", no change to the content" + if f.OldPath != "" { + note = "renamed from " + termtext.Quote(f.OldPath) + ", no change to the content" } p.setNote(note) - case m.isRendered(f): + case m.renderedShown(f): all := func(diff.Fold) bool { return true } - d := diff.Render(f.Path, f.Old, f.New, diff.Options{Expanded: all, Cache: &m.cache}) - m.setMarkdown(p, f.Old, f.New, d.Rows) + // Only the rows are needed, so the lines are not rendered. + d := diff.Render(f.Path, f.OldText, f.NewText, diff.Options{Expanded: all, Cache: &m.cache}) + m.setMarkdown(p, f.OldText, f.NewText, d.Rows) default: - if o := m.folds[f.Path]; o != nil && o.Old != f.oldHash { - delete(m.folds, f.Path) + if o := m.expandedFolds[f.Path]; o != nil && o.Old != f.oldHash { + delete(m.expandedFolds, f.Path) } - o := m.diffOptions(p.xoff) + o := m.diffOptions(p.scrollX) a := m.anchorOf(tabChanges) comments := slices.DeleteFunc(m.comments.All(), func(c *diff.Comment) bool { return c.Anchor != a }) - o.Expanded = func(fd diff.Fold) bool { return m.expanded(f.Path, comments, fd) } - p.setDiff(diff.Render(f.Path, f.Old, f.New, o)) + o.Expanded = func(fold diff.Fold) bool { return m.foldExpanded(f.Path, comments, fold) } + p.setDiff(diff.Render(f.Path, f.OldText, f.NewText, o)) } } -// setNote shows a dim note on one row that shows no source line. The -// pane keeps its path. +// setNote shows note dimmed on a single row with no source line. The pane +// keeps its path. func (p *pane) setNote(note string) { p.set([]string{dimStyle.Render(" " + note)}, []diff.Row{{}}) } -// renderViewer shows the selected file in the Files tab, even if the file -// does not change. -func (m *model) renderViewer() { - m.viewedPath = "" // no file has the path "", so refreshViewer renders - m.refreshViewer() +// renderAllFiles renders the current file in the Files tab, even if the +// file has not changed. It reads the file again only if a different file +// is selected or the last read failed. +func (m *model) renderAllFiles() { + af := &m.allFiles + if af.cur < len(af.entries) && af.entries[af.cur].Path == af.shownPath && af.shownErr == nil { + m.showFile(af.shownPath, af.shownText, nil) + return + } + af.shownPath = "" // no file has the path "", so reloadShownFile always renders + m.reloadShownFile() } -// refreshViewer shows the selected file in the Files tab if the file -// changes. -func (m *model) refreshViewer() { - p := &m.allPane - if m.allSel >= len(m.allFiles) { - p.path, m.viewedPath = "", "" +// reloadShownFile reads the current file in the Files tab and renders it +// again if it has changed. +func (m *model) reloadShownFile() { + p := &m.allFiles.pane + if m.allFiles.cur >= len(m.allFiles.entries) { + p.path, m.allFiles.shownPath = "", "" p.set(nil, nil) return } - path := m.allFiles[m.allSel].Path + path := m.allFiles.entries[m.allFiles.cur].Path content, err := m.repo.ReadFile(path) if err == nil { m.readErr = "" } - if err == nil && content == m.viewed && m.viewedPath == path { + shown := err == nil && m.allFiles.shownErr == nil && + content == m.allFiles.shownText && m.allFiles.shownPath == path + if shown { return } - m.viewed, m.viewedPath = content, path + m.allFiles.shownText, m.allFiles.shownPath, m.allFiles.shownErr = content, path, err + m.showFile(path, content, err) +} + +// showFile shows content, the file at path, in the Files tab. If err, the +// error from reading the file, is not nil, it shows a note instead. +func (m *model) showFile(path, content string, err error) { + p := &m.allFiles.pane p.path = path note := fileNote(err) + rendered := m.rendered && isMarkdown(path) && m.renderableMarkdown(content) + if note != "" || err != nil || rendered { + // The previous file may still be highlighted in the background. + m.cache.StopHighlighting() + } switch { case note != "": p.path = "" p.set([]string{dimStyle.Render(" " + note)}, nil) case err != nil: - // Without a watcher, each tick reads the file again. The log has - // each error once. + // Without a watcher, every tick reads the file again, so log each + // distinct error only once. if text := err.Error(); text != m.readErr { m.readErr = text m.log.Warn("read file failed", "path", path, "err", err) } p.path = "" p.set([]string{dimStyle.Render(" could not read this file")}, nil) - case m.rendered && isMarkdown(path): + case rendered: m.setMarkdown(p, "", content, nil) default: - p.setDiff(diff.RenderFile(path, content, m.diffOptions(p.xoff))) + p.setDiff(diff.RenderFile(path, content, m.fileOptions(p.scrollX))) } } -// markdown returns a glamour renderer for the terminal theme, or nil. The -// renderer wraps at wrap columns. The model keeps the last renderer, -// because a new one is slow to make. -func (m *model) markdown(wrap int) *glamour.TermRenderer { +// markdownRenderer returns a glamour renderer for the terminal theme that +// wraps at wrap columns, or nil if it cannot create one. The model caches +// the last renderer, because creating one is slow. +func (m *model) markdownRenderer(wrap int) *glamour.TermRenderer { style := "dark" if !m.dark() { style = "light" } - md := &m.md - if md.r != nil && md.style == style && md.wrap == wrap { - return md.r + renderer := &m.markdown + if renderer.r != nil && renderer.style == style && renderer.wrap == wrap { + return renderer.r } - r, err := glamour.NewTermRenderer( - glamour.WithStandardStyle(style), - glamour.WithWordWrap(wrap), - ) + r, err := newMarkdownRenderer(style, wrap) if err != nil { return nil } - *md = mdRenderer{r: r, style: style, wrap: wrap} + *renderer = markdownCache{r: r, style: style, wrap: wrap} return r } -// mdRenderer is a glamour renderer with its style and wrap width. -type mdRenderer struct { +// blockSourcesOf returns blockSources(diff.SplitLines(src)). The result for +// the most recent src is cached, because the title, rendersNothing and +// setMarkdown each ask for it. +func (m *model) blockSourcesOf(src string) (spans [][2]int, srcs []string, work int) { + c := &m.markdown.sources + if !c.done || c.src != src { + c.spans, c.srcs, c.work = blockSources(diff.SplitLines(src)) + c.src, c.done = src, true + } + return c.spans, c.srcs, c.work +} + +// hasMarkdownText reports whether any rendered block of src contains text, +// and so whether src as a whole does. It returns false if src contains +// "<", because HTML can span blocks or hide them, so the caller must +// render the whole of src. A src longer than maxMarkdownBlockBytes is too +// slow to render whole, so for it the blocks decide. +func hasMarkdownText(blocks [][]string, src string) bool { + if strings.Contains(src, "<") && len(src) <= maxMarkdownBlockBytes { + return false + } + for _, b := range blocks { + if slices.ContainsFunc(b, func(l string) bool { return !blankLine(l) }) { + return true + } + } + return false +} + +// blankLine reports whether the rendered line l shows no text. +func blankLine(l string) bool { + return strings.TrimSpace(ansi.Strip(l)) == "" +} + +// newMarkdownRenderer returns a glamour renderer with the given style that +// wraps at wrap columns. +func newMarkdownRenderer(style string, wrap int) (*glamour.TermRenderer, error) { + return glamour.NewTermRenderer( + glamour.WithStandardStyle(style), + glamour.WithWordWrap(wrap), + ) +} + +// markdownCache holds a glamour renderer with its style and wrap width. +// It also caches the blocks rendered by the last renderBlocks call, the +// texts rendered by the last renderPR call, the last rendersNothing result +// and the last blockSourcesOf result, because rendering a large file is +// slow. +type markdownCache struct { r *glamour.TermRenderer style string wrap int + + more []*glamour.TermRenderer // extra renderers set up like r, one per additional goroutine + blocks map[string][]string // the rendered lines of each block, keyed by its source + pr map[string][]string // the rendered lines of each PR text, keyed by its source + nothing struct { + src string + done bool // is holds the answer for src + is bool + } + sources struct { + src string + done bool // the fields below hold the answer for src + spans [][2]int + srcs []string + work int + } } -// renderMarkdown returns the lines of src, rendered with r. If r is nil or -// fails, renderMarkdown returns the lines of src as they are. +// minParallelBlocks is the number of uncached blocks per extra goroutine +// in renderBlocks. With fewer blocks than this, rendering uses one core. +const minParallelBlocks = 32 + +// renderBlocks returns the rendered lines of each source in srcs, as +// renderMarkdown would return them with r, which is the renderer in +// m.markdown. Blocks rendered by the previous call come from the cache. +// Many blocks are rendered in parallel, each goroutine using its own +// renderer. All goroutines finish before renderBlocks returns. +func (m *model) renderBlocks(r *glamour.TermRenderer, srcs []string) [][]string { + mc := &m.markdown + out := make([][]string, len(srcs)) + var todo []int // indexes of the blocks that are not cached + for i, src := range srcs { + if lines, ok := mc.blocks[src]; ok && mc.r == r && r != nil { + out[i] = lines + } else { + todo = append(todo, i) + } + } + workers := min(runtime.GOMAXPROCS(0), len(todo)/minParallelBlocks+1) + if r == nil || mc.r != r { + workers = 1 + } + // Each goroutine needs its own renderer. The first one uses r. + rs := []*glamour.TermRenderer{r} + for len(mc.more) < workers-1 { + more, err := newMarkdownRenderer(mc.style, mc.wrap) + if err != nil { + break + } + mc.more = append(mc.more, more) + } + rs = append(rs, mc.more[:min(len(mc.more), workers-1)]...) + var next atomic.Int64 + var wg sync.WaitGroup + for _, wr := range rs { + wg.Go(func() { + for k := int(next.Add(1)) - 1; k < len(todo); k = int(next.Add(1)) - 1 { + out[todo[k]] = renderBlock(wr, srcs[todo[k]], mc.wrap) + } + }) + } + wg.Wait() + if r != nil && mc.r == r { + mc.blocks = make(map[string][]string, len(srcs)) + for i, src := range srcs { + mc.blocks[src] = out[i] + } + } + return out +} + +// Limits on the Markdown that rexdiff renders, because glamour takes time +// and memory in proportion to the work that markdownWork counts, and a +// single slow render blocks the screen. +// +// A file longer than maxMarkdownBytes shows as its source diff, with a +// note in the title. So does a file whose blocks, with the link +// definitions that each block gets, add up to more than maxMarkdownWork. +// +// A block longer than maxMarkdownBlockBytes or with more work than +// maxMarkdownBlockWork, such as a huge table or a long list nested three +// deep, shows as source within the rendered file. +// +// A block whose block quotes and lists nest more than maxMarkdownDepth +// deep also shows as source, because glamour slows down sharply once the +// indentation is wider than the pane. +// +// A link definition longer than maxLinkDefBytes is not added to the +// blocks that refer to it, so its references show as text. +const ( + maxMarkdownBytes = 512 << 10 + maxMarkdownWork = 2 << 20 + maxMarkdownBlockBytes = 64 << 10 + maxMarkdownBlockWork = 128 << 10 + maxMarkdownDepth = 12 + maxLinkDefBytes = 2 << 10 +) + +// Notes for Markdown that rexdiff does not render. +const ( + noteMarkdownTooBig = "markdown too large to render" + noteBlockTooBig = " block too large to render · source below" + noteBlockTooDeep = " block nested too deeply to render · source below" +) + +// renderableMarkdown reports whether src is small enough to show +// rendered. The title asks on every draw, so the work comes from the +// cache of blockSourcesOf. +func (m *model) renderableMarkdown(src string) bool { + if len(src) > maxMarkdownBytes { + return false + } + _, _, work := m.blockSourcesOf(src) + return work <= maxMarkdownWork +} + +// blockNote returns the note to show instead of the rendered block +// source src, or "" if the block can be rendered. work is the work of +// rendering it, as markdownWork counts it, or 0 if it is not rendered. +func blockNote(src string) (note string, work int) { + if len(src) > maxMarkdownBlockBytes { + return noteBlockTooBig, 0 + } + if markdownDepth(src) > maxMarkdownDepth { + return noteBlockTooDeep, 0 + } + if work = markdownWork(src); work > maxMarkdownBlockWork { + return noteBlockTooBig, 0 + } + return "", work +} + +// renderBlock returns the lines of the block source src rendered with r, +// as renderMarkdown does. A block that blockNote rejects is not rendered: +// it shows as its note followed by its source, wrapped to width columns. +func renderBlock(r *glamour.TermRenderer, src string, width int) []string { + if note, _ := blockNote(src); note != "" { + return sourceLines(note, src, width) + } + return renderMarkdown(r, src) +} + +// sourceMargin is the margin of rendered text, which sourceLines gives +// to each line of source. +const sourceMargin = " " + +// sourceLines returns note, dimmed, followed by the lines of the Markdown +// src with tabs expanded and the margin of rendered text. A line wider +// than width columns, counting the margin, is wrapped, and each part has +// the margin. +func sourceLines(note, src string, width int) []string { + room := max(width-len(sourceMargin), 1) + var out []string + for l := range strings.SplitSeq(wrapWords(note, room), "\n") { + out = append(out, sourceMargin+dimStyle.Render(l)) + } + for l := range strings.SplitSeq(diffview.ExpandTabs(strings.TrimRight(src, "\n")), "\n") { + for part := range strings.SplitSeq(ansi.Hardwrap(l, room, true), "\n") { + out = append(out, sourceMargin+part) + } + } + return out +} + +// markdownWork returns an estimate of the work that glamour does to +// render the Markdown src. Each line counts its length times the square +// of its depth, as depthScanner counts it, because glamour takes that +// much longer on each item of a nested list. Each reference to a link +// definition in src adds the length of the definition, because the output +// repeats it there. Any text in brackets counts as a reference. +func markdownWork(src string) int { + work := 0 + var ds depthScanner + for l := range strings.Lines(src) { + d := max(ds.next(l), 1) + work += len(l) * d * d + } + defs := linkDefs(strings.Split(src, "\n"), len(src)) + if len(defs) == 0 { + return work + } + size := make(map[string]int, len(defs)) + for label, lines := range defs { + for _, l := range lines { + size[label] += len(l) + } + } + for _, m := range linkLabel.FindAllStringSubmatch(src, -1) { + work += size[util.ToLinkReference([]byte(m[1]))] + } + return work +} + +// markdownDepth returns a depth that block quotes and lists in the +// Markdown src do not nest beyond: the largest depth of any of its lines, +// as depthScanner counts it. +func markdownDepth(src string) int { + depth := 0 + var ds depthScanner + for l := range strings.Lines(src) { + depth = max(depth, ds.next(l)) + } + return depth +} + +// depthScanner counts how deeply each line of a Markdown block may nest, +// one line after another. The count is never less than the real depth, +// so that it is safe to use as a limit, but it stays close for ordinary +// Markdown. +// +// Each quote or list marker at the start of a line counts as one level, +// whatever its width, so "1. " and "- " count the same. Indentation +// counts as one level for each list item above whose text starts within +// it: a line indented to the text of an item belongs to that item. The +// scanner does not track when an item ends, so it counts every item seen +// so far in the block, which can only count too many. +// +// Inside a fenced code block, the indentation of the code does not +// count, only the indentation up to the column of the fence. A line with +// text left of that column, or with a list marker left of it, ends the +// code block, as it does in CommonMark, and counts as usual. +type depthScanner struct { + // items[c] reports whether the text of a list item seen so far starts + // at column c. + items []bool + // fence is the fence that opened the current code block, such as + // "```", or "" outside a code block. fenceCol is its column. + fence string + fenceCol int +} + +// next returns the depth of the line l, which follows the lines given +// before it. +func (ds *depthScanner) next(l string) int { + depth, col := 0, 0 + gapStart := 0 // the column where the current run of blanks starts + // gap counts the list items whose text starts in the run of blanks + // that ends at col, and starts a new run. + gap := func() { + for c := gapStart + 1; c <= col && c < len(ds.items); c++ { + if ds.items[c] { + depth++ + } + } + gapStart = col + } + // marker counts a marker that ends at l[i], skips the space after it, + // and returns the index after that. + marker := func(i, width int) int { + gap() + depth++ + col += width + if i < len(l) && l[i] == ' ' { + i++ + col++ + } + gapStart = col + return i + } + inCode := ds.fence != "" + i := 0 + for i < len(l) { + if inCode && col >= ds.fenceCol { + // The rest of the line is code, unless it closes the block. + if strings.HasPrefix(strings.TrimLeft(l[i:], " \t"), ds.fence) { + ds.fence = "" + } + col = ds.fenceCol + gap() + return depth + } + c := l[i] + if c == ' ' || c == '\t' { + col++ + if c == '\t' { + col += diffview.TabWidth - 1 + } + i++ + continue + } + if c == '>' { + i = marker(i+1, 1) + continue + } + if n := listMarker(l[i:]); n > 0 { + // A list marker left of the fence ends the code block. + inCode, ds.fence = false, "" + i = marker(i+n, n) + ds.addItem(col) + continue + } + break + } + gap() + // Text left of the fence, or a line of only blanks and markers, ends + // the code block only if it has text. + if inCode && strings.TrimSpace(l[i:]) != "" { + ds.fence = "" + } + if ds.fence == "" { + ds.openFence(l[i:], col) + } + return depth +} + +// addItem records that the text of a list item starts at column col. +func (ds *depthScanner) addItem(col int) { + if col >= len(ds.items) { + ds.items = append(ds.items, make([]bool, col+1-len(ds.items))...) + } + ds.items[col] = true +} + +// openFence starts a code block if rest, the text of a line that starts +// at column col, opens a fence. A fence indented four or more columns +// past the text of the nearest list item is code, not a fence. +func (ds *depthScanner) openFence(rest string, col int) { + t := strings.TrimLeft(rest, " \t") + indent := col + len(rest) - len(t) // tabs inside the fence line are rare, so count them as one + if !strings.HasPrefix(t, "```") && !strings.HasPrefix(t, "~~~") { + return + } + if t[0] == '`' && strings.Contains(strings.TrimLeft(t, "`"), "`") { + return // a backtick fence has no backtick in its info string + } + base := 0 // the column of the text of the nearest list item + for c := min(indent, len(ds.items)-1); c > 0; c-- { + if ds.items[c] { + base = c + break + } + } + if indent-base >= 4 { + return + } + n := len(t) - len(strings.TrimLeft(t, t[:1])) + ds.fence, ds.fenceCol = t[:n], indent +} + +// listMarker returns the width of the list marker at the start of s, or +// 0 if s does not start with one. A marker is "-", "*" or "+", or up to +// nine digits followed by "." or ")", and must be followed by a blank or +// the end of the line. +func listMarker(s string) int { + n := 0 + switch { + case s == "": + return 0 + case s[0] == '-' || s[0] == '*' || s[0] == '+': + n = 1 + default: + for n < len(s) && n < 9 && s[n] >= '0' && s[n] <= '9' { + n++ + } + if n == 0 || n == len(s) || s[n] != '.' && s[n] != ')' { + return 0 + } + n++ + } + if n < len(s) && strings.IndexByte(" \t\r\n", s[n]) < 0 { + return 0 + } + return n +} + +// renderMarkdown returns the lines of src rendered with r. If r is nil or +// rendering fails, it returns the lines of src unchanged. func renderMarkdown(r *glamour.TermRenderer, src string) []string { if r != nil { if out, err := r.Render(src); err == nil { @@ -344,85 +839,101 @@ func renderMarkdown(r *glamour.TermRenderer, src string) []string { return strings.Split(strings.TrimRight(src, "\n"), "\n") } -// markdownSource returns the markdown text of the current file. ok is -// false if the file is not markdown, or if it has no text to render. +// markdownSource returns the Markdown text of the current file. ok is +// false if the file is not Markdown, has been deleted, or is empty. func (m *model) markdownSource() (src string, ok bool) { switch m.tab { case tabChanges: - f := m.selectedChange() + f := m.curChangedFile() if f == nil || !isMarkdown(f.Path) || f.Status == vcs.StatusDeleted { return "", false } - src = f.New - case tabFiles: - if m.allSel >= len(m.allFiles) || !isMarkdown(m.allFiles[m.allSel].Path) { + src = f.NewText + case tabAllFiles: + if m.allFiles.cur >= len(m.allFiles.entries) || !isMarkdown(m.allFiles.entries[m.allFiles.cur].Path) { return "", false } - src = m.viewed + src = m.allFiles.shownText } return src, src != "" } -// rendersNothing reports whether the markdown src renders no text. +// rendersNothing reports whether the Markdown src renders to no visible +// text. The result for the most recent src is cached. A src too large to +// render counts as text, so that m still switches the view and the title +// says why the file shows as source. func (m *model) rendersNothing(src string) bool { - for _, l := range renderMarkdown(m.markdown(m.codeWidth()-2), termtext.Plain(src)) { - if strings.TrimSpace(ansi.Strip(l)) != "" { - return false - } + if !m.renderableMarkdown(src) { + return false + } + r := m.markdownRenderer(m.codeWidth() - 2) + n := &m.markdown.nothing + if r != nil && m.markdown.r == r && n.done && n.src == src { + return n.is } - return true + _, srcs, _ := m.blockSourcesOf(src) + is := !hasMarkdownText(m.renderBlocks(r, srcs), src) + if is && len(src) <= maxMarkdownBlockBytes { + lines := renderBlock(r, termtext.Plain(src), m.markdown.wrap) + is = !slices.ContainsFunc(lines, func(l string) bool { return !blankLine(l) }) + } + if r != nil && m.markdown.r == r { + n.src, n.done, n.is = src, true, is + } + return is } -// setMarkdown shows src as rendered markdown, block by block, as -// herdr-reviewr does. rows are the source rows that the view stands for, -// with old as their old side and src as their new side. If rows is nil, -// no line of src changes. Each rendered line shows the first source line -// of its block. The blank line after a block shows its last source line. -// A comment on a block covers all its lines. A block with a change has -// the bar. A changed block that renders nothing, and the lines of a -// removed block, show as one dim marker row. -func (m *model) setMarkdown(p *pane, old, src string, rows []diff.Row) { - lines, oldLines := diff.Lines(src), diff.Lines(old) +// setMarkdown shows newText in p as rendered Markdown, block by block, with +// a change bar on each changed block. rows are the diff rows from oldText +// to newText, or nil if nothing changed. Each rendered line maps to the +// first source line of its block and the blank line after it to the last, +// so a comment on a block covers it all. A removed block, or a changed one +// that renders nothing, shows as a dim marker line. +func (m *model) setMarkdown(p *pane, oldText, newText string, rows []diff.Row) { + lines, oldLines := diff.SplitLines(newText), diff.SplitLines(oldText) if rows == nil { for n := range lines { rows = append(rows, diff.Row{Kind: diff.KindEqual, New: n + 1}) } } - // Each block gets the link reference definitions of all the blocks. - var defs []string - for _, l := range lines { - if linkDef.MatchString(l) { - defs = append(defs, l) - } - } - refs := "\n\n" + strings.Join(defs, "\n") - r := m.markdown(m.codeWidth() - 2) + ix := newRowIndex(rows) + r := m.markdownRenderer(m.codeWidth() - 2) var out []string var outRows []diff.Row - blank := func(l string) bool { return strings.TrimSpace(ansi.Strip(l)) == "" } - // removed adds a marker row for the deleted lines after the new line - // at, from lo to hi. + // removed adds a marker row for the lines deleted after new lines lo + // up to, but not including, hi. removed := func(lo, hi int) { - if n := removedLines(rows, oldLines, lo, hi); n > 0 { - row := diff.Row{Kind: diff.KindEqual, New: max(lo, 1), Continued: true} + if n := ix.deletedLines(oldLines, lo, hi); n > 0 { + // The marker counts as a deleted row, so that ] and [ stop at it. + // It is a line of its own, so that the cursor can rest on it. + // It maps to the line before the removed lines, but shows no + // comments, as removedMarker explains. + row := diff.Row{Kind: diff.KindDeleted, New: max(lo, 1)} + blank := row + blank.Kind, blank.Continued = diff.KindEqual, true text := fmt.Sprintf(" − %s removed", plural(n, "line")) - out = append(out, delStyle.Render("▌")+dimStyle.Render(text), "") - outRows = append(outRows, row, row) + out = append(out, deletedStyle.Render("▎")+dimStyle.Render(text), "") + outRows = append(outRows, row, blank) } } see := m.keys.Rendered.Help().Key - prev := 0 // the last line of the block before - for first, last := range markdownBlocks(lines) { - removed(prev, first) - prev = last - block := renderMarkdown(r, termtext.Plain(strings.Join(lines[first-1:last], "\n")+refs)) - for len(block) > 0 && blank(block[0]) { + // Render all blocks up front, so that they can be rendered in + // parallel. + spans, srcs, _ := m.blockSourcesOf(newText) + blocks := m.renderBlocks(r, srcs) + prevRows := 0 // the last line of the previous block + for k, span := range spans { + first, last := span[0], span[1] + removed(prevRows, first) + prevRows = last + block := blocks[k] + for len(block) > 0 && blankLine(block[0]) { block = block[1:] } - for len(block) > 0 && blank(block[len(block)-1]) { + for len(block) > 0 && blankLine(block[len(block)-1]) { block = block[:len(block)-1] } - bar, n := changeBar(rows, first, last) + bar, n := ix.changeBar(first, last) switch { case len(block) > 0: case n == 1: @@ -433,35 +944,184 @@ func (m *model) setMarkdown(p *pane, old, src string, rows []diff.Row) { default: continue } + // The lines of a changed block count as changed rows, so that ] + // and [ stop at the block. + kind := diff.KindEqual + if bar != "" { + kind = diff.KindChanged + } for i, l := range block { if bar != "" { l = bar + ansi.TruncateLeft(l, 1, "") } out = append(out, l) - outRows = append(outRows, diff.Row{Kind: diff.KindEqual, New: first, Continued: i > 0}) + outRows = append(outRows, diff.Row{Kind: kind, New: first, Continued: i > 0}) } out = append(out, "") outRows = append(outRows, diff.Row{Kind: diff.KindEqual, New: last, Continued: true}) } - removed(prev, len(lines)+1) + removed(prevRows, len(lines)+1) p.set(out, outRows) - p.src = rows + p.srcRows = rows } -// linkDef matches the first line of a link reference definition. -var linkDef = regexp.MustCompile(`^\s{0,3}\[[^\]]+\]:`) +// removedMarker reports whether row i of p belongs to the marker that +// rendered Markdown shows for removed lines. The marker maps to the line +// before the removed lines, which the block above also shows, so the +// comments on that line show only below the block, not twice. +func (p *pane) removedMarker(i int) bool { + return p.srcRows != nil && i >= 0 && i < len(p.rows) && p.rows[p.lineStart(i)].Kind == diff.KindDeleted +} -// removedLines returns the number of deleted lines of old, not blank, -// that rows put after a new line from lo to hi, hi not included. A -// deletion right before a new line is a change of that line, not a -// removal. -func removedLines(rows []diff.Row, old []string, lo, hi int) int { - n, run, at := 0, 0, 0 // at is the last new line before the row - for _, r := range rows { +// blockSources returns the first and last line numbers, counting from 1, +// of each block in the Markdown lines, and the source to render for each +// block. A block's source includes the link reference definitions in +// lines whose labels appear in the block, so that its reference links +// resolve, unless they would make the block too large to render. Only +// definitions of at most maxLinkDefBytes are included, and none in a +// block that blockNote rejects. work is the sum of the work of the blocks +// that are rendered, as markdownWork counts it. Once work exceeds +// maxMarkdownWork, blockSources stops and returns the blocks so far. +func blockSources(lines []string) (spans [][2]int, srcs []string, work int) { + defs := linkDefs(lines, maxLinkDefBytes) + for first, last := range markdownBlocks(lines) { + src := strings.Join(lines[first-1:last], "\n") + if note, _ := blockNote(src); note == "" { + if used := usedDefs(src, defs, maxMarkdownBlockBytes-len(src)); len(used) > 0 { + with := src + "\n\n" + strings.Join(used, "\n") + if note, _ := blockNote(with); note == "" { + src = with + } + } + } + src = termtext.Plain(src) + _, w := blockNote(src) + work += w + spans = append(spans, [2]int{first, last}) + srcs = append(srcs, src) + if work > maxMarkdownWork { + break + } + } + return spans, srcs, work +} + +// linkDefs returns the lines of link reference definitions in lines that +// are at most maxLen bytes long, by label. +func linkDefs(lines []string, maxLen int) map[string][]string { + defs := map[string][]string{} // the definition lines of each label + for _, l := range lines { + if len(l) > maxLen { + continue + } + if m := linkDef.FindStringSubmatch(l); m != nil { + label := util.ToLinkReference([]byte(m[1])) + defs[label] = append(defs[label], l) + } + } + return defs +} + +// usedDefs returns the lines in defs whose labels appear in brackets in +// the Markdown block src. It matches labels as CommonMark does, ignoring +// case and runs of white space. Text in brackets that is not a link only +// adds a definition that the block does not use. It returns nil if the +// lines would take more than limit bytes. +func usedDefs(src string, defs map[string][]string, limit int) []string { + if len(defs) == 0 { + return nil + } + var out []string + size := 0 + seen := map[string]bool{} + for _, m := range linkLabel.FindAllStringSubmatch(src, -1) { + label := util.ToLinkReference([]byte(m[1])) + if seen[label] { + continue + } + seen[label] = true + for _, l := range defs[label] { + if size += len(l) + 1; size > limit { + return nil + } + out = append(out, l) + } + } + return out +} + +// linkDef matches the first line of a link reference definition and +// captures its label. linkLabel matches text in brackets that can be a +// link label, which cannot hold an unescaped bracket, and captures it. +var ( + linkDef = regexp.MustCompile(`^\s{0,3}\[([^\]]+)\]:`) + linkLabel = regexp.MustCompile(`\[((?:[^\[\]\\]|\\.)+)\]`) +) + +// rowIndex finds the rows for a range of new lines without scanning every +// row. key[i] is the new line of row i, or the last new line before it for +// a deleted row. at[i] is the last new line before row i, and deleted[i] +// reports whether row i follows deleted rows. If the keys ever decrease, +// each lookup scans all the rows. +type rowIndex struct { + rows []diff.Row + key []int + at []int + deleted []bool + monotone bool // the keys never decrease +} + +// newRowIndex builds a rowIndex for rows. +func newRowIndex(rows []diff.Row) *rowIndex { + ix := &rowIndex{ + rows: rows, key: make([]int, len(rows)), at: make([]int, len(rows)), + deleted: make([]bool, len(rows)), monotone: true, + } + at, del, key := 0, false, 0 + for i, r := range rows { + ix.at[i], ix.deleted[i] = at, del + switch { + case r.Continued: + case r.Kind == diff.KindDeleted: + key, del = at, true + default: + key, del, at = r.New, false, r.New + } + if i > 0 && key < ix.key[i-1] { + ix.monotone = false + } + ix.key[i] = key + } + return ix +} + +// first returns the first row whose key is at least line, or 0 if the keys +// are not monotone. +func (ix *rowIndex) first(line int) int { + if !ix.monotone { + return 0 + } + i, _ := slices.BinarySearch(ix.key, line) + return i +} + +// deletedLines returns the number of non-blank lines of oldLines that the +// rows delete right after a new line in the range lo to hi, not including +// hi. Deleted lines directly followed by an added or changed line count as +// a change to that line, not as a removal. +func (ix *rowIndex) deletedLines(oldLines []string, lo, hi int) int { + // Rows before s have keys below lo, so neither they nor deletions + // before them can add to the count. + s := ix.first(lo) + n, run, at := 0, 0, 0 // at is the last new line before the current row + if s < len(ix.rows) { + at = ix.at[s] + } + for _, r := range ix.rows[s:] { switch { case r.Continued: - case r.Kind == diff.KindDelete: - if at >= lo && at < hi && r.Old <= len(old) && strings.TrimSpace(old[r.Old-1]) != "" { + case r.Kind == diff.KindDeleted: + if at >= lo && at < hi && r.Old <= len(oldLines) && strings.TrimSpace(oldLines[r.Old-1]) != "" { run++ } default: @@ -469,14 +1129,17 @@ func removedLines(rows []diff.Row, old []string, lo, hi int) int { n += run } run, at = 0, r.New + if ix.monotone && at >= hi { + return n // all later rows have keys of hi or more + } } } return n + run } -// markdownBlocks yields the first and the last line of each block of -// markdown lines, from 1. A blank line outside a code fence ends a block, -// unless the next line is indented, as in a list item. +// markdownBlocks yields the first and last line numbers, counting from 1, +// of each block in the Markdown lines. A blank line outside a code fence +// ends a block, unless the next line is indented, as in a list item. func markdownBlocks(lines []string) iter.Seq2[int, int] { return func(yield func(int, int) bool) { first, last, fence := 0, 0, "" @@ -511,20 +1174,26 @@ func markdownBlocks(lines []string) iter.Seq2[int, int] { } } -// changeBar returns the bar for the source lines first to last, or "" if -// rows do not change them. The bar is green if rows only add lines there. -// It is yellow if rows also change or delete a line there. n is the -// number of the changed lines there, as herdr-reviewr counts them: the -// new lines, or else the deleted lines. -func changeBar(rows []diff.Row, first, last int) (bar string, n int) { +// changeBar returns the change bar for source lines first to last, or "" +// if the rows do not change them. The bar is green if lines were only +// added there, and yellow if any line there was also changed or deleted. +// n is the number of changed lines: the added or changed new lines if +// there are any, and otherwise the deleted lines. +func (ix *rowIndex) changeBar(first, last int) (bar string, n int) { + // Rows before s have keys below first, so they cannot affect the + // result. + s := ix.first(first) added, changed, deleted := 0, false, false - gone := 0 // the deleted lines there - at := 0 // the last new line before the row - for _, r := range rows { + gone := 0 // the number of deleted lines in the range + at := 0 // the last new line before the current row + if s < len(ix.rows) { + at, deleted = ix.at[s], ix.deleted[s] + } + for _, r := range ix.rows[s:] { if r.Continued { continue } - if r.Kind == diff.KindDelete { + if r.Kind == diff.KindDeleted { deleted = true if at >= first && at < last { changed = true @@ -534,7 +1203,7 @@ func changeBar(rows []diff.Row, first, last int) (bar string, n int) { } if r.New >= first && r.New <= last && r.Kind != diff.KindEqual { added++ - changed = changed || deleted || r.Kind == diff.KindChange + changed = changed || deleted || r.Kind == diff.KindChanged } deleted, at = false, r.New if at >= last { @@ -544,9 +1213,9 @@ func changeBar(rows []diff.Row, first, last int) (bar string, n int) { n = cmp.Or(added, gone) switch { case changed: - return noteStyle.Render("▌"), n + return noteStyle.Render("▎"), n case added > 0: - return addStyle.Render("▌"), n + return addedStyle.Render("▎"), n } return "", 0 } diff --git a/tui/resume.go b/tui/resume.go index e63959f..dfd044a 100644 --- a/tui/resume.go +++ b/tui/resume.go @@ -8,52 +8,51 @@ import ( "github.com/rednafi/rexdiff/diff" "github.com/rednafi/rexdiff/internal/proc" - "github.com/rednafi/rexdiff/vcs" ) -// After an update, rexdiff starts again in the same terminal. The old -// program saves the comments and the place of the user in a file. The new -// program reads the file, so that the user continues from the same place. +// After an update, rexdiff restarts in the same terminal. The old process +// saves the comments and the user's position to a file, and the new +// process reads that file and carries on from the same position. -// resumeState holds the comments, the scope, the view, the expanded folds, -// and the places in the panes for a restart. +// resumeState holds what a restart must keep: the comments, the scope, the +// view settings, the expanded folds and the position in each pane. type resumeState struct { Tab tab `json:"tab"` Scope Scope `json:"scope"` Base string `json:"base"` - From string `json:"from"` - To string `json:"to"` + From string `json:"from"` // the oldest commit in the commits scope + To string `json:"to"` // the newest commit in the commits scope Turn *resumeTurn `json:"turn"` - // The state keeps the files by path, because the lists can change in a - // restart. + // Files are stored by path, because the file lists can change during + // a restart. ChangePath string `json:"change_path"` - FilePath string `json:"file_path"` + FilePath string `json:"file_path"` // the file shown in the Files tab Changes resumePane `json:"changes"` - Viewer resumePane `json:"viewer"` + Viewer resumePane `json:"viewer"` // the pane of the Files tab PR resumePane `json:"pr"` Split bool `json:"split"` Wrap bool `json:"wrap"` Rendered bool `json:"rendered"` ListPos position `json:"list_pos"` - // ListLeft is the place of the list in the state of an older version. - // Then ListPos is "". + // ListLeft is the file list position from older versions, which leave + // ListPos "". ListLeft bool `json:"list_left,omitzero"` ListHidden bool `json:"list_hidden"` FocusList bool `json:"focus_list"` ListW int `json:"list_w"` ListPct int `json:"list_pct,omitzero"` - Folds map[string]*openFolds `json:"folds"` + Folds map[string]*expandedFolds `json:"folds"` Comments *diff.Comments `json:"comments"` LastSent string `json:"last_sent"` Beside string `json:"beside"` } -// resumeTurn is a [turnState], with exported fields for JSON. The state -// of an older version can have the member "block", which Unmarshal skips. +// resumeTurn is a [turnState] with exported fields for JSON. State from an +// older version may contain a "block" member, which Unmarshal ignores. type resumeTurn struct { App string `json:"app"` Tree string `json:"tree"` @@ -61,42 +60,42 @@ type resumeTurn struct { At time.Time `json:"at"` } -// resumePane is the place in a pane. +// resumePane is the cursor and scroll position in a pane. type resumePane struct { Cur int `json:"cur"` Top int `json:"top"` - Xoff int `json:"xoff"` + Xoff int `json:"xoff"` // the horizontal scroll } -// place returns the place of p, for the resume state. -func (p *pane) place() resumePane { return resumePane{Cur: p.cur, Top: p.top, Xoff: p.xoff} } +// place returns the position of p, for the resume state. +func (p *pane) place() resumePane { return resumePane{Cur: p.cur, Top: p.top, Xoff: p.scrollX} } -// restore moves p to the place s, within its lines. +// restore moves p to the position s, clamped to its lines. func (p *pane) restore(s resumePane) { - last := max(len(p.lines)-1, 0) - p.cur, p.top, p.xoff = min(s.Cur, last), min(s.Top, last), s.Xoff + p.cur, p.top, p.scrollX = min(s.Cur, p.last()), min(s.Top, p.last()), s.Xoff } -// saveResume writes the state to a new file, and returns its path. +// saveResume writes the resume state to a new temporary file and returns +// its path. func (m *model) saveResume() (string, error) { s := resumeState{ Tab: m.tab, Scope: m.scope, Base: m.base, - From: m.from, - To: m.to, - Changes: m.changePane.place(), - Viewer: m.allPane.place(), - PR: m.prPane.place(), + From: m.oldest, + To: m.newest, + Changes: m.changes.pane.place(), + Viewer: m.allFiles.pane.place(), + PR: m.pr.pane.place(), Split: m.split, Wrap: m.wrap, Rendered: m.rendered, - ListPos: m.listPos, - ListHidden: m.listHidden, - FocusList: m.focusList, - ListW: m.listW, - ListPct: m.listPct, - Folds: m.folds, + ListPos: m.navigatorPosition, + ListHidden: m.navigatorHidden, + FocusList: m.navigatorFocused, + ListW: m.navigatorWidthSet, + ListPct: m.navigatorPct, + Folds: m.expandedFolds, Comments: &m.comments, LastSent: m.lastSent, Beside: m.beside, @@ -104,14 +103,22 @@ func (m *model) saveResume() (string, error) { if t := m.turn; t != nil { s.Turn = &resumeTurn{App: t.app, Tree: t.tree, Running: t.running, At: t.at} } - if m.prPlace != nil { // the PR has not loaded since the last restart - s.PR = *m.prPlace + if m.pr.place != nil { // the PR has not loaded since the previous restart + s.PR = *m.pr.place } - if f := m.selectedChange(); f != nil { + if f := m.curChangedFile(); f != nil { s.ChangePath = f.Path } - if m.allSel < len(m.allFiles) { - s.FilePath = m.allFiles[m.allSel].Path + if m.allFiles.cur < len(m.allFiles.entries) { + s.FilePath = m.allFiles.entries[m.allFiles.cur].Path + } + // Keep the places that the previous process saved if they have not + // been restored yet. + if r := m.resumeChanges; r != nil { + s.ChangePath, s.Changes = r.ChangePath, r.Changes + } + if r := m.resumeFiles; r != nil { + s.FilePath, s.Viewer = r.FilePath, r.Viewer } b, err := json.Marshal(s) if err != nil { @@ -129,13 +136,14 @@ func (m *model) saveResume() (string, error) { return f.Name(), f.Close() } -// loadResume reads the file that saveResume wrote, and removes it. +// loadResume reads the file written by saveResume and then removes it. func loadResume(path string) (*resumeState, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } - // A file that stays in the temporary directory does no harm. + // If the removal fails, the file left in the temporary directory does + // no harm. _ = os.Remove(path) s := &resumeState{Comments: new(diff.Comments)} if err := json.Unmarshal(b, s); err != nil { @@ -144,56 +152,61 @@ func loadResume(path string) (*resumeState, error) { return s, nil } -// restoreSettings sets the scope, the view, and the comments of s. Call -// it before the first reload, which reads the files of the scope. +// restoreSettings restores the scope, view settings and comments from s, +// and keeps s to restore the places once the changes and the files have +// loaded. Call it before the first reload, which reads the files in the +// scope. func (m *model) restoreSettings(s *resumeState) { - m.tab, m.scope, m.base, m.from, m.to = s.Tab, s.Scope, s.Base, s.From, s.To + m.resumeChanges, m.resumeFiles = s, s + // Expand the directories above the current file before reading the + // Files tab entries, so that a file inside an ignored directory + // appears in the file list. + m.allFiles.navigator.reveal(s.FilePath) + m.allFiles.listing.want = true + m.pr.place = &s.PR // the PR loads later, and onPR restores the position + m.tab, m.scope, m.base, m.oldest, m.newest = s.Tab, s.Scope, s.Base, s.From, s.To if t := s.Turn; t != nil { m.turn = &turnState{app: t.App, tree: t.Tree, running: t.Running, at: t.At} } m.split, m.wrap, m.rendered = s.Split, s.Wrap, s.Rendered - m.listPos, m.listHidden, m.focusList, m.listW = s.ListPos, s.ListHidden, s.FocusList, s.ListW + m.navigatorPosition, m.navigatorHidden = s.ListPos, s.ListHidden + m.navigatorFocused, m.navigatorWidthSet = s.FocusList, s.ListW switch { - case m.listPos != "": + case m.navigatorPosition != "": + // The saved position takes precedence over the former ListLeft. case s.ListLeft: - m.listPos = positionLeft + m.navigatorPosition = positionLeft default: - m.listPos = positionRight + m.navigatorPosition = positionRight } if s.ListPct > 0 { - m.listPct = s.ListPct + m.navigatorPct = s.ListPct } - m.folds = s.Folds + m.expandedFolds = s.Folds if s.Comments != nil { m.comments = *s.Comments - m.comments.SetOldRev(m.diffRev()) + m.comments.SetRev(m.diffRev()) } m.lastSent, m.beside = s.LastSent, s.Beside } -// restorePlaces selects the files of s and moves the cursors to the places -// in s. Call it after the first reload. -func (m *model) restorePlaces(s *resumeState) { - // The directories above the selected files expand. Expand them before - // the list of all files is read, so that the list has a file in an - // ignored directory. - m.changeTree.reveal(s.ChangePath) - m.allTree.reveal(s.FilePath) - if i := slices.IndexFunc(m.changeFiles, func(f changedFile) bool { return f.Path == s.ChangePath }); i >= 0 { - m.changeSel = i - } - files, _ := m.repo.Files(m.ctx) // an error shows an empty list - m.allFiles = m.withIgnored(files) - if i := slices.IndexFunc(m.allFiles, func(e vcs.Entry) bool { return e.Path == s.FilePath }); i >= 0 { - m.allSel = i - } - // The cursors go to the selected files. - m.changeTree.cur, m.allTree.cur = -1, -1 - m.buildTrees() +// restoreChanges selects the file saved in the resume state and moves the +// cursor of the Changes tab to the saved position, once the first reload +// has loaded the changes. The first file list restores the Files tab in +// [model.onFiles]. +func (m *model) restoreChanges() { + s := m.resumeChanges + if s == nil || !m.changesLoaded { + return + } + m.resumeChanges = nil + m.changes.navigator.reveal(s.ChangePath) + if i := slices.IndexFunc(m.changes.files, func(f changedFile) bool { return f.Path == s.ChangePath }); i >= 0 { + m.changes.cur = i + } + m.changes.navigator.cur = -1 // put the list cursor on the current file + m.buildBothNavigators() m.renderChanges() - m.refreshViewer() - m.changePane.restore(s.Changes) - m.allPane.restore(s.Viewer) - m.prPlace = &s.PR // the PR loads later, and onPR restores the place + m.changes.pane.restore(s.Changes) m.rerender() } diff --git a/tui/search.go b/tui/search.go index 5578b3c..6988df1 100644 --- a/tui/search.go +++ b/tui/search.go @@ -1,10 +1,9 @@ package tui -// The search finds files by name and lines of code, as in herdr-reviewr. -// It shows in an overlay. +// The search finds files by name and lines of code. It appears in an +// overlay. import ( - "context" "fmt" "slices" "strings" @@ -13,78 +12,80 @@ import ( tea "charm.land/bubbletea/v2" "github.com/rednafi/rexdiff/internal/search" + "github.com/rednafi/rexdiff/internal/xctx" "github.com/rednafi/rexdiff/vcs" ) -// The times of a code search, as in herdr-reviewr. After codeBudget, the -// search shows the lines that it finds, and a "+" tells that there can be -// others. A search with no line yet goes on until its first line, or -// until codeTimeout. +// Time limits for a code search. After grepBudget, the search shows the +// lines found so far, with a "+" to say there may be more. A search that +// has found nothing keeps going until its first match or until +// grepTimeout. const ( - codeBudget = 80 * time.Millisecond - codeTimeout = 2 * time.Second + grepBudget = 80 * time.Millisecond + grepTimeout = 2 * time.Second ) -// searchState has the results of the search. As in herdr-reviewr, the -// search has two modes. It shows the files that match by name, or the -// lines of the code that contain the query. +// searchState holds the search results. The search has two modes: one +// shows the files whose names match, and the other shows the lines of code +// that contain the query. type searchState struct { - code bool // the mode that shows the code matches + grep bool // true in code mode paths []string // the files that the search can find - files []string // the files that match - fileTotal int // the number of the files that match + files []string // the matching files that are listed + fileTotal int // the total number of matching files hits []vcs.Match - hitsMore bool // git finds more matches than hits has - err string // the error of the code search + hitsMore bool // git found more matches than hits holds + err string // the code search error preview *preview - // previewY is the line of the box of the search where the preview - // starts, with its rule. + // previewY is the line in the search box where the preview, with its + // rule, starts. previewY int } -// showSearch shows the results of the search in its mode, with the -// cursor on the first result. As in herdr-reviewr, the code matches show -// under a header for each file. A "… more" row tells that the list does -// not show all the results. An error shows in place of the results. -func (o *overlay) showSearch() { - s := &o.search - var items []pickItem +// setSearchItems lists the results for the current mode, with the cursor +// on the first result. Code matches are grouped under a heading for each +// file. A "… more" row means the list does not include every result. An +// error replaces the results. +func (ov *overlay) setSearchItems() { + s := &ov.search + var items []overlayItem switch { case s.err != "": - case !s.code: + // The overlay shows the error instead of a list. + case !s.grep: for _, p := range s.files { - items = append(items, pickItem{label: p, id: p}) + items = append(items, overlayItem{label: p, id: p}) } if s.fileTotal > len(s.files) { - items = append(items, pickItem{label: "… more"}) + items = append(items, overlayItem{label: "… more"}) } default: for i, h := range s.hits { if i == 0 || h.Path != s.hits[i-1].Path { - items = append(items, pickItem{label: h.Path, head: true}) + items = append(items, overlayItem{label: h.Path, isHeader: true}) } - items = append(items, pickItem{ + items = append(items, overlayItem{ label: fmt.Sprintf("%5d: %s", h.Line, h.Text), id: h.Path, line: h.Line, }) } if s.hitsMore { - items = append(items, pickItem{label: "… more"}) + items = append(items, overlayItem{label: "… more"}) } } - o.setItems(items) - o.cur = 0 - if len(o.shown) > 0 && !o.pickable(0) { - o.move(1) + ov.setItems(items) + ov.cur = 0 + if len(ov.shown) > 0 && !ov.canHaveCursor(0) { + ov.move(1) } } -// chips returns the chips of the modes of the search, as in -// herdr-reviewr: "files N │ code M". A "+" after M tells that git finds -// more matches. The chip of the mode in use is bright. -func (o *overlay) chips() string { - s := &o.search +// chips returns the labels of the two search modes: "files N │ code M". +// A "+" after M means git found more matches. The label of the current +// mode is highlighted. +func (ov *overlay) chips() string { + s := &ov.search files, code := "files", "code" if s.err == "" { files = fmt.Sprintf("files %d", s.fileTotal) @@ -93,76 +94,134 @@ func (o *overlay) chips() string { code += "+" } } - on := cursorStyle.Underline(true) - if s.code { + on := accentStyle.Underline(true) + if s.grep { return dimStyle.Render(files+" │ ") + on.Render(code) } return on.Render(files) + dimStyle.Render(" │ "+code) } -// openSearch opens the search of the files and the code. With no query, -// it lists the files. +// openSearch opens the search over file names and code. The query starts +// empty, so it lists files and searches no code. func (m *model) openSearch() { ov := newOverlay(overlaySearch, "") ov.input.Placeholder = "Search files and code…" - for _, e := range m.allFiles { + ov.search.paths = searchPaths(m.allFiles.entries) + m.openOverlay(ov) + m.refreshSearch() // an empty query needs no command +} + +// searchPaths returns the paths of the entries that the search can find: +// those that git does not ignore. +func searchPaths(es []vcs.Entry) []string { + var paths []string + for _, e := range es { if !e.Ignored { - ov.search.paths = append(ov.search.paths, e.Path) + paths = append(paths, e.Path) } } - m.open(ov) - m.runSearch() + return paths +} + +// maxSyncPaths is the most paths that the file search goes through on the +// UI goroutine. With more, a fuzzy search is slow enough to hold up the +// screen, so it runs in a command. +const maxSyncPaths = 20_000 + +// fileSearchMsg carries the files that match a search query. +type fileSearchMsg struct { + gen int // identifies the search; results of older searches are stale + files []string + total int +} + +// findFiles finds the files that match the search query. With more than +// maxSyncPaths paths, it returns a command that finds them instead, and +// the list keeps the previous files until onFileSearch shows the new ones. +func (m *model) findFiles() tea.Cmd { + s, q := &m.overlay.search, m.overlay.input.Value() + m.fileGen++ + if q == "" || len(s.paths) <= maxSyncPaths { + s.files, s.fileTotal = search.Files(s.paths, m.recent, q, search.MaxFiles) + return nil + } + paths, recent, gen := s.paths, slices.Clone(m.recent), m.fileGen + return func() tea.Msg { + files, total := search.Files(paths, recent, q, search.MaxFiles) + return fileSearchMsg{gen: gen, files: files, total: total} + } } -// runSearch finds the files that match the query of the search, and -// starts a code search for the query. It stops the code search of the -// last query. Until the new code search ends, the old code matches show. -// An empty query finds no code. -func (m *model) runSearch() tea.Cmd { +// onFileSearch shows the files found by the latest file search, unless +// the search has closed since. In code mode, only the count changes. +func (m *model) onFileSearch(msg fileSearchMsg) { + ov := m.overlay + if ov == nil || ov.kind != overlaySearch || msg.gen != m.fileGen { + return + } + s := &ov.search + s.files, s.fileTotal = msg.files, msg.total + if !s.grep { + ov.setSearchItems() + } +} + +// refreshSearch finds the files that match the search query, cancels the +// code search for the previous query and updates the list. It returns the +// query, and the command that finds the files if [model.findFiles] +// returns one. The previous code matches stay on screen until the new +// search finishes, except that a blank query clears them. +func (m *model) refreshSearch() (query string, cmd tea.Cmd) { ov := m.overlay s, q := &ov.search, ov.input.Value() - s.files, s.fileTotal = search.Files(s.paths, m.recent, q, search.MaxFiles) + cmd = m.findFiles() s.err = "" - m.stopGrep(fmt.Errorf("code search: a newer query replaces it: %w", context.Canceled)) - // A blank grep is noise, as in herdr-reviewr. + m.stopGrep() if strings.TrimSpace(q) == "" { s.hits, s.hitsMore = nil, false - ov.showSearch() - return nil } - ov.showSearch() - ctx, cancel := context.WithCancelCause(m.ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, codeTimeout, - fmt.Errorf("code search: %v timeout: %w", codeTimeout, context.DeadlineExceeded)) + ov.setSearchItems() + return q, cmd +} + +// startGrep refreshes the search list with [model.refreshSearch] and +// returns a command that searches the code for the query. A blank query +// searches no code and returns nil. +func (m *model) startGrep() tea.Cmd { + q, files := m.refreshSearch() + // A blank query would match nearly every line. + if strings.TrimSpace(q) == "" { + return files + } + ctx, cancel := xctx.WithTimeout(m.ctx, grepTimeout, "code search") m.grepCancel = cancel repo, gen := m.repo, m.grepGen - return func() tea.Msg { - defer cancelTimeout() - defer cancel(fmt.Errorf("code search: done: %w", context.Canceled)) - hits, more, err := repo.Grep(ctx, q, search.MaxCodeLines, codeBudget) + return tea.Batch(files, func() tea.Msg { + defer cancel() + hits, more, err := repo.Grep(ctx, q, search.MaxGrepLines, grepBudget) return grepMsg{gen: gen, hits: hits, more: more, err: err} - } + }) } -// addRecent puts path first in the list of the files that the search -// shows for an empty query. +// addRecent moves path to the front of the recent files, which the search +// lists for an empty query. func (m *model) addRecent(path string) { rest := slices.DeleteFunc(m.recent, func(p string) bool { return p == path }) - m.recent = append([]string{path}, rest[:min(len(rest), search.MaxFiles-1)]...) + m.recent = slices.Concat([]string{path}, rest[:min(len(rest), search.MaxFiles-1)]) } -// stopGrep stops the code search that runs, if any. Its result then does -// not show. cause tells why it stops. -func (m *model) stopGrep(cause error) { +// stopGrep cancels the code search in progress, if any, and makes sure +// its result is ignored. +func (m *model) stopGrep() { m.grepGen++ if m.grepCancel != nil { - m.grepCancel(cause) + m.grepCancel() m.grepCancel = nil } } -// onGrep keeps the code matches of the search, but only if the search -// still shows their query. The list shows them in the code mode. +// onGrep stores the code matches in the search, unless the search has +// closed or the query has changed since. The list shows them in code mode. func (m *model) onGrep(msg grepMsg) { ov := m.overlay if ov == nil || ov.kind != overlaySearch || msg.gen != m.grepGen { @@ -171,12 +230,12 @@ func (m *model) onGrep(msg grepMsg) { s := &ov.search s.hits, s.hitsMore, s.err = msg.hits, msg.more, "" if msg.err != nil { - // Show only the error, and no old results under it. + // Show only the error, without older results below it. s.hits, s.hitsMore, s.err = nil, false, msg.err.Error() } - // The list of the file matches stays, with its cursor. The chips - // show the count of the code matches. - if s.code { - ov.showSearch() + // In file mode, keep the list and its cursor as they are. The mode + // labels still show the new number of code matches. + if s.grep { + ov.setSearchItems() } } diff --git a/tui/send.go b/tui/send.go index d4ad9db..7b396cd 100644 --- a/tui/send.go +++ b/tui/send.go @@ -2,7 +2,6 @@ package tui import ( "cmp" - "context" "errors" "fmt" "slices" @@ -11,25 +10,24 @@ import ( tea "charm.land/bubbletea/v2" "github.com/rednafi/rexdiff/internal/clipboard" + "github.com/rednafi/rexdiff/internal/xctx" "github.com/rednafi/rexdiff/rex" ) -// maxSend is the largest review, in bytes, that rexdiff pastes into an -// agent. The user must copy a larger review. +// maxSend is the largest review, in bytes, that rexdiff will paste into an +// agent. A larger review must be copied instead. Rex limits one paste to +// about 64 KB, so rex.Client.Paste sends a long review in parts. const maxSend = 256 << 10 -// listAgents returns a command that lists the agents of the session, other -// than rexdiff panes. The command returns msg with the agents. Set -// msg.send to send the review to them. -func (m *model) listAgents(msg agentsMsg) tea.Cmd { +// fetchAgents returns a command that lists the agents in the Rex session, +// leaving out rexdiff panes. The command returns msg with the agents +// filled in. Set msg.send if the list is for sending the review. +func (m *model) fetchAgents(msg agentsMsg) tea.Cmd { c, ctx := m.rex, m.ctx msg.gen = m.agentsGen return func() tea.Msg { - ctx, cancel := context.WithCancelCause(ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, rexTimeout, - fmt.Errorf("agent list: %v timeout: %w", rexTimeout, context.DeadlineExceeded)) - defer cancelTimeout() - defer cancel(fmt.Errorf("agent list: done: %w", context.Canceled)) + ctx, cancel := xctx.WithTimeout(ctx, rexTimeout, "agent list") + defer cancel() agents, err := c.Agents(ctx) isRexdiff := func(a rex.Agent) bool { return a.Status.App == appName } agents = slices.DeleteFunc(agents, isRexdiff) @@ -38,16 +36,15 @@ func (m *model) listAgents(msg agentsMsg) tea.Cmd { } } -// rexTimeout is the longest time of the calls to Rex for one action. A -// Rex that does not answer then fails the action. +// rexTimeout limits how long the Rex calls for one action can take. const rexTimeout = 10 * time.Second -// inRepo reports whether dir is in the repository. An empty dir is -// unknown, and counts as in the repository. +// inRepo reports whether dir is inside the repository. An empty dir means +// the directory is unknown, and counts as inside. func (m *model) inRepo(dir string) bool { return dir == "" || m.repo.Contains(dir) } -// agent returns the state of the agent in block. If the block is new, agent -// adds it. +// agent returns the state of the agent in block, adding an empty state if +// the block is new. func (m *model) agent(block string) *agentState { a := m.agents[block] if a == nil { @@ -57,22 +54,21 @@ func (m *model) agent(block string) *agentState { return a } -// onEvent keeps the state of the agent in the block of e, and follows the -// turns. After a reconnect, it forgets the agents and lists them again, -// as herdr-reviewr does after a lost connection. Nobody sees the agents -// in the gap, so the next state starts no turn. A list from before the -// gap is stale. An event with an error only goes to the log. +// onEvent records the state of the agent in e's block and updates turn +// tracking. After a reconnection, it forgets all agents and lists them +// again, and the next state does not start a turn. An event with an error +// is only logged. func (m *model) onEvent(e rex.Event) tea.Cmd { if e.Err != nil { m.log.Warn("rex events failed", "err", e.Err) return nil } if e.Reconnect { - m.tracker.Gap() + m.tracker.MarkGap() clear(m.agents) - m.sampled = false + m.agentsSampled = false m.agentsGen++ - return m.listAgents(agentsMsg{}) + return m.fetchAgents(agentsMsg{}) } if e.Closed { delete(m.agents, e.Block) @@ -85,27 +81,31 @@ func (m *model) onEvent(e rex.Event) tea.Cmd { return m.observeTurn() } -// onAgents keeps the states of the agents. For a send, onAgents sends the -// review to the agent, or lets the user pick one. A list from before a -// reconnect does not change the states, because they can be old. +// onAgents records the states of the listed agents. If the list is for a +// send, it sends the review to the only agent or lets the user choose one. +// A list taken before a reconnection does not update the states, because +// they may be out of date. func (m *model) onAgents(msg agentsMsg) tea.Cmd { stale := msg.gen != m.agentsGen if stale && !msg.send { - return nil // the list after the reconnect follows + return nil // a new list follows the reconnection } if !stale { - m.keepAgents(msg.agents) + m.storeAgents(msg.agents) } switch { case stale: + // A stale list says nothing about the current connection. case msg.err == nil: - m.sampled = true - case !m.sampled && !msg.send: - // Without a list, the turns do not start. Thus list again later. + m.agentsSampled = true + case !m.agentsSampled && !msg.send: + // Turns cannot be tracked until a list succeeds, so try again + // later. m.log.Warn("agent list failed", "err", msg.err) m.sampleRetry.fail(time.Now()) } - // A send drops the question for a cwd. The next state asks again. + // storeAgents resets each agent's state, including any cwd request in + // progress, so observeTurn may ask for the cwd again. turn := m.observeTurn() if !msg.send { return turn @@ -115,8 +115,8 @@ func (m *model) onAgents(msg agentsMsg) tea.Cmd { m.flashCopy(msg.err.Error()) return nil } - // Prefer the agents in this repository. Else use all the agents of the - // session. + // Prefer agents working in this repository. If there are none, use + // every agent in the session. var here []rex.Agent for _, a := range msg.agents { if m.inRepo(a.Cwd) { @@ -131,12 +131,12 @@ func (m *model) onAgents(msg agentsMsg) tea.Cmd { m.flashCopy("no agent in this session") return nil case len(here) == 1: - return m.deliver(here[0]) - case m.mode != modeNormal && !m.inCommentList(): - // The user can open the comment box or an overlay while the agents - // load. A picker on top can hide the box and lose the comment. The - // picker can take the place of the comment list, as in - // herdr-reviewr. + return m.pasteReview(here[0]) + case m.mode != modeNormal && !m.inCommentsOverlay(): + // The user may have opened the comment box or an overlay while the + // agents were loading. A picker on top could hide the box and lose + // the comment. The comment list is the exception: the picker can + // replace it. m.flash("several agents: close the box, then press " + firstKey(m.keys.Send) + " to pick one") return nil @@ -145,20 +145,21 @@ func (m *model) onAgents(msg agentsMsg) tea.Cmd { return nil } -// keepAgents keeps the states of the agents of a list. -func (m *model) keepAgents(agents []rex.Agent) { +// storeAgents records the state of each agent in agents, replacing any +// earlier state. +func (m *model) storeAgents(agents []rex.Agent) { for _, a := range agents { *m.agent(a.Block) = agentState{ app: a.Status.App, state: a.Status.State, since: cmp.Or(a.Status.At, time.Now()), cwd: a.Cwd, - hasCwd: a.Cwd != "", // else askCwd asks Rex again + hasCwd: a.Cwd != "", // if false, askCwd asks Rex for it } } } -// commentTexts returns the texts of the comments, by ID. +// commentTexts returns the text of each comment, keyed by ID. func (m *model) commentTexts() map[int]string { texts := map[int]string{} for _, c := range m.comments.All() { @@ -167,25 +168,22 @@ func (m *model) commentTexts() map[int]string { return texts } -// reviewText returns the text of the review that a send pastes. +// reviewText returns the review text that is sent or copied. func (m *model) reviewText() string { - return m.comments.Review(m.cfg.ReviewPrefix) + return m.comments.Review(m.cfg.ReviewPrefix, m.commentStale) } -// deliver returns a command that pastes the review into agent a. It checks -// the agent again before the paste, because a prompt of the agent can take -// the text. -func (m *model) deliver(a rex.Agent) tea.Cmd { +// pasteReview returns a command that pastes the review into agent a. It +// checks the agent's status first, because if the agent is waiting at a +// prompt, the prompt would receive the text. +func (m *model) pasteReview(a rex.Agent) tea.Cmd { m.sends++ c, ctx := m.rex, m.ctx text, sent := m.reviewText(), m.commentTexts() focus := m.cfg.focusesAgent() return func() tea.Msg { - ctx, cancel := context.WithCancelCause(ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, rexTimeout, - fmt.Errorf("send: %v timeout: %w", rexTimeout, context.DeadlineExceeded)) - defer cancelTimeout() - defer cancel(fmt.Errorf("send: done: %w", context.Canceled)) + ctx, cancel := xctx.WithTimeout(ctx, rexTimeout, "send") + defer cancel() status, err := c.Status(ctx, a.Block) if err != nil { return sentMsg{agent: a, err: fmt.Errorf("check %s: %w", a.Name, err)} @@ -197,19 +195,28 @@ func (m *model) deliver(a rex.Agent) tea.Cmd { err := fmt.Errorf("answer %s's prompt first", a.Name) return sentMsg{agent: a, err: err} } - if err := c.Paste(ctx, a.Block, text); err != nil { + // Rex pastes a long review in parts. If a part fails after + // others, the agent holds the start of the review, so say so: the + // user should clear the agent's input before copying the review. + err = c.Paste(ctx, a.Block, text) + if partly, ok := errors.AsType[*rex.PasteError](err); ok { + err = fmt.Errorf("only %d of %d parts reached %s, clear its input: %w", + partly.Pasted, partly.Parts, a.Name, partly.Err) + return sentMsg{agent: a, err: err} + } + if err != nil { return sentMsg{agent: a, err: fmt.Errorf("paste into %s: %w", a.Name, err)} } if focus { - // Ignore the error, because the paste works. + // Ignore any error, because the paste itself succeeded. _ = c.Focus(ctx, a.Block) } return sentMsg{agent: a, sent: sent} } } -// onSent removes the sent comments if the send worked. The comments that -// the user adds or changes during the send stay. +// onSent removes the sent comments if the send succeeded. Comments that +// the user added or changed during the send are kept. func (m *model) onSent(msg sentMsg) { m.sends-- if msg.err != nil { @@ -222,7 +229,8 @@ func (m *model) onSent(msg sentMsg) { m.flash(fmt.Sprintf("sent %s to %s", sent, msg.agent.Name)) } -// startSend lists the agents to send the review to. +// startSend checks that the review can be sent and returns a command that +// lists the agents to send it to. func (m *model) startSend() tea.Cmd { switch { case m.comments.Len() == 0: @@ -240,11 +248,11 @@ func (m *model) startSend() tea.Cmd { } m.flash("sending…") m.sends++ - return m.listAgents(agentsMsg{send: true}) + return m.fetchAgents(agentsMsg{send: true}) } -// copyComments copies the review to the clipboard. A good copy clears -// the comments, as a send does. +// copyComments copies the review to the clipboard. A successful copy +// clears the comments, as sending does. func (m *model) copyComments() { if m.comments.Len() == 0 { m.flash("no comments yet") @@ -254,21 +262,16 @@ func (m *model) copyComments() { m.copyText(m.reviewText(), "copied "+plural(len(texts), "comment"), texts) } -// copyTimeout is the longest time of a copy. +// copyTimeout limits how long copying to the clipboard can take. const copyTimeout = 2 * time.Second -// copyText writes text to the clipboard. If the copy works, it removes -// the comments texts and shows done. As in herdr-reviewr, the copy runs -// before the next frame, so two copies keep their order. A clipboard tool -// that does not answer fails after copyTimeout. As in herdr-reviewr, the -// message of a failure names only the fix that the user can make. The log -// has the error. -func (m *model) copyText(text, done string, texts map[int]string) { - ctx, cancel := context.WithCancelCause(m.ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, copyTimeout, - fmt.Errorf("copy: %v timeout: %w", copyTimeout, context.DeadlineExceeded)) - defer cancelTimeout() - defer cancel(fmt.Errorf("copy: done: %w", context.Canceled)) +// copyText writes text to the clipboard, waiting at most copyTimeout, so +// that copies happen in order. On success, it removes the comments in +// texts and shows the message success. On failure, it logs the error and +// shows a short message. +func (m *model) copyText(text, success string, texts map[int]string) { + ctx, cancel := xctx.WithTimeout(m.ctx, copyTimeout, "copy") + defer cancel() err := clipboard.Write(ctx, m.env, text) switch { case errors.Is(err, clipboard.ErrNoTool): @@ -278,41 +281,41 @@ func (m *model) copyText(text, done string, texts map[int]string) { m.flash("copy failed") default: m.clearComments(texts) - m.flash(done) + m.flash(success) } } -// inCommentList reports whether the comment list is open. -func (m *model) inCommentList() bool { - return m.mode == modeOverlay && m.overlay != nil && m.overlay.kind == overlayComments +// inCommentsOverlay reports whether the comment list is open. +func (m *model) inCommentsOverlay() bool { + return m.overlay != nil && m.overlay.kind == overlayComments } -// clearComments removes the comments of texts after a send or a copy. A -// comment whose text is not the text in texts stays, because the user -// edits it during the send. An open comment list shows the rest, or -// closes if no comment stays. +// clearComments removes the comments in texts after a send or copy. A +// comment whose text no longer matches is kept, because the user edited it +// in the meantime. An open comment list is updated, or closed if no +// comments remain. func (m *model) clearComments(texts map[int]string) { for id, text := range texts { if c := m.comments.Find(id); c != nil && c.Text == text { - m.comments.Remove(id) + m.comments.Delete(id) } } - if m.inCommentList() { + if m.inCommentsOverlay() { if m.comments.Len() == 0 { m.closeOverlay() return } - m.fillCommentList(m.overlay) + m.setCommentItems(m.overlay) } } -// flashCopy shows why the review cannot go to an agent, and tells the -// user to copy it. +// flashCopy shows why the review cannot be sent to an agent and suggests +// copying it instead. func (m *model) flashCopy(why string) { m.flash(why + ", press " + firstKey(m.keys.Copy) + " to copy") } -// plural returns n and word, with an s if n is not 1. +// plural returns n followed by word, adding an s if n is not 1. func plural(n int, word string) string { if n == 1 { return "1 " + word diff --git a/tui/tree.go b/tui/tree.go deleted file mode 100644 index 38bf1c5..0000000 --- a/tui/tree.go +++ /dev/null @@ -1,299 +0,0 @@ -package tui - -// This file keeps the file list of each tab as a tree of directories, as -// in herdr-reviewr. The cursor of the list moves over the directories and -// the files. The pane shows the last file that the cursor is on. - -import ( - "slices" - "strings" - - "github.com/rednafi/rexdiff/internal/tree" - "github.com/rednafi/rexdiff/vcs" -) - -// fileListTree is the file list of a tab. -type fileListTree struct { - entries []tree.Entry - rows []tree.Row - cur int // the row of the cursor - // top is the first row on the screen while free is true. free is true - // after the wheel moves the view. Then the cursor can be off the - // screen until the next key. - top int - free bool - // expanded is true if the directories start expanded. toggled has the - // directories that the user changes from that start. - expanded bool - toggled map[string]bool -} - -// rebuild makes the rows of entries. The cursor stays on its directory or -// its file. If the row is gone, or the cursor is -1, the cursor goes to -// the file with index open, else to the first file. -func (t *fileListTree) rebuild(entries []tree.Entry, expanded bool, open int) { - var dir, path string - if t.cur >= 0 && t.cur < len(t.rows) { - if r := t.rows[t.cur]; r.File < 0 { - dir = r.Dir - } else { - path = t.entries[r.File].Path - } - } - t.entries, t.expanded = entries, expanded - t.rows = tree.Build(entries, t.toggled, expanded) - t.cur = slices.IndexFunc(t.rows, func(r tree.Row) bool { - if r.File < 0 { - return dir != "" && r.Dir == dir - } - return path != "" && entries[r.File].Path == path - }) - if t.cur < 0 { - t.cur = t.rowOf(open) - } - if t.cur < 0 { - t.cur = t.nextFile(-1, 1) - } - t.cur = max(t.cur, 0) -} - -// file returns the index of the file at the cursor, or -1 on a directory. -func (t *fileListTree) file() int { - if t.cur >= len(t.rows) { - return -1 - } - return t.rows[t.cur].File -} - -// dir returns the row at the cursor if it is a directory. -func (t *fileListTree) dir() (tree.Row, bool) { - if t.cur >= len(t.rows) || t.rows[t.cur].File >= 0 { - return tree.Row{}, false - } - return t.rows[t.cur], true -} - -// rowOf returns the row of the file with index i, or -1 if no row shows -// it. -func (t *fileListTree) rowOf(i int) int { - if i < 0 { - return -1 - } - return slices.IndexFunc(t.rows, func(r tree.Row) bool { return r.File == i }) -} - -// nextFile returns the first file row after row from in direction d (1 or -// -1), or -1. -func (t *fileListTree) nextFile(from, d int) int { - for r := from + d; r >= 0 && r < len(t.rows); r += d { - if t.rows[r].File >= 0 { - return r - } - } - return -1 -} - -// setExpanded expands or collapses the directory dir. It reports whether -// the state changes. -func (t *fileListTree) setExpanded(dir string, want bool) bool { - if (t.expanded != t.toggled[dir]) == want { - return false - } - if t.toggled == nil { - t.toggled = map[string]bool{} - } - if t.toggled[dir] { - delete(t.toggled, dir) - } else { - t.toggled[dir] = true - } - return true -} - -// reveal expands each directory above path. It reports whether one -// changes. -func (t *fileListTree) reveal(path string) bool { - changed := false - for dir, _, ok := strings.CutLast(path, "/"); ok; dir, _, ok = strings.CutLast(dir, "/") { - changed = t.setExpanded(dir, true) || changed - } - return changed -} - -// list returns the file list of the current tab, or nil on the PR tab. -func (m *model) list() *fileListTree { - switch m.tab { - case tabChanges: - return &m.changeTree - case tabFiles: - return &m.allTree - } - return nil -} - -// buildChangeTree makes the rows of the Changes tab. The selected file is -// the file at the cursor, if the cursor is on a file. -func (m *model) buildChangeTree() { - es := make([]tree.Entry, len(m.changeFiles)) - for i, f := range m.changeFiles { - es[i] = tree.Entry{Path: f.Path, Changed: true} - } - t := &m.changeTree - t.rebuild(es, true, m.changeSel) - if i := t.file(); i >= 0 { - m.changeSel = i - } - m.changeSel = max(m.changeSel, 0) -} - -// buildFileTree makes the rows of the Files tab. A directory holds a -// change if a file under it is in the Changes tab. The selected file is -// the file at the cursor, if the cursor is on a file. -func (m *model) buildFileTree() { - changed := make(map[string]bool, len(m.changeFiles)) - for _, f := range m.changeFiles { - changed[f.Path] = true - } - es := make([]tree.Entry, len(m.allFiles)) - for i, e := range m.allFiles { - p, isDir := strings.CutSuffix(e.Path, "/") - es[i] = tree.Entry{Path: p, Changed: changed[p], Ignored: e.Ignored, Dir: isDir} - } - t := &m.allTree - t.rebuild(es, false, m.allSel) - if i := t.file(); i >= 0 { - m.allSel = i - } - m.allSel = min(max(m.allSel, 0), max(len(m.allFiles)-1, 0)) -} - -// buildTree makes the rows of the file list of the current tab. -func (m *model) buildTree() { - switch m.tab { - case tabChanges: - m.buildChangeTree() - case tabFiles: - m.buildFileTree() - } -} - -// withIgnored adds to es the entries of each ignored directory that the -// user expands in the Files tab, and sorts es by path. -func (m *model) withIgnored(es []vcs.Entry) []vcs.Entry { - // The loop also reads the entries that it adds, for the directories - // in an ignored directory. - for i := 0; i < len(es); i++ { - if dir, ok := m.openIgnored(es[i]); ok { - es = append(es, m.repo.IgnoredDir(dir)...) - } - } - slices.SortStableFunc(es, func(a, b vcs.Entry) int { return strings.Compare(a.Path, b.Path) }) - return es -} - -// shownPaths returns the paths for Watcher.Show when the Files tab -// shows: the ignored directories that the user expands and the open file. -// The watcher reports their changes, as in herdr-reviewr. -func (m *model) shownPaths() []string { - if m.tab != tabFiles { - return nil - } - var paths []string - for _, e := range m.allFiles { - if dir, ok := m.openIgnored(e); ok { - paths = append(paths, dir) - } - } - if m.allSel < len(m.allFiles) { - if e := m.allFiles[m.allSel]; !strings.HasSuffix(e.Path, "/") { - paths = append(paths, e.Path) - } - } - return paths -} - -// openIgnored returns the path of e without the last "/". It also reports -// whether e is an ignored directory that the user expands in the Files -// tab. -func (m *model) openIgnored(e vcs.Entry) (string, bool) { - dir, isDir := strings.CutSuffix(e.Path, "/") - return dir, isDir && e.Ignored && m.allTree.toggled[dir] -} - -// atDir reports whether the file list has the focus and its cursor is on -// a directory. -func (m *model) atDir() bool { - if t := m.list(); t != nil && m.focusList { - _, ok := t.dir() - return ok - } - return false -} - -// setDir expands or collapses the directory at the cursor of the file -// list. An ignored directory reads its entries when it expands. -func (m *model) setDir(want bool) { - t := m.list() - r, ok := t.dir() - if !ok || !t.setExpanded(r.Dir, want) { - return - } - if m.tab == tabFiles && r.Ignored && want { - m.readFiles() - } - m.buildTree() -} - -// stepRow moves the cursor of the file list by d rows. On a file, the pane -// shows the file. -func (m *model) stepRow(d int) { - t := m.list() - if t == nil || len(t.rows) == 0 { - return - } - t.cur = min(max(t.cur+d, 0), len(t.rows)-1) - if i := t.file(); i >= 0 && i != m.listSel() { - m.pickFile(i) - } -} - -// stepFile shows the next (d = 1) or the previous (d = -1) file of the -// list, past the directories. It starts from the cursor of the list when -// the list has the focus, else from the file that shows. -func (m *model) stepFile(d int) { - t := m.list() - if t == nil { - return - } - from := t.cur - if r := t.rowOf(m.listSel()); !m.focusList && r >= 0 { - from = r - } - if r := t.nextFile(from, d); r >= 0 { - m.pickFile(t.rows[r].File) - } -} - -// clickRow handles a click on row r of the file list. A file shows, and a -// directory expands or collapses. -func (m *model) clickRow(r int) { - t := m.list() - if t == nil || r < 0 || r >= len(t.rows) { - return - } - m.focusList = true - t.cur = r - if d, ok := t.dir(); ok { - m.setDir(!d.Expanded) - return - } - m.pickFile(t.rows[r].File) -} - -// revealFile expands the directories above path in the file list of the -// current tab, so that its row shows. -func (m *model) revealFile(path string) { - if t := m.list(); t != nil && t.reveal(path) { - m.buildTree() - } -} diff --git a/tui/tui.go b/tui/tui.go index 1b83744..44d8912 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -1,6 +1,6 @@ -// Package tui is the terminal program of rexdiff. It shows the changes in a -// repository and takes review comments on the lines. It sends the comments -// to a coding agent in Rex. +// Package tui implements the terminal UI of rexdiff. It shows the changes +// in a repository, lets the user add review comments to lines, and sends +// the comments to a coding agent in Rex. package tui import ( @@ -12,95 +12,93 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/rednafi/rexdiff/internal/xctx" "github.com/rednafi/rexdiff/rex" "github.com/rednafi/rexdiff/update" "github.com/rednafi/rexdiff/vcs" ) -// appName is the name that rexdiff reports to Rex, and the label of its -// block. +// appName is the name that rexdiff reports to Rex, and the label it gives +// its own block. const appName = "rexdiff" // Options configure Run. type Options struct { Repo *vcs.Repo // the repository to review, not nil - // Rex is the Rex session of the process, or nil outside Rex. Without - // Rex, the user cannot send the review. The user can only copy it. + // Rex is the Rex session that the process runs in, or nil outside + // Rex. Without Rex, the user can copy the review but not send it. Rex *rex.Client - // Config is the settings as JSON, from rexdiff.lua. An empty text + // Config holds the settings as JSON, read from rexdiff.lua. Empty // means the default settings. Config string - // Layout and Scope override the settings if they are not empty. Set - // them from the command-line flags -layout and -scope. The error of a - // bad value names the flag. + // Layout and Scope, if not empty, override the settings. They come + // from the -layout and -scope flags, and the error for an invalid + // value names the flag. Layout Layout Scope Scope - // Agent is the block ID or the label of the agent that the program - // opens beside, or "". The agent picker selects it first. + // Agent is the block ID or label of the agent beside rexdiff, or "". + // The agent picker selects it first. Agent string - // Wrap continues long lines on the next lines, instead of cutting - // them. The key w changes it. + // Wrap wraps long lines instead of cutting them off. The w key turns + // it on and off. Wrap bool - // Base is the base of the branch scope. An empty value means the - // default branch of the repository. + // Base is the base of the branch scope. Empty means the repository's + // default branch. Base string - Log *slog.Logger // nil means no log - // Notice, if not empty, is a problem that the footer shows until the - // program quits. + Log *slog.Logger // nil disables logging + // Notice, if not empty, describes a problem that the footer shows + // until rexdiff quits. Notice string - // Updater installs a newer release, or is nil if rexdiff cannot update - // itself. If it is not nil, the program asks for the latest release. - Updater *update.Updater - // Resume is the file of the state that Run returned before an update, - // or "". Run reads it and removes it. + // Installer installs a newer release, or is nil if rexdiff cannot + // update itself. If it is not nil, rexdiff checks for the latest + // release. + Installer *update.Installer + // Resume is the state file that Run returned before an update, or "". + // Run reads the file and then removes it. Resume string - // Env is the environment of the editor, the URL opener, and the - // clipboard tool, for example the environment of the process from - // os.Environ. Run finds them in its PATH, and reads $VISUAL, $EDITOR, - // $TMPDIR, and the variables of the terminal from it. + // Env is the environment for the editor, the URL opener and the + // clipboard tool, such as [os.Environ]. Run looks these tools up in + // its PATH, and reads $VISUAL, $EDITOR, $TMPDIR and the terminal + // variables from it. Env []string } -// check returns an error for options that Run cannot use. -func (o Options) check() error { +// validate returns an error if Run cannot use o. +func (o Options) validate() error { if o.Repo == nil { return errors.New("no repository") } - return CheckFlags(o.Layout, o.Scope) + return ValidateFlags(o.Layout, o.Scope) } -// CheckFlags returns the error of the values of the flags -layout and -// -scope, or nil if rexdiff accepts them. An empty value is the default. -func CheckFlags(layout Layout, scope Scope) error { +// ValidateFlags returns an error if the values of the -layout or -scope +// flags are invalid. An empty value means the default and is valid. +func ValidateFlags(layout Layout, scope Scope) error { if layout != "" { - if err := choice("-layout", layout, layouts); err != nil { + if err := validateChoice("-layout", layout, layouts); err != nil { return err } } if scope != "" { - return choice("-scope", scope, configScopes) + return validateChoice("-scope", scope, configScopes) } return nil } -// CheckConfig returns the error of the settings text, a JSON object, or -// nil if rexdiff accepts it. -func CheckConfig(text string) error { +// ValidateConfig returns an error if text, the settings as a JSON object, +// is invalid. +func ValidateConfig(text string) error { _, err := parseConfig(text) return err } -// Run shows the review program in the terminal. It returns when the user -// quits or when ctx is done. Run returns an error before it starts if the -// settings are not valid. It also returns an error if Repo is nil, or if -// Run does not know the Layout or the Scope. After an update, Run returns -// the file of the saved state. The caller then runs the new rexdiff with -// Options.Resume set to that file. In that case Run keeps the alternate -// screen on, with the last frame and no cursor, so that the restart does -// not show the shell. If the restart fails, the caller leaves the -// alternate screen and shows the cursor. +// Run runs the UI in the terminal until the user quits or ctx is done. It +// fails at once if the settings are invalid, Repo is nil, or Layout or +// Scope is unknown. After an update, it returns the saved state file to +// pass as [Options.Resume], and leaves the alternate screen on with the +// cursor hidden; if the restart fails, the caller must restore both. func Run(ctx context.Context, o Options) (resume string, err error) { - if err := o.check(); err != nil { + if err := o.validate(); err != nil { return "", err } cfg, err := parseConfig(o.Config) @@ -111,8 +109,8 @@ func Run(ctx context.Context, o Options) (resume string, err error) { defer cancel(errQuit) m := startModel(ctx, o, cfg) - // Bubble Tea reads TERM and COLORTERM from env. A nil env makes it - // read the environment of the process. + // Bubble Tea reads TERM and COLORTERM from env. Given a nil env, it + // would read the process environment instead. env := o.Env if env == nil { env = []string{} @@ -120,9 +118,10 @@ func Run(ctx context.Context, o Options) (resume string, err error) { program := tea.NewProgram(&m, tea.WithContext(ctx), tea.WithEnvironment(env)) var wg sync.WaitGroup if o.Rex != nil { - // Watch returns only the cause of ctx, when ctx is done. + // Events returns only when ctx is done, and then returns its + // cause, so the error can be ignored. wg.Go(func() { - _ = o.Rex.Watch(ctx, func(e rex.Event) { program.Send(eventMsg(e)) }) + _ = o.Rex.Events(ctx, func(e rex.Event) { program.Send(eventMsg(e)) }) }) } final, err := program.Run() @@ -130,8 +129,8 @@ func Run(ctx context.Context, o Options) (resume string, err error) { wg.Wait() if fm, ok := final.(*model); ok && err == nil { if fm.resumePath != "" { - // Keep the last screen until the new rexdiff draws, so that - // the restart does not show the shell of the pane. + // Keep the last frame on screen until the new rexdiff draws, + // so that the shell does not appear during the restart. fmt.Print("\x1b[?1049h\x1b[?25l\x1b[H" + fm.View().Content) } return fm.resumePath, nil @@ -139,39 +138,35 @@ func Run(ctx context.Context, o Options) (resume string, err error) { return "", err } -// errQuit is the cause of the stop of the work of Run when rexdiff quits. +// errQuit is the cancellation cause for Run's background work when +// rexdiff quits. var errQuit = fmt.Errorf("rexdiff quits: %w", context.Canceled) -// startModel returns the model that Run shows. It starts the watcher, -// reads the state of o.Resume, loads the changes, and labels the block of -// the program in Rex. +// startModel returns the model that Run displays. It restores the +// settings saved in o.Resume, asks for the changes to load, and labels the +// rexdiff block in Rex. [model.Init] starts the loading, so that the first +// frame does not wait for git. func startModel(ctx context.Context, o Options, cfg config) model { m := newModel(ctx, o, cfg) - if err := m.startWatch(); err != nil { - m.dropWatch(err) - } var saved *resumeState if o.Resume != "" { var err error if saved, err = loadResume(o.Resume); err != nil { m.flash("restore of the comments failed: " + err.Error()) - } else { - m.restoreSettings(saved) } } - m.loadNow() + // loadResume returns a nil state with an error. if saved != nil { - m.restorePlaces(saved) + m.restoreSettings(saved) } + m.requestReload() + m.changes.pane.note = "loading changes…" if o.Rex != nil && o.Rex.Block() != "" { - ctx, cancel := context.WithCancelCause(ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, rexTimeout, - fmt.Errorf("rex label: %v timeout: %w", rexTimeout, context.DeadlineExceeded)) + ctx, cancel := xctx.WithTimeout(ctx, rexTimeout, "rex label") if err := o.Rex.SetLabel(ctx, o.Rex.Block(), appName); err != nil { m.log.Debug("set label failed", "err", err) } - cancel(fmt.Errorf("rex label: done: %w", context.Canceled)) - cancelTimeout() + cancel() } return m } diff --git a/tui/turn.go b/tui/turn.go index e6b1b85..42ed12f 100644 --- a/tui/turn.go +++ b/tui/turn.go @@ -1,30 +1,29 @@ package tui import ( - "context" - "fmt" "maps" "slices" "time" tea "charm.land/bubbletea/v2" + "github.com/rednafi/rexdiff/internal/xctx" "github.com/rednafi/rexdiff/rex" ) -// member is an agent that works in the repository. +// member is an agent working in the repository. type member struct { block, app string state rex.State - since time.Time // when Rex got the state + since time.Time // when Rex received the state } -// members returns the agents that work in the repository, in the order -// of their blocks. As in herdr-reviewr, an agent is a member if its -// working directory is absolute and in this working tree. If the place of -// an agent is not known, members returns false, and the block of the -// agent if Rex can tell its place. -func (m *model) members() (ms []member, ask string, ok bool) { +// turnMembers returns the agents working in the repository, sorted by +// block. An agent is a member if its working directory is absolute and +// inside this working tree. If an agent's directory is not known, ok is +// false, and ask is that agent's block if Rex can be asked for the +// directory. +func (m *model) turnMembers() (ms []member, ask string, ok bool) { for _, block := range slices.Sorted(maps.Keys(m.agents)) { a := m.agents[block] if a.state == "" || a.app == appName || (m.rex != nil && block == m.rex.Block()) { @@ -38,7 +37,8 @@ func (m *model) members() (ms []member, ask string, ok bool) { } in, err := m.owns(a.cwd) if err != nil { - // Each event asks again. The log has each error once. + // Every event tries again, so log each distinct error only + // once. if text := err.Error(); text != m.ownsErr { m.ownsErr = text m.log.Warn("agent membership failed", "err", err) @@ -54,7 +54,8 @@ func (m *model) members() (ms []member, ask string, ok bool) { } // askCwd returns a command that asks Rex for the working directory of the -// agent in block. The answer comes as a cwdMsg. +// agent in block, or nil if a request is already in progress. The answer +// arrives as a cwdMsg. func (m *model) askCwd(block string) tea.Cmd { a := m.agents[block] if a.asking { @@ -63,22 +64,19 @@ func (m *model) askCwd(block string) tea.Cmd { a.asking = true c, ctx := m.rex, m.ctx return func() tea.Msg { - ctx, cancel := context.WithCancelCause(ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, rexTimeout, - fmt.Errorf("rex cwd: %v timeout: %w", rexTimeout, context.DeadlineExceeded)) - defer cancelTimeout() - defer cancel(fmt.Errorf("rex cwd: done: %w", context.Canceled)) + ctx, cancel := xctx.WithTimeout(ctx, rexTimeout, "rex cwd") + defer cancel() cwd, err := c.Cwd(ctx, block) return cwdMsg{block: block, cwd: cwd, err: err} } } -// onCwd keeps the working directory of an agent, and follows the turns -// with it. After an error, the next state asks again. +// onCwd records an agent's working directory and updates turn tracking. +// After an error, the next state change asks again. func (m *model) onCwd(msg cwdMsg) tea.Cmd { a := m.agents[msg.block] if a == nil { - return nil // the block closed + return nil // the block has closed } a.asking = false if msg.err != nil { @@ -89,45 +87,48 @@ func (m *model) onCwd(msg cwdMsg) tea.Cmd { return m.observeTurn() } -// owns reports whether dir is in this working tree. It keeps the -// directories that are, because their top directory does not move. +// owns reports whether dir is inside this working tree. It caches each +// answer, because the top of the working tree does not move, and every +// Rex event asks again on the UI goroutine. func (m *model) owns(dir string) (bool, error) { - if m.memberDirs[dir] { - return true, nil - } - in, err := m.repo.Owns(m.ctx, dir) - if in { - if m.memberDirs == nil { - m.memberDirs = map[string]bool{} + if in, ok := m.ownsDir[dir]; ok { + return in, nil + } + ctx, done := m.gitContext("owns") + in, err := m.repo.Owns(ctx, dir) + done() + if err == nil { + if m.ownsDir == nil { + m.ownsDir = map[string]bool{} } - m.memberDirs[dir] = true + m.ownsDir[dir] = in } return in, err } -// observeTurn gives the state of the repository to the tracker, and -// follows the start and the end of a turn. +// observeTurn passes the states of the agents in the repository to the +// tracker and handles the start or end of a turn. func (m *model) observeTurn() tea.Cmd { - if !m.sampled { + if !m.agentsSampled { return nil } - ms, ask, ok := m.members() + ms, ask, ok := m.turnMembers() if ask != "" { return m.askCwd(ask) } if !ok { - return nil // keep the last state, as herdr-reviewr does + return nil // keep the previous state } here := len(ms) > 0 - if m.agentsHere == nil || *m.agentsHere != here { - m.agentsHere = &here + if m.agentInRepo == nil || *m.agentInRepo != here { + m.agentInRepo = new(here) if m.scope == ScopeLastTurn && m.turn == nil { - m.reload() // show the new wait message + m.requestReload() // show the updated waiting message } } - states := make([]string, len(ms)) + states := make([]rex.State, len(ms)) for i, a := range ms { - states[i] = string(a.state) + states[i] = a.state } started, ended := m.tracker.Observe(states...) if started { @@ -139,54 +140,64 @@ func (m *model) observeTurn() tea.Cmd { return nil } -// turnWaitMessage tells why the last-turn scope has no turn. The messages -// come from herdr-reviewr. -func (m *model) turnWaitMessage() string { - if m.raced { +// turnWaitNote returns a message explaining why the last-turn scope has no +// turn to show. +func (m *model) turnWaitNote() string { + if m.turnRaced { return "the last turn started before rexdiff could snapshot it" } - if m.agentsHere != nil && !*m.agentsHere { + if m.agentInRepo != nil && !*m.agentInRepo { return "no agent works here" } return "waiting for the first turn" } -// raceBefore is how long before Rex gets the working state the agent can -// already write, as in herdr-reviewr. +// raceBefore is how long before Rex receives the working state an agent +// may already be writing files. const raceBefore = time.Second // startTurn returns a command that takes a snapshot of the working tree -// at the start of a turn of the members ms. The snapshot comes as a +// when the members ms start a turn. The snapshot arrives as a // turnStartMsg. func (m *model) startTurn(ms []member) tea.Cmd { - m.pending = nil + m.pendingTurn = nil m.turnGen++ + // A turn starts only when some member is working, so i is valid. i := slices.IndexFunc(ms, func(a member) bool { return a.state == rex.StateWorking }) - gen, a, repo, ctx := m.turnGen, ms[i], m.repo, m.ctx + gen, a, repo, watch, ctx := m.turnGen, ms[i], m.repo, m.watch, m.ctx return func() tea.Msg { - ctx, cancel := context.WithCancelCause(ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, reloadTimeout, - fmt.Errorf("turn snapshot: %v timeout: %w", reloadTimeout, context.DeadlineExceeded)) - defer cancelTimeout() - defer cancel(fmt.Errorf("turn snapshot: done: %w", context.Canceled)) - tree, err := repo.Snapshot(ctx) - return turnStartMsg{gen: gen, agent: a, tree: tree, snapped: time.Now(), err: err} + ctx, cancel := xctx.WithTimeout(ctx, reloadTimeout, "turn snapshot") + defer cancel() + tree, err := repo.SnapshotTree(ctx) + msg := turnStartMsg{gen: gen, agent: a, tree: tree, snapped: time.Now(), err: err} + if watch != nil && err == nil { + // LastWrite waits for a watcher check, so call it here rather + // than in Update. A write after the snapshot does not hide one + // before it. + w := watch.LastWrite(msg.snapped) + msg.write = w + msg.raced = !w.Before(a.since.Add(-raceBefore)) && !w.After(msg.snapped) + } + return msg } } -// turnStartMsg is the snapshot at the start of a turn. +// turnStartMsg carries the snapshot taken at the start of a turn. type turnStartMsg struct { - gen int // the value of model.turnGen at the start - agent member // the first agent of the turn that works + gen int // the value of model.turnGen when the snapshot started + agent member // the first working agent in the turn tree string - snapped time.Time // the end of the snapshot + snapped time.Time // when the snapshot finished + write time.Time // the last write before snapped, or zero + raced bool // write happened in the window just before the snapshot err error } -// onTurnStart makes the turn of msg pending. The turn stays pending until -// it changes a file. As in herdr-reviewr, a write from raceBefore the start -// to the snapshot can be in the snapshot. Then the turn is not known, and -// the scope says so. A start or an end after the start of msg drops msg. +// onTurnStart records the turn in msg as pending. It stays pending until +// it changes a file. If a file was written in the time from raceBefore +// before the start to the snapshot, the snapshot may already hold the +// agent's changes, so the turn is forgotten and the scope says so. msg is +// ignored if another turn started or ended after it was sent. func (m *model) onTurnStart(msg turnStartMsg) tea.Cmd { if msg.gen != m.turnGen { return nil @@ -196,16 +207,12 @@ func (m *model) onTurnStart(msg turnStartMsg) tea.Cmd { return nil } a := msg.agent - if m.watch != nil { - // A write after the snapshot does not hide a write before it. - w := m.watch.LastWrite(msg.snapped) - if !w.Before(a.since.Add(-raceBefore)) && !w.After(msg.snapped) { - m.log.Debug("turn raced", "block", a.block, "write", w) - return m.dropTurn() - } + if msg.raced { + m.log.Debug("turn raced", "block", a.block, "write", msg.write) + return m.forgetTurn() } m.log.Debug("turn start", "block", a.block, "tree", msg.tree) - m.pending = &turnState{ + m.pendingTurn = &turnState{ app: a.app, tree: msg.tree, running: true, @@ -214,39 +221,37 @@ func (m *model) onTurnStart(msg turnStartMsg) tea.Cmd { return nil } -// checkTurnLater returns a command that takes a snapshot for the pending -// turn. onTurnCheck makes the turn the last turn if the working tree -// differs from its start. A check at the end of the turn always runs. -// Else one check runs at a time. -func (m *model) checkTurnLater(end bool) tea.Cmd { +// scheduleTurnCheck returns a command that takes a snapshot for the +// pending turn. onTurnCheck then makes it the last turn if the working +// tree differs from the start of the turn. A check at the end of a turn +// always runs; other checks run one at a time. +func (m *model) scheduleTurnCheck(end bool) tea.Cmd { if m.turnChecking && !end { return nil } m.turnChecking = m.turnChecking || !end - p, gen, repo, ctx := m.pending, m.turnGen, m.repo, m.ctx + p, gen, repo, ctx := m.pendingTurn, m.turnGen, m.repo, m.ctx return func() tea.Msg { - ctx, cancel := context.WithCancelCause(ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, reloadTimeout, - fmt.Errorf("turn check: %v timeout: %w", reloadTimeout, context.DeadlineExceeded)) - defer cancelTimeout() - defer cancel(fmt.Errorf("turn check: done: %w", context.Canceled)) - tree, err := repo.Snapshot(ctx) + ctx, cancel := xctx.WithTimeout(ctx, reloadTimeout, "turn check") + defer cancel() + tree, err := repo.SnapshotTree(ctx) return turnCheckMsg{turn: p, gen: gen, end: end, tree: tree, err: err} } } -// turnCheckMsg is a snapshot for the pending turn. +// turnCheckMsg carries a snapshot taken for the pending turn. type turnCheckMsg struct { turn *turnState - gen int // the value of model.turnGen at the check - end bool // the check is at the end of the turn + gen int // the value of model.turnGen when the check started + end bool // the check was made at the end of the turn tree string err error } -// onTurnCheck makes the turn of msg the last turn if the working tree -// differs from its start. Before the end, the turn must still be pending. -// After the end, no new turn must start. Else the last turn stays. +// onTurnCheck makes the turn in msg the last turn if the working tree +// differs from the start of the turn. For a check before the end, the turn +// must still be pending. For a check at the end, no new turn may have +// started since. Otherwise the last turn does not change. func (m *model) onTurnCheck(msg turnCheckMsg) tea.Cmd { if !msg.end { m.turnChecking = false @@ -255,31 +260,34 @@ func (m *model) onTurnCheck(msg turnCheckMsg) tea.Cmd { case msg.err != nil: m.log.Warn("turn change check failed", "err", msg.err) case msg.tree == msg.turn.tree: - case msg.end && msg.gen == m.turnGen, !msg.end && msg.turn == m.pending: - return m.promote(msg.turn) + // Nothing changed during the turn. + case msg.end && msg.gen == m.turnGen: + return m.promoteTurn(msg.turn) + case !msg.end && msg.turn == m.pendingTurn: + return m.promoteTurn(msg.turn) } return nil } -// dropTurn forgets the last turn, because a new turn starts that is not -// known. It returns a command that deletes the saved tree. -func (m *model) dropTurn() tea.Cmd { - m.turn, m.raced = nil, true +// forgetTurn forgets the last turn when a new turn starts whose starting +// state cannot be known. It returns a command that deletes the saved tree. +func (m *model) forgetTurn() tea.Cmd { + m.turn, m.turnRaced = nil, true if m.scope == ScopeLastTurn { - m.reload() + m.requestReload() } return m.saveTurn("") } -// promote makes the turn t the last turn. It returns a command that saves -// its tree. -func (m *model) promote(t *turnState) tea.Cmd { - m.turn, m.raced = t, false - if m.pending == t { - m.pending = nil +// promoteTurn makes t the last turn. It returns a command that saves its +// tree. +func (m *model) promoteTurn(t *turnState) tea.Cmd { + m.turn, m.turnRaced = t, false + if m.pendingTurn == t { + m.pendingTurn = nil } if m.scope == ScopeLastTurn { - m.reload() + m.requestReload() } return m.saveTurn(t.tree) } @@ -289,11 +297,8 @@ func (m *model) promote(t *turnState) tea.Cmd { func (m *model) saveTurn(tree string) tea.Cmd { repo, ctx, log := m.repo, m.ctx, m.log return func() tea.Msg { - ctx, cancel := context.WithCancelCause(ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, reloadTimeout, - fmt.Errorf("turn save: %v timeout: %w", reloadTimeout, context.DeadlineExceeded)) - defer cancelTimeout() - defer cancel(fmt.Errorf("turn save: done: %w", context.Canceled)) + ctx, cancel := xctx.WithTimeout(ctx, reloadTimeout, "turn save") + defer cancel() if err := repo.SaveTurn(ctx, tree); err != nil { log.Warn("turn baseline ref write failed", "err", err) } @@ -301,22 +306,22 @@ func (m *model) saveTurn(tree string) tea.Cmd { } } -// endTurn ends the turn. It returns a command that checks the pending turn. -// The turn becomes the last turn if it changes the working tree. After the -// end, a change is not from the turn. +// endTurn ends the current turn. It returns a command that checks the +// pending turn, which becomes the last turn if it changed the working +// tree. Changes after the end do not belong to the turn. func (m *model) endTurn() tea.Cmd { - m.turnGen++ // a start that is not pending yet stays out + m.turnGen++ // ignore a turn start whose snapshot has not arrived yet var cmd tea.Cmd - if m.pending != nil { - m.pending.running = false - cmd = m.checkTurnLater(true) - m.pending = nil + if m.pendingTurn != nil { + m.pendingTurn.running = false + cmd = m.scheduleTurnCheck(true) + m.pendingTurn = nil } if m.turn != nil { m.turn.running = false } if m.scope == ScopeLastTurn { - m.reload() + m.requestReload() } return cmd } diff --git a/tui/view.go b/tui/view.go index dfddb46..a5e6dfa 100644 --- a/tui/view.go +++ b/tui/view.go @@ -3,7 +3,7 @@ package tui import ( "fmt" "path" - "slices" + "strconv" "strings" "time" "unicode/utf8" @@ -18,33 +18,34 @@ import ( "github.com/rednafi/rexdiff/vcs" ) -// The colours are ANSI palette indices, so they follow the terminal theme. +// The colours are ANSI palette indexes, so they follow the terminal theme. var ( - // dimColor is the colour of the dim text and of the borders. - dimColor = lipgloss.Color("8") - dimStyle = lipgloss.NewStyle().Foreground(dimColor) - boldStyle = lipgloss.NewStyle().Bold(true) - addStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - delStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true) - noteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) - headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("4")) - activeStyle = lipgloss.NewStyle().Bold(true).Underline(true) - findStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + // dimColor is the colour of dim text and borders. + dimColor = lipgloss.Color("8") + dimStyle = lipgloss.NewStyle().Foreground(dimColor) + boldStyle = lipgloss.NewStyle().Bold(true) + addedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + deletedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + accentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true) + noteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("4")) + findStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // the file path above the diff ) -// truncate cuts s to w columns, with an ellipsis. +// truncate cuts s to at most w columns, ending with an ellipsis if it +// was cut. func truncate(s string, w int) string { return ansi.Truncate(s, max(w, 0), "…") } -// dark reports whether the terminal background is dark. A background that -// is not known counts as dark. +// dark reports whether the terminal background is dark. An unknown +// background counts as dark. func (m *model) dark() bool { return m.bg == nil || tea.BackgroundColorMsg{Color: m.bg}.IsDark() } -// selStyle returns the style of a selected row. The style is a tint of the -// terminal background. -func (m *model) selStyle() lipgloss.Style { +// highlightStyle returns the style for a highlighted row: a tint of the +// terminal background, or reverse video if the background is unknown. +func (m *model) highlightStyle() lipgloss.Style { if m.bg == nil { return lipgloss.NewStyle().Reverse(true) } @@ -55,34 +56,35 @@ func (m *model) selStyle() lipgloss.Style { return lipgloss.NewStyle().Bold(true).Background(tint) } -// headerAction is what a click on a part of the header does. +// headerAction is the action that clicking part of the header performs. type headerAction int const ( headerNone headerAction = iota headerTabChanges - headerTabFiles + headerTabAllFiles headerTabPR - headerScope0 // the first scope. Add the index of the scope. + headerScope0 // the first scope; add the scope's index for the others headerBase = headerScope0 + headerAction(len(scopes)) headerCommits = headerBase + 1 headerAgent = headerBase + 2 ) -// headerSpan is a part of the header that takes clicks. +// headerSpan is a clickable part of the header. type headerSpan struct { x0, x1 int action headerAction } -// headerLine builds the top line, and keeps the parts that take clicks. +// headerLine builds the top line and records its clickable parts. type headerLine struct { b strings.Builder spans []headerSpan x int // the width so far, in columns } -// add adds s. A click on s does action. +// add appends s to the line. Clicking s performs action, unless action is +// headerNone. func (l *headerLine) add(s string, action headerAction) { w := lipgloss.Width(s) if action != headerNone { @@ -92,17 +94,17 @@ func (l *headerLine) add(s string, action headerAction) { l.x += w } -// header renders the top line, and returns the parts that take clicks. +// header renders the top line and returns its clickable parts. func (m *model) header() (string, []headerSpan) { var l headerLine for i, t := range []string{"Changes", "Files", "PR"} { - label := fmt.Sprintf("%d %s", i+1, t) + // The key is bold and its label plain, as in the footer. The + // labels of the other tabs are dim. + name := dimStyle.Render(t) if tab(i) == m.tab { - label = activeStyle.Render(label) - } else { - label = dimStyle.Render(label) + name = t } - l.add(" "+label+" ", headerTabChanges+headerAction(i)) + l.add(" "+boldStyle.Render(strconv.Itoa(i+1))+" "+name+" ", headerTabChanges+headerAction(i)) } sep := dimStyle.Render(" │ ") switch m.tab { @@ -115,7 +117,7 @@ func (m *model) header() (string, []headerSpan) { l.add(dimStyle.Render(string(pr.Forge)+" "), headerNone) } } - // The right end shows where a send goes. + // The right end shows where a review would be sent. if right := m.agentLabel(); right != "" { if pad := m.w - l.x - lipgloss.Width(right) - 1; pad > 0 { l.add(strings.Repeat(" ", pad), headerNone) @@ -125,13 +127,13 @@ func (m *model) header() (string, []headerSpan) { return truncate(l.b.String(), m.w), l.spans } -// headerScopes adds the scopes to the header, and what the current scope -// compares with. +// headerScopes adds the scopes to the header, followed by what the current +// scope compares against. func (m *model) headerScopes(l *headerLine) { for i, s := range scopes { style := dimStyle if s == m.scope { - style = cursorStyle + style = lipgloss.NewStyle() } l.add(style.Render(s.label()), headerScope0+headerAction(i)) l.add(" ", headerNone) @@ -148,14 +150,15 @@ func (m *model) headerScopes(l *headerLine) { } } -// commitsLabel returns the header text for the commits of the commits scope. +// commitsLabel returns the header text naming the commits chosen for the +// commits scope. func (m *model) commitsLabel() string { - if m.to == "" { + if m.newest == "" { return "pick ▾" } - label := shortSHA(m.to) - if m.from != m.to { - label = shortSHA(m.from) + ".." + shortSHA(m.to) + label := shortSHA(m.newest) + if m.oldest != m.newest { + label = shortSHA(m.oldest) + ".." + shortSHA(m.newest) } return label + dimStyle.Render(" ▾") } @@ -163,23 +166,23 @@ func (m *model) commitsLabel() string { // shortSHA returns the first 7 characters of a commit hash. func shortSHA(s string) string { return s[:min(len(s), 7)] } -// turnLabel returns the text about the last turn, for the header. As in -// herdr-reviewr, a saved turn from an earlier run has no label. With no -// turn, the Changes tab tells why, so the header shows nothing. +// turnLabel returns the header text about the last turn. It returns "" +// for a turn saved by an earlier run, and when there is no turn, because +// the Changes tab then explains why. func (m *model) turnLabel() string { if m.turn == nil || m.turn.at.IsZero() { return "" } ago := time.Since(m.turn.at).Round(time.Second) - app := termtext.Quote(m.turn.app) // the app name comes from the agent + app := termtext.Quote(m.turn.app) // the agent reports the app name, so quote it if m.turn.running { return noteStyle.Render("● ") + app + dimStyle.Render(" working · "+ago.String()) } return app + dimStyle.Render(" · "+ago.String()+" ago") } -// agentLabel returns the header text that tells how many agents can get -// the next send. +// agentLabel returns the header text saying how many agents the next +// review could be sent to. func (m *model) agentLabel() string { if m.rex == nil { return dimStyle.Render("not in Rex") @@ -196,13 +199,14 @@ func (m *model) agentLabel() string { return dimStyle.Render("send → ") + plural(n, "agent") } -// statusMark returns the mark of a changed file in the file list. +// statusMark returns the status letter shown for a changed file in the +// file list. func statusMark(s vcs.FileStatus) string { switch s { case vcs.StatusAdded: - return addStyle.Render("A") + return addedStyle.Render("A") case vcs.StatusDeleted: - return delStyle.Render("D") + return deletedStyle.Render("D") case vcs.StatusModified: return noteStyle.Render("M") case vcs.StatusRenamed: @@ -211,151 +215,162 @@ func statusMark(s vcs.FileStatus) string { return " " } -// listLines renders h lines of the file list. The lines are a title and the +// navigatorLines renders h lines of the file list: a title followed by the // rows of the tree. -func (m *model) listLines(h int) []string { - t := m.list() +func (m *model) navigatorLines(h int) []string { + t := m.curNavigator() if t == nil { return nil } - w := m.listWidth() - title := fmt.Sprintf("Changes (%d)", len(m.changeFiles)) - if m.tab == tabFiles { - n := 0 - for _, e := range m.allFiles { - if !strings.HasSuffix(e.Path, "/") { - n++ - } + w := m.navigatorWidth() + title := fmt.Sprintf("Changes (%d)", max(len(m.changes.files), m.changes.tooMany)) + if m.tab == tabAllFiles { + title = fmt.Sprintf("Files (%d)", m.allFiles.fileCount) + if n := m.allFiles.more; n > 0 { + title += fmt.Sprintf(" … %d more files not listed", n) } - title = fmt.Sprintf("Files (%d)", n) } - out := []string{headerStyle.Render(title)} - for i := m.listStart(h); i < len(t.rows) && len(out) < h; i++ { - r := m.treeRow(t.rows[i], w) + // The title lines up with the status letters of the rows. + out := []string{truncate(" "+headerStyle.Render(title), w)} + for i := m.navigatorStart(h); i < len(t.rows) && len(out) < h; i++ { + // A long name or count must not wrap the row onto the next line. + r := truncate(m.navigatorRow(t.rows[i], w), w) if i == t.cur { - r = m.selectedRow(r, w) + r = m.highlightRow(r, w) } out = append(out, r) } return out } -// treeRow renders row r of the file list, w columns wide. -func (m *model) treeRow(r tree.Row, w int) string { +// navigatorRow renders row r of the file list, w columns wide. +func (m *model) navigatorRow(r tree.Row, w int) string { indent := strings.Repeat(" ", r.Depth) switch { case r.File < 0: return m.dirRow(r, indent, w) case m.tab == tabChanges: - return m.changedRow(m.changeFiles[r.File], indent, r.Name, w) + return m.changesRow(m.changes.files[r.File], indent, r.Name, w) } - return m.entryRow(m.allFiles[r.File], indent, r.Name, w) + return m.allFilesRow(m.allFiles.entries[r.File], indent, r.Name, w) } -// dirRow renders directory r of the file list, w columns wide. As in -// herdr-reviewr, a collapsed directory of the Files tab has a dot if it -// holds a change. +// dirRow renders directory row r of the file list, w columns wide. In the +// Files tab, a collapsed directory that contains changes gets a dot. func (m *model) dirRow(r tree.Row, indent string, w int) string { arrow, mark := "▸", "" if r.Expanded { arrow = "▾" } - if m.tab == tabFiles && !r.Expanded && r.Changed { + if m.tab == tabAllFiles && !r.Expanded && r.Changed { mark = noteStyle.Render("•") } room := w - ansi.StringWidth(indent) - lipgloss.Width(mark) - 4 - name := boldStyle.Render(fitName(r.Name+"/", room)) + st := boldStyle if r.Ignored { - name = dimStyle.Render(fitName(r.Name+"/", room)) + st = dimStyle } + name := st.Render(fitName(r.Name+"/", room)) return " " + indent + dimStyle.Render(arrow) + " " + name + " " + mark } -// changedRow renders changed file f in the file list, w columns wide. The -// row shows the name in the tree, after indent. -func (m *model) changedRow(f changedFile, indent, name string, w int) string { - adds := addStyle.Render(fmt.Sprintf("+%d", f.adds)) - stat := adds + " " + delStyle.Render(fmt.Sprintf("-%d", f.dels)) +// changesRow renders changed file f in the file list, w columns wide. The +// row shows name, its name in the tree, after indent. +func (m *model) changesRow(f changedFile, indent, name string, w int) string { + stat := f.stat() if c := m.comments.Count(m.anchorOf(tabChanges), f.Path); c > 0 { - stat = cursorStyle.Render(fmt.Sprintf("●%d ", c)) + stat + stat = accentStyle.Render(fmt.Sprintf("●%d ", c)) + stat } - if f.From != "" { - name = f.From + " → " + name + if f.OldPath != "" { + name = f.OldPath + " → " + name } - return fileRow(indent, statusMark(f.Status), name, stat, w) + return navigatorRowText(indent, statusMark(f.Status), name, stat, w) } -// entryRow renders entry e of the Files tab in the file list, w columns -// wide. The row shows the name in the tree, after indent. -func (m *model) entryRow(e vcs.Entry, indent, name string, w int) string { +// allFilesRow renders entry e in the Files tab's file list, w columns +// wide. The row shows name, its name in the tree, after indent. +func (m *model) allFilesRow(e vcs.Entry, indent, name string, w int) string { if e.Ignored { - return dimStyle.Render(fileRow(indent, " ", name, "", w)) + return dimStyle.Render(navigatorRowText(indent, " ", name, "", w)) } stat := "" - if c := m.comments.Count(m.anchorOf(tabFiles), e.Path); c > 0 { - stat = cursorStyle.Render(fmt.Sprintf("●%d", c)) + if c := m.comments.Count(m.anchorOf(tabAllFiles), e.Path); c > 0 { + stat = accentStyle.Render(fmt.Sprintf("●%d", c)) } - return fileRow(indent, " ", name, stat, w) + return navigatorRowText(indent, " ", name, stat, w) } -// selectedRow highlights row r of the selected file, w columns wide. -// When the list has the focus, a mark takes the place of the first space. -func (m *model) selectedRow(r string, w int) string { - style := m.selStyle().Width(w) - if !m.focusList { +// highlightRow highlights r, the row of the current file, w columns wide. +// When the file list has focus, a bar replaces the first space. +func (m *model) highlightRow(r string, w int) string { + style := m.highlightStyle().Width(w) + if !m.navigatorFocused { return style.Render(r) } - return style.Render(cursorStyle.Render("▌") + ansi.TruncateLeft(r, 1, "")) + return style.Render(accentStyle.Render("▎") + ansi.TruncateLeft(r, 1, "")) } -// listStart returns the first row that the file list shows in h lines. -// The first line is the title. -func (m *model) listStart(h int) int { - t := m.list() - switch { - case t == nil: - return 0 - case t.free: - return min(max(t.top, 0), max(len(t.rows)-(h-1), 0)) +// navigatorStart returns the first row that the file list shows in h +// lines, the first of which is the title. +func (m *model) navigatorStart(h int) int { + if t := m.curNavigator(); t != nil { + return t.start(h - 1) } - return max(0, t.cur-(h-2)) + return 0 } -// fileRow returns " M name/of/file.go +3 -1", with indent before the -// mark. -func fileRow(indent, mark, p, stat string, w int) string { +// navigatorRowText returns a file list row such as +// " M name/of/file.go +3 -1", with indent before the status letter. +func navigatorRowText(indent, mark, p, stat string, w int) string { room := w - ansi.StringWidth(indent) - lipgloss.Width(stat) - 4 return " " + indent + mark + " " + fitName(p, room) + " " + stat } -// fitName returns p in room columns, with spaces after it. If p is too -// long, fitName removes the directory first, then the start of the name. +// fitName fits p into room columns, padding it with spaces. If p is too +// long, it drops the directory first, and then cuts the start of the name. func fitName(p string, room int) string { - p = termtext.Quote(p) - if n := ansi.StringWidth(p); n > room { - if base := path.Base(p); ansi.StringWidth(base)+2 <= room { - p = "…/" + base - } else { - p = "…" + ansi.TruncateLeft(p, n-room+1, "") - } - } - // Pad by width, not by runes. + if q := termtext.Quote(p); ansi.StringWidth(q) <= room { + p = q + } else if base := path.Base(q); ansi.StringWidth(base)+2 <= room { + p = "…/" + base + } else { + p = "…" + lastColumns(q, room-1) + } + // Pad by display width, not by rune count. A wide character cut in + // half leaves a column to pad. return p + strings.Repeat(" ", max(room-ansi.StringWidth(p), 0)) } +// lastColumns returns the end of s that fits in n columns. It drops a +// wide character that does not fit whole, so the result can be a column +// short of n. +func lastColumns(s string, n int) string { + n = max(n, 0) + w := ansi.StringWidth(s) + cut := max(w-n, 0) + end := ansi.TruncateLeft(s, cut, "") + // TruncateLeft keeps a wide character that the cut falls inside. + for ansi.StringWidth(end) > n { + cut++ + end = ansi.TruncateLeft(s, cut, "") + } + return end +} + // mainLines renders the visible part of the main pane. rowAt[y] is the row -// on screen line y, or -1 for a note, a comment, or the comment box. hits -// are the find hits that get a mark. The marks do not change rowAt. +// shown on screen line y, or -1 for a note, a comment or the comment box. +// hits are the find matches to mark. The marks do not affect rowAt. func (m *model) mainLines(h int, hits []findHit) (lines []string, rowAt []int) { - p := m.cur() + p := m.curPane() if note := m.emptyNote(); note != "" { - return []string{"", " " + dimStyle.Render(note)}, []int{-1, -1} + return m.noteLines(note, h) } - lo, hi := m.highlighted() + lo, hi := m.highlightRange() add := func(l string, row int) { lines, rowAt = append(lines, l), append(rowAt, row) } - // addBelow adds lines that show no source line, while there is room. + // addBelow adds lines that show no source line, as long as there is + // room. addBelow := func(ls []string) { for _, l := range ls { if len(lines) >= h { @@ -366,103 +381,136 @@ func (m *model) mainLines(h int, hits []findHit) (lines []string, rowAt []int) { } marked := map[int]bool{} for _, hit := range hits { - if hit.line == 0 { // a hit in a fold has no row of its own + if hit.line == 0 { // a match inside a fold has no row of its own marked[hit.row] = true } } - for i := p.top; i < len(p.lines) && len(lines) < h; i++ { - // A wrapped line shows its marks on its first row, and its + for i := p.top; i < p.count() && len(lines) < h; i++ { + // A wrapped line shows its markers on its first row and its // comments below its last row. - t := m.target(i, diff.SideNew) - add(m.rowMark(i, t, lo, hi, marked[i])+p.lines[i], i) - if t != nil && p.end(i) == i { - addBelow(m.commentsBelow(i, t)) + target := m.targetAt(i, diff.SideNew) + add(m.rowMark(i, target, lo, hi, marked[i])+p.line(i), i) + if target != nil && p.lineEnd(i) == i { + addBelow(m.commentsBelow(i, target)) } - if m.mode == modeComment && i == p.end(p.cur) { + if m.mode == modeComment && i == p.lineEnd(p.cur) { addBelow(m.commentBox()) } } return lines, rowAt } -// emptyNote returns the text that shows instead of an empty pane, or "". +// noteLines renders note, the text of an empty pane, for a pane h lines +// high. The note is indented and wrapped, with the indent on every line. +// A blank line comes first if the pane, and the screen below the header, +// have room for it. +func (m *model) noteLines(note string, h int) (lines []string, rowAt []int) { + wrapped := strings.Split(wrapWords(note, m.mainWidth()-2), "\n") + if min(h, m.h-1-m.footerHeight()) > len(wrapped) { + lines = append(lines, "") + } + for _, l := range wrapped { + lines = append(lines, " "+dimStyle.Render(l)) + } + rowAt = make([]int, len(lines)) + for i := range rowAt { + rowAt[i] = -1 + } + return lines, rowAt +} + +// emptyNote returns the text to show instead of an empty pane, or "" if +// the pane is not empty. func (m *model) emptyNote() string { - p := m.cur() + p := m.curPane() switch { - case len(p.lines) > 0: + case p.count() > 0: return "" case p.note != "": - return termtext.Quote(p.note) // the note can come from git or a forge - case m.tab == tabPR && m.prf.inFlight: + return termtext.Quote(p.note) // the note may come from git or a forge + case m.tab == tabPR && m.pr.fetch.inFlight: return "loading the pull request…" - case m.tab == tabFiles && len(m.allFiles) == 0: + case m.tab == tabAllFiles && m.allFiles.entries == nil && (m.allFiles.listing.running || m.allFiles.listing.want): + return "loading files…" + case m.tab == tabAllFiles && len(m.allFiles.entries) == 0: return "no files" - case m.tab == tabFiles && p.path != "": + case m.tab == tabAllFiles && p.path != "": return "empty file" } return "" } -// highlighted returns the first and the last row of the drag or the +// highlightRange returns the first and last rows of the drag or the // selection. With neither, it returns -1, -1. -func (m *model) highlighted() (lo, hi int) { - p := m.cur() +func (m *model) highlightRange() (lo, hi int) { + p := m.curPane() switch { case m.drag.active && m.drag.moved: - return p.span(m.drag.start, p.cur) - case p.anchor >= 0: - return p.selection() + return p.span(m.drag.startRow, p.cur) + case p.selStart >= 0: + return p.selRange() } return -1, -1 } -// rowMark returns the gutter mark of row i. t is the line that row i -// shows, or nil. hit is true for a find hit. The mark shows the cursor, -// the rows lo to hi, a find hit, or a comment. -func (m *model) rowMark(i int, t *targetLine, lo, hi int, hit bool) string { - p := m.cur() +// rowMark returns the gutter marker for row i. target is the line that +// row i shows, or nil, and hit reports whether the row is a find match. +// The marker shows, in order of priority, the cursor, the highlighted +// rows lo to hi, a find match, or a comment. +func (m *model) rowMark(i int, target *targetLine, lo, hi int, hit bool) string { + p := m.curPane() + // The first row of a line taller than the pane can scroll above the + // top, so the top row then shows the cursor while the line is in view. + cur := i == p.cur || i == p.top && p.cur < p.top && p.lineEnd(p.cur) >= p.top switch { - case i == p.cur && m.tab != tabPR: - return cursorStyle.Render("▶ ") + case cur && m.tab != tabPR: + return accentStyle.Render("▶") case lo >= 0 && i >= lo && i <= hi: - return cursorStyle.Render("┃ ") + return accentStyle.Render("┃") case hit: - return findStyle.Render("≈ ") - case t == nil || p.cont(i): + return findStyle.Render("≈") + case target == nil || p.cont(i): return commentMark(nil) } - return commentMark(m.commentOn(i, t)) + return commentMark(m.commentOn(i, target)) } -// commentOn returns a comment on row i, which shows line t, or nil. In -// rendered markdown, it is a comment on a line of the block of row i. -func (m *model) commentOn(i int, t *targetLine) *diff.Comment { - p := m.cur() - first, last := t.line, t.line - if p.src != nil { - first, last = p.rows[p.head(i)].New, p.rows[p.end(i)].New +// commentOn returns a comment on row i, which shows target, or nil. In +// rendered Markdown, it returns a comment on any line of the block that +// contains row i. +func (m *model) commentOn(i int, target *targetLine) *diff.Comment { + p := m.curPane() + if p.removedMarker(i) { + return nil + } + first, last := target.line, target.line + if p.srcRows != nil { + first, last = p.rows[p.lineStart(i)].New, p.rows[p.lineEnd(i)].New } for line := first; line <= last; line++ { - if c := m.comments.At(m.anchor(), p.path, t.side, line); c != nil { + if c := m.comments.At(m.curAnchor(), p.path, target.side, line); c != nil { return c } } return nil } -// commentsBelow returns the lines of the comments whose range ends on -// row i, which shows line t. In rendered markdown, they are the comments -// that end in the block of row i. The comment that the box edits does not -// show. -func (m *model) commentsBelow(i int, t *targetLine) []string { - p := m.cur() - first := t.line - if p.src != nil { - first = p.rows[p.head(i)].New +// commentsBelow returns the rendered lines of the comments whose range +// ends on row i, which shows target. In rendered Markdown, these are the +// comments that end anywhere in the block of row i. The comment being +// edited in the comment box is left out. +func (m *model) commentsBelow(i int, target *targetLine) []string { + p := m.curPane() + if p.removedMarker(i) { + return nil + } + first := target.line + if p.srcRows != nil { + first = p.rows[p.lineStart(i)].New } var out []string - for line := first; line <= t.line; line++ { - for _, c := range m.comments.EndingAt(m.anchor(), p.path, t.side, line) { + for line := first; line <= target.line; line++ { + for _, c := range m.comments.EndingAt(m.curAnchor(), p.path, target.side, line) { if m.mode == modeComment && c.ID == m.draft.editID { continue } @@ -472,82 +520,129 @@ func (m *model) commentsBelow(i int, t *targetLine) []string { return out } -// commentMark returns the gutter mark of a line with comment c, which can -// be nil. +// commentMark returns the gutter marker for a line with comment c, or a +// space if c is nil. func commentMark(c *diff.Comment) string { if c == nil { - return " " + return " " } - return cursorStyle.Render("● ") + return accentStyle.Render("●") } -// commentBox returns the lines of the comment box, indented. +// commentBox returns the lines of the comment box, indented to sit below +// the code. func (m *model) commentBox() []string { var box []string - for l := range strings.SplitSeq(m.editor.View(), "\n") { + for l := range strings.SplitSeq(m.commentInput.View(), "\n") { box = append(box, " "+l) } return box } -// commentLines returns the lines that show comment c below its range. +// commentLines returns the lines that display comment c below the lines +// it refers to. A stale comment starts with "stale". func (m *model) commentLines(c *diff.Comment) []string { var out []string + stale := m.commentStale(c) for i, l := range strings.Split(c.Text, "\n") { prefix := " 💬 " - if i > 0 { + switch { + case i > 0: prefix = " " + case stale: + prefix += accentStyle.Render("stale") + " " } out = append(out, truncate(prefix+noteStyle.Render(l), m.mainWidth())) } return out } -// ensureVisible scrolls the main pane so that the cursor and the comment -// box below it show. The box shows below the last row of its line. When -// the box is open, a line taller than the screen shows only its end. When -// the box is closed, the line shows only its first rows. -func (m *model) ensureVisible() { - p := m.cur() - h := m.mainHeight() - if p.free { - p.top = min(p.top, max(len(p.lines)-h, 0)) +// scrollToCursor scrolls the main pane so that the cursor, and the comment +// box below it, are visible. The box appears below the last row of the +// cursor line. If a line is taller than the screen, only its end is shown +// while the box is open, and only its first rows while the box is closed. +// It also scrolls the file list so that its cursor is visible. Each view +// moves as little as possible. +func (m *model) scrollToCursor() { + // A hidden file list keeps its top row for when it is shown again. + if t, h := m.curNavigator(), m.navigatorRows()-1; t != nil && m.navigatorWidth() > 0 && h > 0 { + t.scrollToCursor(h) // h excludes the title line + } + p := m.curPane() + h := m.shownMainHeight() + if !p.following { + p.top = min(p.top, max(p.count()-h, 0)) return } - p.cur = p.head(p.cur) // the cursor is on the first row of a wrapped line + p.cur = p.lineStart(p.cur) // keep the cursor on the first row of a wrapped line last, box := p.cur, 0 if m.mode == modeComment { - last, box = p.end(p.cur), lipgloss.Height(m.editor.View()) - } - // Each row takes one line or more. Thus the last row cannot show if it is - // h rows or more below the top. - p.top = max(p.top, last-h+1) - p.top = min(last, p.top, max(len(p.lines)-1, 0)) - for p.top < last { - _, rowAt := m.mainLines(h, nil) - idx := slices.Index(rowAt, p.end(p.cur)) - if idx >= 0 && idx+1+box <= h { - return + // The line's comments appear between the line and the box. + last = p.lineEnd(p.cur) + box = lipgloss.Height(m.commentInput.View()) + m.linesBelow(last) + } + // Each row takes at least one line, so row last cannot be visible if + // it is h or more rows below the top. + p.top = min(keepInView(p.top, last, h), p.last()) + if p.top >= last { + return + } + if m.emptyNote() != "" { // the note replaces all rows + p.top = last + return + } + // The end of the cursor line is visible if it lands on screen line 0 + // to h-1-box. Starting from that line, move the top up one row at a + // time for as long as the end stays in that range. Each row takes its + // own line plus the lines of the comments below it. The top never + // moves above its current value. + end := p.lineEnd(p.cur) + top, used := end, 0 + for top > p.top { + n := 1 + m.linesBelow(top-1) + if used+n+1+box > h { + break } - p.top++ + used += n + top-- + } + p.top = min(top, last) +} + +// linesBelow returns the number of comment lines shown below row i of the +// main pane, as mainLines adds them. +func (m *model) linesBelow(i int) int { + p := m.curPane() + target := m.targetAt(i, diff.SideNew) + if target == nil || p.lineEnd(i) != i { + return 0 } + return len(m.commentsBelow(i, target)) } -// footer renders the notice, the status, and the help of the keys. The -// notice wraps, so that the user can read all of it. hits are the hits of -// the find band. +// footer renders the notice, the status and the key help. The notice is +// wrapped so that the user can read all of it. hits are the matches shown +// in the find bar. func (m *model) footer(hits []findHit) string { + line := m.footerLine(hits) if m.notice != "" { - notice := termtext.Quote(m.notice) // the notice can have a path - return delStyle.Render(wrapWords(notice, m.w)) + "\n" + m.footerLine(hits) + notice := termtext.Quote(m.notice) // the notice may contain a path + line = deletedStyle.Render(wrapWords(notice, m.footerWidth())) + "\n" + line } - return m.footerLine(hits) + return inset(line) } -// wrapWords breaks s into lines of width w or less, at spaces only. -// ansi.Wrap and lipgloss let a line end past w at a hyphen. Then the edge -// of the screen cuts off a "--". wrapWords cuts a word wider than w. A -// character wider than w gets a line of its own. +// footerWidth returns the width of the footer, not counting the blank +// column at its left. The header has the same blank column. +func (m *model) footerWidth() int { return max(m.w-1, 1) } + +// inset adds a space at the start of each line of s. +func inset(s string) string { return " " + strings.ReplaceAll(s, "\n", "\n ") } + +// wrapWords breaks s into lines at most w columns wide, breaking only at +// spaces. A word wider than w is cut, and a character wider than w gets a +// line of its own. It does not use [ansi.Wrap], because that can let a +// line run past w at a hyphen. func wrapWords(s string, w int) string { w = max(w, 1) var lines []string @@ -575,32 +670,34 @@ func wrapWords(s string, w int) string { return strings.Join(append(lines, line), "\n") } -// footerLine renders the status and the help of the keys, or the find -// band. +// footerLine renders the status and the key help, or the find bar when it +// is open. func (m *model) footerLine(hits []findHit) string { if m.mode == modeFind { return m.findBand(hits) } - m.help.SetWidth(m.w) + w := m.footerWidth() + m.help.SetWidth(w) var badge string switch { - case m.status != "": - badge = cursorStyle.Render(m.status) + case m.flashText != "": + badge = accentStyle.Render(m.flashText) case m.release != nil: - badge = cursorStyle.Render(m.releaseBadge()) + badge = accentStyle.Render(m.releaseBadge()) case m.mode == modeComment && m.draft != nil: - badge = cursorStyle.Render("✎ " + m.draft.Location()) + badge = accentStyle.Render("✎ " + m.draft.Location()) case m.comments.Len() > 0: - badge = cursorStyle.Render("● " + plural(m.comments.Len(), "unsent comment")) + badge = accentStyle.Render("● " + plural(m.comments.Len(), "unsent comment")) } hk := helpKeys{ - k: m.keys, - mode: m.mode, - tab: m.tab, - split: m.splitShown(), - visual: m.cur().anchor >= 0, - atFold: m.atFold(), - armed: m.armed, + keys: m.keys, + mode: m.mode, + tab: m.tab, + split: m.splitShown(), + selecting: m.curPane().selStart >= 0, + cursorOnFold: m.cursorOnFold(), + cursorInExpandedFold: m.canCollapseFold(), + crossStep: m.crossStep, } if m.help.ShowAll { h := m.help.View(hk) @@ -610,24 +707,59 @@ func (m *model) footerLine(hits []findHit) string { return h } if badge != "" { - m.help.SetWidth(max(m.w-lipgloss.Width(badge)-4, 10)) - return truncate(badge+" "+m.help.View(hk), m.w) + m.help.SetWidth(max(w-lipgloss.Width(badge)-len(helpGap), 10)) + return truncate(badge+helpGap+m.help.View(hk), w) } return m.help.View(hk) } -// bodyHeight returns the number of lines between the header and the footer. -func (m *model) bodyHeight() int { return m.heightAbove(m.footer(m.shownHits())) } +// bodyHeight returns the number of lines between the header and the +// footer, and at least 3. +func (m *model) bodyHeight() int { return max(m.h-m.footerHeight()-1, 3) } + +// shownBodyHeight returns the number of body lines on screen. On a short +// terminal, View cuts the body to make room for the footer, so this can +// be less than bodyHeight. +func (m *model) shownBodyHeight() int { + return min(m.bodyHeight(), max(m.h-1-m.footerHeight(), 0)) +} + +// shownMainHeight returns the number of rows of the main pane on screen, +// and at least 1. On a short terminal, View cuts the body, so this can be +// less than mainHeight. +func (m *model) shownMainHeight() int { + room := m.shownBodyHeight() - m.titleHeight() + if m.navigatorPosition == positionTop { + room -= m.navigatorHeight(m.bodyHeight()) + } + return max(min(m.mainHeight(), room), 1) +} -// heightAbove returns the height of the body above footer. +// footerHeight returns the height of the footer, as heightAbove measures +// it, without rendering the footer. Only the full key list can take more +// than one line, so it is the only part rendered. +func (m *model) footerHeight() int { + n := 1 + if m.mode != modeFind && m.help.ShowAll { + n = lipgloss.Height(m.footerLine(nil)) + } + if m.notice != "" { + n += lipgloss.Height(wrapWords(termtext.Quote(m.notice), m.footerWidth())) + } + return n +} + +// heightAbove returns the height of the body above the rendered footer, +// and at least 3. func (m *model) heightAbove(footer string) int { - return max(m.h-lipgloss.Height(footer)-2, 3) + return max(m.h-lipgloss.Height(footer)-1, 3) } // View renders the screen. func (m *model) View() tea.View { if !m.sized { - // A frame of the guessed size shows wrong for a moment. + // Draw nothing until the terminal reports its size, because a + // frame drawn at the guessed size would flash up wrong. v := tea.NewView("") v.AltScreen = true return v @@ -637,31 +769,65 @@ func (m *model) View() tea.View { h := m.heightAbove(footer) hdr, _ := m.header() - mh := m.mainHeightOf(h) + mh, mw := m.mainHeightOf(h), m.mainWidth() + title := "" + if m.titleHeight() > 0 { + title = m.mainTitle() + } + // With no file to name, the note of an empty pane starts on the title + // line, so that a short terminal still shows it. + noteOnTitle := m.titleHeight() > 0 && title == "" && m.emptyNote() != "" + if noteOnTitle { + mh++ + } ml, _ := m.mainLines(mh, hits) - main := box(ml, m.mainWidth(), mh) + main, mainExact := box(ml, mw, mh) + if m.titleHeight() > 0 && !noteOnTitle { + // Pad a title that fits to the full width of the pane. + if tw := ansi.StringWidth(title); tw <= mw { + title += strings.Repeat(" ", mw-tw) + } else { + mainExact = false + } + main = title + "\n" + main + } body := main rule := dimStyle.Render(strings.Repeat("─", m.w)) - switch lw, lh := m.listWidth(), m.listHeight(h); { + switch lw, lh := m.navigatorWidth(), m.navigatorHeight(h); { case lh > 0: - list := box(m.listLines(lh-1), lw, lh-1) - if m.listPos == positionTop { - body = lipgloss.JoinVertical(lipgloss.Left, list, rule, main) + navigator, _ := box(m.navigatorLines(lh-1), lw, lh-1) + if m.navigatorPosition == positionTop { + body = lipgloss.JoinVertical(lipgloss.Left, navigator, rule, main) } else { - body = lipgloss.JoinVertical(lipgloss.Left, main, rule, list) + body = lipgloss.JoinVertical(lipgloss.Left, main, rule, navigator) } - case lw > 0: - list := box(m.listLines(h), lw, h) - sep := dimStyle.Render(strings.TrimSuffix(strings.Repeat(" │ \n", h), "\n")) - if m.listPos == positionLeft { - body = lipgloss.JoinHorizontal(lipgloss.Top, list, sep, main) + case lw > 0 && !m.navigatorPosition.stacked(): // a file list above or below with no rows is not shown + navigator, navigatorExact := box(m.navigatorLines(h), lw, h) + sep := dimStyle.Render(strings.TrimSuffix(strings.Repeat(" │\n", h), "\n")) + parts := []string{main, sep, navigator} + if m.navigatorPosition == positionLeft { + parts = []string{navigator, sep, main} + } + if mainExact && navigatorExact { + // Every line of each part is exactly as wide as the part, so + // the lines can be joined without measuring. + body = joinColumns(parts...) } else { - body = lipgloss.JoinHorizontal(lipgloss.Top, main, sep, list) + body = lipgloss.JoinHorizontal(lipgloss.Top, parts...) } } - screen := hdr + "\n" + body + "\n" + rule + "\n" + footer + // On a short terminal, the body keeps its minimum height and can push + // the footer off the screen. Cut the body instead, so that the find + // bar and the key help stay visible. + if room := max(m.h-1-lipgloss.Height(footer), 0); lipgloss.Height(body) > room { + body = strings.Join(strings.Split(body, "\n")[:room], "\n") + } + screen := hdr + "\n" + footer + if body != "" { + screen = hdr + "\n" + body + "\n" + footer + } - if m.mode == modeOverlay && m.overlay != nil { + if m.overlay != nil { ov, x, y, _ := m.overlayBox() c := lipgloss.NewCanvas(m.w, m.h) c.Compose(lipgloss.NewLayer(screen)) @@ -669,9 +835,9 @@ func (m *model) View() tea.View { screen = c.Render() } - // On a small screen, the parts have their minimum sizes. Cut the - // screen to the size of the terminal. - screen = lipgloss.NewStyle().MaxWidth(max(m.w, 1)).MaxHeight(max(m.h, 1)).Render(screen) + // On a small terminal, the parts keep their minimum sizes and can + // overflow, so cut the screen to the terminal size. + screen = fitScreen(screen, max(m.w, 1), max(m.h, 1)) v := tea.NewView(screen) v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion @@ -680,16 +846,108 @@ func (m *model) View() tea.View { return v } -// box joins lines into a block of exactly w columns and h lines. -func box(lines []string, w, h int) string { +// box joins lines into a block of w columns and h lines. exact reports +// whether every line is exactly w columns wide; if not, a line with a wide +// character at the edge may be one column short. +func box(lines []string, w, h int) (block string, exact bool) { + if out, ok := paddedBox(lines, w, h); ok { + return out, true + } + return styledBox(lines, w, h), false +} + +// joinColumns joins the blocks side by side, like +// [lipgloss.JoinHorizontal] but without measuring widths. Each block must +// have the same number of lines, all as wide as the block. +func joinColumns(blocks ...string) string { + split := make([][]string, len(blocks)) + for i, b := range blocks { + split[i] = strings.Split(b, "\n") + } + var b strings.Builder + for i := range split[0] { + if i > 0 { + b.WriteByte('\n') + } + for _, lines := range split { + if i < len(lines) { + b.WriteString(lines[i]) + } + } + } + return b.String() +} + +// styledBox implements box with a lipgloss style. It handles any input. +func styledBox(lines []string, w, h int) string { st := lipgloss.NewStyle().Width(w).MaxWidth(w).Height(h).MaxHeight(h) return st.Render(strings.Join(lines, "\n")) } -// programStatus returns the status that rexdiff reports to Rex with OSC 7501. -// Rex shows it in its tabs and its sidebar. +// paddedBox implements box for lines that already fit, by padding them as +// the style in styledBox would. It measures each line only once. ok is +// false if a line is too wide or contains a tab or carriage return, which +// the style would change. +func paddedBox(lines []string, w, h int) (string, bool) { + if w <= 0 || h <= 0 { + return "", false + } + var b strings.Builder + b.Grow(h * (w + 1)) + n := 0 + for _, l := range lines { + if n == h { + break + } + lw := ansi.StringWidth(l) + if lw > w || strings.ContainsAny(l, "\t\r\n") { + return "", false + } + if n > 0 { + b.WriteByte('\n') + } + b.WriteString(l) + b.WriteString(strings.Repeat(" ", w-lw)) + n++ + } + for ; n < h; n++ { + if n > 0 { + b.WriteByte('\n') + } + b.WriteString(strings.Repeat(" ", w)) + } + return b.String(), true +} + +// fitScreen cuts the screen to w columns and h lines, as a style with +// MaxWidth and MaxHeight would. Like that style, it pads each line to the +// width of the widest line. +func fitScreen(screen string, w, h int) string { + slow := func() string { return lipgloss.NewStyle().MaxWidth(w).MaxHeight(h).Render(screen) } + if strings.ContainsAny(screen, "\t\r") { + return slow() + } + lines := strings.Split(screen, "\n") + widths := make([]int, len(lines)) + widest := 0 + for i, l := range lines { + widths[i] = ansi.StringWidth(l) + widest = max(widest, widths[i]) + } + if widest > w { + return slow() + } + lines = lines[:min(len(lines), h)] + for i, l := range lines { + lines[i] = l + strings.Repeat(" ", widest-widths[i]) + } + return strings.Join(lines, "\n") +} + +// programStatus returns the status that rexdiff reports to Rex with OSC +// 7501. Rex shows it in its tabs and sidebar. func (m *model) programStatus() *tea.ProgramStatus { - msg := "reviewing " + plural(len(m.changeFiles), "file") + msg := "reviewing " + plural(max(len(m.changes.files), m.changes.tooMany), "file") if n := m.comments.Len(); n > 0 { msg = plural(n, "unsent comment") } @@ -703,48 +961,50 @@ func (m *model) programStatus() *tea.ProgramStatus { // mainX returns the screen column where the main pane starts. func (m *model) mainX() int { - if m.listPos == positionLeft && m.listWidth() > 0 { - return m.listWidth() + sepWidth + if m.navigatorPosition == positionLeft && m.navigatorWidth() > 0 { + return m.navigatorWidth() + sepWidth } return 0 } // mainY returns the screen line where the main pane starts. func (m *model) mainY() int { - if m.listPos == positionTop { - return bodyY + m.listHeight(m.bodyHeight()) + if m.navigatorPosition == positionTop { + return bodyY + m.navigatorHeight(m.bodyHeight()) + m.titleHeight() } - return bodyY + return bodyY + m.titleHeight() } -// listX returns the screen column where the file list starts. -func (m *model) listX() int { - if m.listPos == positionRight { +// navigatorX returns the screen column where the file list starts. +func (m *model) navigatorX() int { + if m.navigatorPosition == positionRight { return m.mainWidth() + sepWidth } return 0 } -// listY returns the screen line where the file list starts. Below the -// main pane, the list starts under the rule. -func (m *model) listY() int { - if m.listPos == positionBottom { - return bodyY + m.mainHeight() + 1 +// navigatorY returns the screen line where the file list starts. Below +// the main pane, it starts under the rule. +func (m *model) navigatorY() int { + if m.navigatorPosition == positionBottom { + return bodyY + m.titleHeight() + m.mainHeight() + 1 } return bodyY } -// listRows returns the number of lines of the file list, with its title. -func (m *model) listRows() int { +// navigatorRows returns the number of lines in the file list, including +// its title. +func (m *model) navigatorRows() int { h := m.bodyHeight() - if m.listPos.stacked() { - return max(m.listHeight(h)-1, 0) + if m.navigatorPosition.stacked() { + return max(m.navigatorHeight(h)-1, 0) } return h } -// sepWidth is the width of the line " │ " between the panes. -const sepWidth = 3 +// sepWidth is the width of the line " │" between the panes. The file +// list has its own blank column at its left. +const sepWidth = 2 -// bodyY is the screen line where the body starts, below the header. +// bodyY is the screen line where the body starts, just below the header. const bodyY = 1 diff --git a/update/update.go b/update/update.go index 787e500..0ac8731 100644 --- a/update/update.go +++ b/update/update.go @@ -1,15 +1,11 @@ // Package update finds a newer release of rexdiff, installs it, and starts // it in place of the running program. // -// [Detect] finds how the user installed rexdiff. Its [Updater] finds the -// latest [Release] and installs it the same way. It uses Homebrew or go -// install. Else it downloads the archive of the release from GitHub and -// checks it with the checksums of the release. [Restart] runs the new binary -// in the same process, so the terminal and the process ID stay. -// -// The caller gives the environment as a value, as in [exec.Cmd.Env]. The -// package does not read the environment of the process. But net/http reads -// the proxy variables of the process, for example HTTPS_PROXY. +// An [Installer] installs the latest [Release] the way rexdiff was +// installed: with Homebrew, go install, or a checked download from GitHub. +// [Restart] runs the new binary in the current process. The environment is +// passed as a value, but net/http still reads proxy variables from the +// process environment. package update import ( @@ -37,61 +33,63 @@ import ( "time" "github.com/rednafi/rexdiff/internal/proc" + "github.com/rednafi/rexdiff/internal/xctx" ) -// latestURL is the GitHub API address of the latest release of rexdiff. -// REXDIFF_LATEST_URL replaces it (see [Detect]). +// latestURL is the GitHub API URL of the latest release. +// REXDIFF_LATEST_URL overrides it; see [NewInstaller]. const latestURL = "https://api.github.com/repos/rednafi/rexdiff/releases/latest" -// modulePath is the path of the main package, for go install. +// modulePath is the import path of the main package, for go install. const modulePath = "github.com/rednafi/rexdiff/cmd/rexdiff" -// Limits of the network and of the commands. The sizes are in bytes. +// Timeouts and size limits for downloads and commands. Sizes are in bytes. const ( - checkTimeout = 5 * time.Second + fetchTimeout = 5 * time.Second installTimeout = 5 * time.Minute maxArchive = 64 << 20 // the largest archive that download accepts - maxBinary = 128 << 20 // the largest binary in an archive + maxBinary = 128 << 20 // the largest binary that an archive can contain + maxMetadata = 1 << 20 // the largest release JSON or checksum file ) -// method is how the user installed rexdiff. An update installs it the same -// way. +// method is how the user installed rexdiff, and so how an update installs +// it. type method string const ( methodBrew method = "brew" // a Homebrew cask methodGo method = "go" // go install - methodDownload method = "download" // an archive of a release + methodDownload method = "download" // a release archive from GitHub ) -// Release is a release of rexdiff on GitHub. Get one from [Updater.Latest]. -// A Release that you make yourself has no files. Install can install such a -// Release with Homebrew or go install. Install cannot download it. +// Release is a release of rexdiff on GitHub, as returned by +// [Installer.Latest]. A Release created any other way has no download +// files, so Install can install it only with Homebrew or go install. type Release struct { Tag string // for example "v0.2.0" assets []asset } -// asset is a file that is attached to a GitHub release. +// asset is a file attached to a GitHub release. type asset struct { Name string `json:"name"` URL string `json:"browser_download_url"` } -// Updater installs releases of rexdiff. Use [Detect] to make one. -type Updater struct { - bin string // the path of rexdiff as the user runs it, maybe a symlink +// Installer installs releases of rexdiff. Use [NewInstaller] to create one. +type Installer struct { + bin string // the path that the user runs rexdiff from, possibly a symlink version string // the version of the running rexdiff, for example "v0.1.0" method method - brew string // the brew of the Caskroom of bin, for methodBrew - env []string // the environment of brew, go, and rexdiff + brew string // the brew that owns the cask of bin, for methodBrew + env []string // the environment for brew, go and rexdiff latestURL string } // Version returns the version of rexdiff, for example "v0.2.0". stamped is -// the version that the release build sets, for example "0.2.0", or "". If +// the version that a release build embeds, for example "0.2.0", or "". If // stamped is "", Version returns the module version that go install -// records. For a build for development, Version returns "". +// records. For a development build, Version returns "". func Version(stamped string) string { if stamped != "" { return "v" + strings.TrimPrefix(stamped, "v") @@ -104,28 +102,17 @@ func Version(stamped string) string { return "" } -// Detect returns the Updater of the rexdiff at bin. stamped is as for -// [Version]. Detect returns nil if rexdiff cannot update itself, for -// example a build for development. -// -// The Updater installs a release the same way as the user installed -// rexdiff. If bin links into a Caskroom, it uses Homebrew. If stamped is "" -// and the version comes from go install, it uses go install. Else it -// downloads the release. The Updater runs brew, go, and rexdiff with the -// environment env, and finds them in the PATH of env. If env sets -// REXDIFF_LATEST_URL, the Updater gets the latest release from that -// address. Set it to test a release before it is public. -func Detect(env []string, bin, stamped string) *Updater { - return detect(bin, Version(stamped), stamped == "", env) -} - -// detect is [Detect] with the version already found. goInstall is true if -// the version comes from go install. -func detect(bin, version string, goInstall bool, env []string) *Updater { +// NewInstaller returns an Installer for the rexdiff binary at bin. If +// rexdiff cannot update itself, as in a development build, it returns nil +// and false. stamped is as for [Version]. The Installer runs brew, go and +// rexdiff with env, and fetches the latest release from +// REXDIFF_LATEST_URL if env sets it. +func NewInstaller(env []string, bin, stamped string) (*Installer, bool) { + version := Version(stamped) if _, valid := numbers(version); bin == "" || !valid { - return nil + return nil, false } - u := &Updater{ + u := &Installer{ bin: bin, version: version, method: methodDownload, @@ -137,25 +124,22 @@ func detect(bin, version string, goInstall bool, env []string) *Updater { switch { case err == nil && strings.Contains(target, caskroom): u.method = methodBrew - // The brew that owns the cask is in the bin directory beside the + // The brew that owns the cask is in the bin directory next to the // Caskroom, for example /opt/homebrew/bin/brew. prefix, _, _ := strings.Cut(target, caskroom) u.brew = tool("brew", filepath.Join(prefix, "bin")) - case goInstall: + case stamped == "": // the version comes from go install u.method = methodGo } - return u + return u, true } -// Latest returns the latest release, and whether it is newer than the -// running rexdiff. Latest stops and returns an error after five seconds. -func (u *Updater) Latest(ctx context.Context) (Release, bool, error) { - ctx, cancel := context.WithCancelCause(ctx) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, checkTimeout, - fmt.Errorf("release check: %v timeout: %w", checkTimeout, context.DeadlineExceeded)) - defer cancelTimeout() - defer cancel(fmt.Errorf("release check: done: %w", context.Canceled)) - body, err := get(ctx, u.latestURL, 1<<20) +// Latest returns the latest release and reports whether it is newer than +// the running rexdiff. Latest times out after 5 seconds. +func (in *Installer) Latest(ctx context.Context) (r Release, newer bool, err error) { + ctx, cancel := xctx.WithTimeout(ctx, fetchTimeout, "release check") + defer cancel() + body, err := get(ctx, in.latestURL, maxMetadata) if err != nil { return Release{}, false, err } @@ -166,59 +150,53 @@ func (u *Updater) Latest(ctx context.Context) (Release, bool, error) { if err := json.Unmarshal(body, &wire); err != nil { return Release{}, false, fmt.Errorf("read the latest release: %w", err) } - r := Release{Tag: wire.Tag, assets: wire.Assets} - return r, newer(r.Tag, u.version), nil + r = Release{Tag: wire.Tag, assets: wire.Assets} + return r, isNewer(r.Tag, in.version), nil } -// Install installs the release r. Then it checks that the binary of rexdiff -// has the version of r. Install does not stop the running rexdiff. Install -// uses the values of ctx, but ignores its cancel. An install that stops -// early can leave a broken cask or binary. Install stops after five -// minutes. -func (u *Updater) Install(ctx context.Context, r Release) error { - ctx, cancel := context.WithCancelCause(context.WithoutCancel(ctx)) - ctx, cancelTimeout := context.WithTimeoutCause(ctx, installTimeout, - fmt.Errorf("install: %v timeout: %w", installTimeout, context.DeadlineExceeded)) - defer cancelTimeout() - defer cancel(fmt.Errorf("install: done: %w", context.Canceled)) +// Install installs the release r and checks that the new binary reports +// the version of r. It does not stop the running rexdiff. Install ignores +// cancellation of ctx, because an install that stops halfway can leave a +// broken cask or binary. Install times out after 5 minutes. +func (in *Installer) Install(ctx context.Context, r Release) error { + ctx, cancel := xctx.WithTimeout(context.WithoutCancel(ctx), installTimeout, "install") + defer cancel() var err error - switch u.method { + switch in.method { case methodBrew: - // Turn off the hints and the prompts of brew, because no user sees - // the output. + // Turn off brew's hints and prompts, because no one sees the output. env := []string{"HOMEBREW_NO_ENV_HINTS=1", "NONINTERACTIVE=1"} - brew := cmp.Or(u.brew, "brew") - // brew updates its taps at most once a day. Thus brew upgrade can - // miss a new release. Update the taps first. Then - // HOMEBREW_NO_AUTO_UPDATE stops a second update. - if _, err = u.runProgram(ctx, env, brew, "update", "--quiet"); err == nil { + brew := cmp.Or(in.brew, "brew") + // brew updates its taps at most once a day, so brew upgrade can + // miss a new release. Update the taps first, then set + // HOMEBREW_NO_AUTO_UPDATE so that upgrade does not update them again. + if _, err = in.run(ctx, env, brew, "update", "--quiet"); err == nil { env = append(env, "HOMEBREW_NO_AUTO_UPDATE=1") - _, err = u.runProgram(ctx, env, brew, "upgrade", "--cask", "rexdiff") + _, err = in.run(ctx, env, brew, "upgrade", "--cask", "rexdiff") } case methodGo: - // GOBIN puts the new binary where the old one is. - env := []string{"GOBIN=" + filepath.Dir(u.bin)} + // GOBIN makes go install write the new binary next to the old one. + env := []string{"GOBIN=" + filepath.Dir(in.bin)} goTool := "go" - // Rex starts panes with a PATH without Go. - if _, lerr := proc.LookPath(u.env, goTool); lerr != nil { + // Rex starts panes with a PATH that does not include Go. + if _, lerr := proc.LookPath(in.env, goTool); lerr != nil { goTool = tool("go", "/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin") } - _, err = u.runProgram(ctx, env, goTool, "install", modulePath+"@"+r.Tag) + _, err = in.run(ctx, env, goTool, "install", modulePath+"@"+r.Tag) default: - return u.download(ctx, r) // it checks the version before the replace + return in.download(ctx, r) // download checks the version before it replaces the binary } if err != nil { return err } - return u.checkVersion(ctx, u.bin, r.Tag) + return in.verifyVersion(ctx, in.bin, r.Tag) } -// checkVersion checks that the binary at bin is the version tag, so that a -// restart does not run a broken or an old binary. -func (u *Updater) checkVersion(ctx context.Context, bin, tag string) error { - out, err := u.runProgram(ctx, nil, bin, "-version") +// verifyVersion checks that the binary at bin reports the version tag. +func (in *Installer) verifyVersion(ctx context.Context, bin, tag string) error { + out, err := in.run(ctx, nil, bin, "-version") if err != nil { - // The error of runProgram names rexdiff. + // The error from run already names rexdiff. return fmt.Errorf("check the new version: %w", err) } if got := strings.TrimSpace(string(out)); got != tag { @@ -227,11 +205,11 @@ func (u *Updater) checkVersion(ctx context.Context, bin, tag string) error { return nil } -// runProgram runs name with args, with env added to the environment of u. -// Its error has the end of the output, which tells what went wrong. -func (u *Updater) runProgram(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { - // A copy keeps u.env as it is. - cmd := proc.Command(ctx, slices.Concat(u.env, env), name, args...) +// run runs the program name with args, adding env to the Installer's +// environment, and returns its combined output. The error includes the +// last line of the output. +func (in *Installer) run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { + cmd := proc.Command(ctx, slices.Concat(in.env, env), name, args...) out, err := cmd.CombinedOutput() if err == nil { return out, nil @@ -248,10 +226,9 @@ func (u *Updater) runProgram(ctx context.Context, env []string, name string, arg return out, fmt.Errorf("%s: %w", filepath.Base(name), err) } -// tool returns the path of the program name in dirs. If dirs do not have -// the program, tool returns name, and the Updater looks for name in the -// PATH of its environment. tool looks in dirs first, because Rex starts -// panes with a PATH without Homebrew or Go. +// tool returns the path of the program name in one of dirs, or name itself +// if no dir has it. It exists because Rex starts panes with a PATH that +// does not include Homebrew or Go. func tool(name string, dirs ...string) string { env := []string{"PATH=" + strings.Join(dirs, string(os.PathListSeparator))} if path, err := proc.LookPath(env, name); err == nil { @@ -260,16 +237,16 @@ func tool(name string, dirs ...string) string { return name } -// download downloads the archive of r for this system and checks it with -// the checksums of r. Then it puts its binary in place of the file that -// u.bin links to. -func (u *Updater) download(ctx context.Context, r Release) error { +// download downloads the archive of r for this operating system and +// architecture, verifies it against the checksums of r, and replaces the +// file that in.bin links to with the binary from the archive. +func (in *Installer) download(ctx context.Context, r Release) error { name := fmt.Sprintf("rexdiff-%s-%s-%s.tar.gz", r.Tag, runtime.GOOS, runtime.GOARCH) archiveURL, sumsURL := assetURL(r, name), assetURL(r, "sha256sums.txt") if archiveURL == "" || sumsURL == "" { return fmt.Errorf("release %s has no %s or sha256sums.txt", r.Tag, name) } - sums, err := get(ctx, sumsURL, 1<<20) + sums, err := get(ctx, sumsURL, maxMetadata) if err != nil { return err } @@ -288,15 +265,15 @@ func (u *Updater) download(ctx context.Context, r Release) error { if err != nil { return fmt.Errorf("%s: %w", name, err) } - target, err := filepath.EvalSymlinks(u.bin) + target, err := filepath.EvalSymlinks(in.bin) if err != nil { return err } - check := func(tmp string) error { return u.checkVersion(ctx, tmp, r.Tag) } + check := func(tmp string) error { return in.verifyVersion(ctx, tmp, r.Tag) } return replaceFile(target, bin, check) } -// assetURL returns the address of the asset of r with the name, or "". +// assetURL returns the download URL of the asset of r called name, or "". func assetURL(r Release, name string) string { if i := slices.IndexFunc(r.assets, func(a asset) bool { return a.Name == name }); i >= 0 { return r.assets[i].URL @@ -304,7 +281,8 @@ func assetURL(r Release, name string) string { return "" } -// checksum returns the hex SHA-256 of the file name in a sha256sums.txt. +// checksum returns the hex SHA-256 sum that sums, the content of a +// sha256sums.txt file, lists for the file name. func checksum(sums []byte, name string) (string, bool) { for line := range strings.SplitSeq(string(sums), "\n") { sum, file, ok := strings.Cut(strings.TrimSpace(line), " ") @@ -315,7 +293,8 @@ func checksum(sums []byte, name string) (string, bool) { return "", false } -// binaryOf returns the content of the file rexdiff in a .tar.gz archive. +// binaryOf returns the content of the regular file named rexdiff in a +// .tar.gz archive. func binaryOf(archive []byte) ([]byte, error) { gz, err := gzip.NewReader(bytes.NewReader(archive)) if err != nil { @@ -343,15 +322,15 @@ func binaryOf(archive []byte) ([]byte, error) { } } -// replaceFile puts content in place of the file at path, in one rename, so -// that path is never a half-written file. check runs on the new file first. -// If check fails, path does not change. +// replaceFile replaces the file at path with content using a single +// rename, so path is never half written. It first calls check on the new +// file. If check fails, path does not change. func replaceFile(path string, content []byte, check func(tmp string) error) error { tmp, err := os.CreateTemp(filepath.Dir(path), ".rexdiff-update-*") if err != nil { return err } - defer func() { _ = os.Remove(tmp.Name()) }() // fails after the rename + defer func() { _ = os.Remove(tmp.Name()) }() // a no-op after the rename _, err = tmp.Write(content) if err == nil { err = tmp.Chmod(0o755) @@ -368,7 +347,8 @@ func replaceFile(path string, content []byte, check func(tmp string) error) erro return os.Rename(tmp.Name(), path) } -// get returns the body at url, of at most limit bytes. +// get fetches url and returns the response body. It returns an error if +// the body is larger than limit bytes. func get(ctx context.Context, url string, limit int64) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -393,25 +373,23 @@ func get(ctx context.Context, url string, limit int64) ([]byte, error) { return body, nil } -// Restart runs the rexdiff at bin with args and the environment env in -// place of this process. The process ID and the terminal stay, so Rex sees -// the same program. Restart returns only if it fails. Its parameters have -// the order of [syscall.Exec], so env comes last. -func Restart(bin string, args, env []string) error { - return syscall.Exec(bin, append([]string{bin}, args...), env) +// Restart replaces the current process with the rexdiff at bin, run with +// env and args. The process keeps its ID and terminal. Restart returns +// only if it fails. +func Restart(env []string, bin string, args []string) error { + return syscall.Exec(bin, slices.Concat([]string{bin}, args), env) } -// newer reports whether the version tag is newer than the version have. -// Both are like "v1.2.3". A version that is not "v" and three numbers is -// never newer. Thus a build for development does not ask to update. Also, -// rexdiff does not offer a tag that names no archive of GoReleaser. -func newer(tag, have string) bool { +// isNewer reports whether the version tag is newer than the version have. +// Both have the form "v1.2.3". If either does not, isNewer reports false. +func isNewer(tag, have string) bool { t, ok1 := numbers(tag) h, ok2 := numbers(have) return ok1 && ok2 && slices.Compare(t[:], h[:]) > 0 } -// numbers returns the three numbers of a version like "v1.2.3". +// numbers returns the three numbers in a version such as "v1.2.3", and +// false if v does not have that form. func numbers(v string) ([3]int, bool) { var n [3]int v, ok := strings.CutPrefix(v, "v") @@ -423,14 +401,13 @@ func numbers(v string) ([3]int, bool) { return n, false } for i, p := range parts { - if p == "" || strings.Trim(p, "0123456789") != "" { - return n, false // Atoi accepts a sign, but a version has none - } - x, err := strconv.Atoi(p) + // ParseUint, unlike Atoi, rejects a sign. The bit size keeps x + // within the range of an int. + x, err := strconv.ParseUint(p, 10, strconv.IntSize-1) if err != nil { return n, false } - n[i] = x + n[i] = int(x) } return n, true } diff --git a/update/update_test.go b/update/update_test.go index b46679c..db2e43e 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strconv" "strings" "testing" @@ -23,7 +24,8 @@ import ( "github.com/rednafi/rexdiff/update" ) -// mkdir makes the directory dir and its parents, and returns dir. +// mkdir creates the directory dir and any missing parents, and returns +// dir. func mkdir(t *testing.T, dir string) string { t.Helper() if err := os.MkdirAll(dir, 0o755); err != nil { @@ -32,21 +34,22 @@ func mkdir(t *testing.T, dir string) string { return dir } -// detect returns the Updater of the rexdiff at bin, with the latest -// release at latest. Its environment has REXDIFF_LATEST_URL and the PATH -// /usr/bin:/bin, then the variables of env. A later variable wins. -func detect(t *testing.T, latest, bin, stamped string, env ...string) *update.Updater { +// newInstaller returns an Installer for the rexdiff at bin that fetches +// the latest release from the URL latest. Its environment sets +// REXDIFF_LATEST_URL and PATH=/usr/bin:/bin, followed by env, so env can +// override them. +func newInstaller(t *testing.T, latest, bin, stamped string, env ...string) *update.Installer { t.Helper() - env = append([]string{"REXDIFF_LATEST_URL=" + latest, "PATH=/usr/bin:/bin"}, env...) - u := update.Detect(env, bin, stamped) - if u == nil { - t.Fatalf("Detect(%q, %s, %s) = nil, want an Updater", env, bin, stamped) + env = slices.Concat([]string{"REXDIFF_LATEST_URL=" + latest, "PATH=/usr/bin:/bin"}, env) + u, ok := update.NewInstaller(env, bin, stamped) + if !ok { + t.Fatalf("NewInstaller(%q, %s, %s) = nil, false, want an Installer", env, bin, stamped) } return u } -// serve starts a server with the handler h, and returns its address. The -// test stops the server at the end. +// serve starts a test HTTP server with the handler h and returns its URL. +// The server stops when the test ends. func serve(t *testing.T, h http.Handler) string { t.Helper() srv := httptest.NewServer(h) @@ -54,9 +57,9 @@ func serve(t *testing.T, h http.Handler) string { return srv.URL } -// TestVersion checks the version from the release build, and the builds -// that cannot update. A test binary has no version, like a build for -// development. +// TestVersion checks that Version adds a leading v to a stamped version, +// and that NewInstaller returns nil for a build with no valid version, +// such as a test binary, or for an empty path. func TestVersion(t *testing.T) { t.Parallel() for stamped, want := range map[string]string{"0.2.0": "v0.2.0", "v0.2.0": "v0.2.0", "": ""} { @@ -71,40 +74,42 @@ func TestVersion(t *testing.T) { {"", "0.1.0"}, } for _, tt := range tests { - if u := update.Detect(nil, tt.bin, tt.stamped); u != nil { - t.Errorf("Detect(nil, %q, %q) = %+v, want nil", tt.bin, tt.stamped, u) + if u, ok := update.NewInstaller(nil, tt.bin, tt.stamped); u != nil || ok { + t.Errorf("NewInstaller(nil, %q, %q) = %+v, %t, want nil, false", tt.bin, tt.stamped, u, ok) } } } -// TestLatest checks Latest against a fake GitHub API, and which tags are -// newer than the running version. +// TestLatest checks that Latest reads the release tag from a fake GitHub +// API and reports which tags are newer than the running version. It also +// checks the errors for a missing release and for bad JSON. func TestLatest(t *testing.T) { t.Parallel() release := func(tag string) string { return `{"tag_name": "` + tag + `", "assets": [], "other": 1}` } tests := []struct { - name, have, body string // a body of "" is a 404 - tag string // the tag of the release, or "" + name, have, body string // an empty body makes the server return 404 + tag string // the release tag, or "" newer bool - errText string // a part of the error, or "" + errText string // text that the error must contain, or "" }{ - {"minor", "0.1.0", release("v0.2.0"), "v0.2.0", true, ""}, - {"patch", "v0.1.0", release("v0.1.1"), "v0.1.1", true, ""}, - {"major", "0.9.9", release("v1.0.0"), "v1.0.0", true, ""}, - {"two digits", "0.9.0", release("v0.10.0"), "v0.10.0", true, ""}, - // GoReleaser names archives with the v. - {"no v", "0.1.0", release("0.2.0"), "0.2.0", false, ""}, - {"same", "0.1.0", release("v0.1.0"), "v0.1.0", false, ""}, - {"older", "0.2.0", release("v0.1.0"), "v0.1.0", false, ""}, - {"release candidate", "0.1.0", release("v0.2.0-rc1"), "v0.2.0-rc1", false, ""}, - {"no tag", "0.1.0", release(""), "", false, ""}, - // Atoi accepts a sign, but a version has none. - {"sign", "0.9.0", release("v+1.0.0"), "v+1.0.0", false, ""}, - {"minus", "0.9.0", release("v1.-0.0"), "v1.-0.0", false, ""}, - {"status", "0.1.0", "", "", false, "404"}, - {"bad JSON", "0.1.0", "not json", "", false, "read the latest release"}, + {name: "minor", have: "0.1.0", body: release("v0.2.0"), tag: "v0.2.0", newer: true}, + {name: "patch", have: "v0.1.0", body: release("v0.1.1"), tag: "v0.1.1", newer: true}, + {name: "major", have: "0.9.9", body: release("v1.0.0"), tag: "v1.0.0", newer: true}, + {name: "two digits", have: "0.9.0", body: release("v0.10.0"), tag: "v0.10.0", newer: true}, + // Release tags start with a v, which GoReleaser also puts in the + // archive names, so a tag without it is never newer. + {name: "no v", have: "0.1.0", body: release("0.2.0"), tag: "0.2.0"}, + {name: "same", have: "0.1.0", body: release("v0.1.0"), tag: "v0.1.0"}, + {name: "older", have: "0.2.0", body: release("v0.1.0"), tag: "v0.1.0"}, + {name: "release candidate", have: "0.1.0", body: release("v0.2.0-rc1"), tag: "v0.2.0-rc1"}, + {name: "no tag", have: "0.1.0", body: release("")}, + // Atoi accepts a sign, but a version number cannot have one. + {name: "sign", have: "0.9.0", body: release("v+1.0.0"), tag: "v+1.0.0"}, + {name: "minus", have: "0.9.0", body: release("v1.-0.0"), tag: "v1.-0.0"}, + {name: "status", have: "0.1.0", errText: "404"}, + {name: "bad JSON", have: "0.1.0", body: "not json", errText: "read the latest release"}, } files := fstest.MapFS{} for i, tt := range tests { @@ -116,7 +121,7 @@ func TestLatest(t *testing.T) { for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - u := detect(t, fmt.Sprint(base, "/", i), "/bin/rexdiff", tt.have) + u := newInstaller(t, fmt.Sprint(base, "/", i), "/bin/rexdiff", tt.have) r, newer, err := u.Latest(t.Context()) if tt.errText != "" { if err == nil || !strings.Contains(err.Error(), tt.errText) { @@ -132,14 +137,15 @@ func TestLatest(t *testing.T) { } } -// TestInstallBrew checks the commands of a Homebrew install, and the check -// of the new version. +// TestInstallBrew checks the brew commands and environment variables that +// Install uses for a Homebrew install, the errors when brew fails, and the +// check of the new binary's version. func TestInstallBrew(t *testing.T) { t.Parallel() - // The fake brew logs the flags of brew and its arguments. Rex starts - // rexdiff with a PATH without Homebrew, so the brew beside the - // Caskroom comes first. The environment of the Updater has none of the - // flags of brew. + // The fake brew logs the brew variables and its arguments. Install must + // prefer the brew next to the Caskroom, because Rex starts rexdiff with + // a PATH that does not include Homebrew. The Installer's environment + // sets none of the brew variables. const logBrew = `echo "hints=$HOMEBREW_NO_ENV_HINTS ` + `ni=$NONINTERACTIVE auto=$HOMEBREW_NO_AUTO_UPDATE ${0##*/} $*" ` + `>> "${0%/*}/../calls"` + "\n" @@ -148,34 +154,33 @@ func TestInstallBrew(t *testing.T) { "hints=1 ni=1 auto=1 brew upgrade --cask rexdiff\n" tests := []struct { name string - beside bool // the brew is beside the Caskroom, else in PATH - brew string // the script of the fake brew - version string // the version of the new rexdiff + beside bool // the brew is next to the Caskroom, not only in PATH + brew string // the fake brew script + version string // the version that the new rexdiff reports cancel bool // cancel the context before Install - calls string // the log of brew, or "" for no check - errText string // a part of the error, or "" for none + calls string // the expected brew log, or "" to skip the check + errText string // text that the error must contain, or "" for no error }{ - {"beside the Caskroom", true, logBrew, "v0.2.0", false, calls, ""}, - {"in PATH", false, logBrew, "v0.2.0", false, calls, ""}, - // The error has the last line of the output. + {name: "beside the Caskroom", beside: true, brew: logBrew, version: "v0.2.0", calls: calls}, + {name: "in PATH", brew: logBrew, version: "v0.2.0", calls: calls}, + // The error includes the last line of the output. { - "brew fails", true, busy, "v0.2.0", false, "", - "brew: Error: another brew is running: exit status 1", + name: "brew fails", beside: true, brew: busy, version: "v0.2.0", + errText: "brew: Error: another brew is running: exit status 1", }, - // The error has no empty part at its end. + // With no output, the error does not end with an empty part. { - "brew fails with no output", true, "exit 1\n", "v0.2.0", false, "", - "brew: exit status 1", + name: "brew fails with no output", beside: true, brew: "exit 1\n", version: "v0.2.0", + errText: "brew: exit status 1", }, - // A new binary of another version is an error, so rexdiff does not - // restart into it. + // It is an error if the new binary reports a different version. { - "old version", true, logBrew, "v0.1.0", false, "", - "the update left rexdiff v0.1.0, not v0.2.0", + name: "old version", beside: true, brew: logBrew, version: "v0.1.0", + errText: "the update left rexdiff v0.1.0, not v0.2.0", }, - // A stopped brew can leave a broken cask. The user can quit - // rexdiff while brew runs. - {"outlives a cancel", true, logBrew, "v0.2.0", true, calls, ""}, + // Cancelling the context does not stop brew, because stopping brew + // halfway can leave a broken cask. + {name: "outlives a cancel", beside: true, brew: logBrew, version: "v0.2.0", cancel: true, calls: calls}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -199,13 +204,12 @@ func TestInstallBrew(t *testing.T) { if tt.cancel { cancel(fmt.Errorf("test stops the install: %w", context.Canceled)) } - u := detect(t, "http://127.0.0.1:1/latest", link, "0.1.0", pathEnv) + u := newInstaller(t, "http://127.0.0.1:1/latest", link, "0.1.0", pathEnv) err := u.Install(ctx, update.Release{Tag: "v0.2.0"}) - failed := err != nil - if tt.errText != "" { - failed = err == nil || !strings.Contains(err.Error(), tt.errText) - } - if failed { + switch { + case tt.errText == "" && err != nil: + t.Errorf("Install = %v, want nil", err) + case tt.errText != "" && (err == nil || !strings.Contains(err.Error(), tt.errText)): t.Errorf("Install = %v, want an error with %q", err, tt.errText) } if tt.calls == "" { @@ -222,8 +226,8 @@ func TestInstallBrew(t *testing.T) { } } -// archive returns a .tar.gz with a README.md and a file rexdiff that has -// content. +// archive returns a .tar.gz archive that holds a README.md and a file +// named rexdiff with the given content. func archive(t *testing.T, content string) []byte { t.Helper() var b bytes.Buffer @@ -250,9 +254,9 @@ func archive(t *testing.T, content string) []byte { return b.Bytes() } -// TestDownload checks the download of a release. It checks the checksum, -// the binary in the archive, and the check of its version. It also checks -// the replacement of the target file of a link. +// TestDownload checks that a download install verifies the checksum, the +// binary size and the new version, replaces the file that the symlink +// points to, and leaves the old binary in place when any check fails. func TestDownload(t *testing.T) { t.Parallel() name := fmt.Sprintf("rexdiff-v0.2.0-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) @@ -263,7 +267,7 @@ func TestDownload(t *testing.T) { return hex.EncodeToString(sum[:]) + " " + name + "\nabc other.tar.gz\n" } stale := archive(t, "#!/bin/sh\necho v0.1.9\n") - // The limit of a binary is 128 MiB. + // A binary can be at most 128 MiB. big := archive(t, strings.Repeat("x", 128<<20+1)) files := fstest.MapFS{} for dir, a := range map[string][]byte{"good": good, "stale": stale, "big": big, "bad": good} { @@ -273,8 +277,9 @@ func TestDownload(t *testing.T) { files["bad/"+name] = &fstest.MapFile{Data: []byte("tampered")} mux := http.NewServeMux() mux.Handle("/", http.FileServerFS(files)) - // The release of a directory has its files, at the URLs of the server. - // The release of "none" has no files. + // Each directory's release lists the files in that directory as + // assets. The directory "none" does not exist, so its release has no + // assets. mux.HandleFunc("GET /{dir}/latest", func(w http.ResponseWriter, req *http.Request) { type asset struct { Name string `json:"name"` @@ -284,7 +289,7 @@ func TestDownload(t *testing.T) { assets := []asset{} for _, f := range []string{name, "sha256sums.txt"} { if _, ok := files[dir+"/"+f]; ok { - assets = append(assets, asset{f, "http://" + req.Host + "/" + dir + "/" + f}) + assets = append(assets, asset{Name: f, URL: "http://" + req.Host + "/" + dir + "/" + f}) } } release := map[string]any{"tag_name": "v0.2.0", "assets": assets} @@ -296,16 +301,15 @@ func TestDownload(t *testing.T) { tests := []struct { name, dir string - errText string // a part of the error, or "" for none + errText string // text that the error must contain, or "" for no error }{ - {"good", "good", ""}, - {"tampered", "bad", "does not match its checksum"}, - // The old binary stays, so the next start does not run a binary of - // another version. - {"other version", "stale", "the update left rexdiff v0.1.9, not v0.2.0"}, - // A binary over the limit is an error, not a binary that is cut. - {"too large", "big", "larger than"}, - {"no archive", "none", "has no " + name}, + {name: "good", dir: "good"}, + {name: "tampered", dir: "bad", errText: "does not match its checksum"}, + // The old binary stays in place. + {name: "other version", dir: "stale", errText: "the update left rexdiff v0.1.9, not v0.2.0"}, + // A binary over the limit is an error, not a truncated binary. + {name: "too large", dir: "big", errText: "larger than"}, + {name: "no archive", dir: "none", errText: "has no " + name}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -318,7 +322,7 @@ func TestDownload(t *testing.T) { if err := os.Symlink(target, link); err != nil { t.Fatal(err) } - u := detect(t, base+"/"+tt.dir+"/latest", link, "0.1.0") + u := newInstaller(t, base+"/"+tt.dir+"/latest", link, "0.1.0") r, newer, err := u.Latest(t.Context()) if err != nil || !newer { t.Fatalf("Latest = %+v, %v, %v, want a newer release", r, newer, err) diff --git a/vcs/bench_test.go b/vcs/bench_test.go new file mode 100644 index 0000000..cf0c8e0 --- /dev/null +++ b/vcs/bench_test.go @@ -0,0 +1,64 @@ +package vcs_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/rednafi/rexdiff/internal/testgit" + "github.com/rednafi/rexdiff/vcs" +) + +// BenchmarkDiff measures Diff on 1,000 changed files of 500 lines each. +// The cold case opens a new Repo each time, so Diff reads every object. +// The warm case repeats the Diff on the same Repo, so every object comes +// from the cache. +func BenchmarkDiff(b *testing.B) { + dir := b.TempDir() + testgit.InitRepo(b, dir) + for i := range 1000 { + testgit.WriteFile(b, dir, fmt.Sprintf("d%d/f%04d.go", i%20, i), strings.Repeat(fmt.Sprintf("line of file %d\n", i), 500)) + } + testgit.Git(b, dir, "add", ".") + testgit.Git(b, dir, "commit", "-qm", "base") + for i := range 1000 { + testgit.WriteFile(b, dir, fmt.Sprintf("d%d/f%04d.go", i%20, i), strings.Repeat(fmt.Sprintf("new line of file %d\n", i), 500)) + } + env := testgit.ToolEnv(b) + openRepo := func(b *testing.B) (r *vcs.Repo, oldTree, newTree string) { + b.Helper() + r, err := vcs.Open(b.Context(), env, dir) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = r.Close() }) + if oldTree, err = r.HeadTree(b.Context()); err != nil { + b.Fatal(err) + } + if newTree, err = r.SnapshotTree(b.Context()); err != nil { + b.Fatal(err) + } + return r, oldTree, newTree + } + b.Run("cold", func(b *testing.B) { + for b.Loop() { + b.StopTimer() + r, oldTree, newTree := openRepo(b) + b.StartTimer() + if _, err := r.Diff(b.Context(), oldTree, newTree); err != nil { + b.Fatal(err) + } + } + }) + b.Run("warm", func(b *testing.B) { + r, oldTree, newTree := openRepo(b) + if _, err := r.Diff(b.Context(), oldTree, newTree); err != nil { + b.Fatal(err) + } + for b.Loop() { + if _, err := r.Diff(b.Context(), oldTree, newTree); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/vcs/export_test.go b/vcs/export_test.go index 9f10dc0..6f44276 100644 --- a/vcs/export_test.go +++ b/vcs/export_test.go @@ -2,6 +2,10 @@ package vcs import "time" -// SetClock sets the clock of w. The pause between two checks follows it. -// A clock that does not move turns off the pause. +// SetClock replaces the clock that w uses to time the pause between +// checks. A clock that never moves turns the pause off. func (w *Watcher) SetClock(now func() time.Time) { w.now = now } + +// SetMaxDiffBytes replaces the most text that r.Diff reads, 128 MB, which +// would make a test slow. +func (r *Repo) SetMaxDiffBytes(n int) { r.maxDiffBytes = n } diff --git a/vcs/hostile_test.go b/vcs/hostile_test.go index 8c9cd62..1a90071 100644 --- a/vcs/hostile_test.go +++ b/vcs/hostile_test.go @@ -15,27 +15,29 @@ import ( "time" "github.com/rednafi/rexdiff/internal/testgit" + "github.com/rednafi/rexdiff/internal/xctx" "github.com/rednafi/rexdiff/vcs" ) -// commitAll commits all the changes in the working tree of dir. +// commitAll stages and commits every change in the working tree of dir. func commitAll(t *testing.T, dir string) { t.Helper() testgit.Git(t, dir, "add", "-A") testgit.Git(t, dir, "commit", "-qm", "all") } -// TestRevIsNotAnOption checks that git does not read a rev that starts -// with "-" as an option. +// TestRevIsNotAnOption checks that every method that takes a revision +// fails for a revision that starts with "-", instead of passing it to git +// as an option, and that --output does not overwrite a file. func TestRevIsNotAnOption(t *testing.T) { t.Parallel() - r, _ := newRepo(t) + r, _ := openTestRepo(t) ctx := t.Context() out := filepath.Join(t.TempDir(), "out") testgit.WriteFile(t, filepath.Dir(out), "out", "keep\n") for _, rev := range []string{"--output=" + out, "--independent", "--all", "-h"} { - if id, err := r.Resolve(ctx, rev); err == nil { - t.Errorf("Resolve(%q) = %s, want an error", rev, id) + if id, err := r.ResolveRev(ctx, rev); err == nil { + t.Errorf("ResolveRev(%q) = %s, want an error", rev, id) } if id, err := r.Tree(ctx, rev); err == nil { t.Errorf("Tree(%q) = %s, want an error", rev, id) @@ -59,10 +61,12 @@ func TestRevIsNotAnOption(t *testing.T) { } } -// TestHostilePaths checks that each part of the API keeps a path as it is. +// TestHostilePaths checks that Diff, Files, ReadFile and Grep handle file +// names that look like options, globs or pathspecs, or that contain +// quotes, tabs, newlines or invalid UTF-8, without changing them. func TestHostilePaths(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) ctx := t.Context() commitAll(t, dir) hostile := []string{ @@ -70,11 +74,12 @@ func TestHostilePaths(t *testing.T) { `say "hi"`, `back\slash`, "ünï cödé", " spaces ", "*", "[a]", ":(glob)x", "\xff\xfe", } - want := map[string]string{} // the content of each path + want := map[string]string{} // the expected content of each path for i, p := range hostile { s := fmt.Sprintf("id%02d\n", i) if err := os.WriteFile(filepath.Join(dir, p), []byte(s), 0o644); err != nil { - // For example, macOS needs UTF-8 names. + // Some file systems reject some names; macOS, for example, + // requires valid UTF-8. t.Logf("skip %q: %v", p, err) continue } @@ -86,7 +91,7 @@ func TestHostilePaths(t *testing.T) { if f.Status != vcs.StatusAdded { t.Errorf("Diff: %q has status %c, want A", f.Path, f.Status) } - got[f.Path] = f.New + got[f.Path] = f.NewText } if !maps.Equal(got, want) { t.Errorf("Diff = \n%q\nwant:\n%q", got, want) @@ -111,11 +116,12 @@ func TestHostilePaths(t *testing.T) { } } -// TestDiffFileKinds checks what Diff gives for links, file modes, -// directories, line ends, and empty files. +// TestDiffFileKinds checks what Diff returns for symbolic links, mode +// changes, a file replaced by a directory or by a link, CRLF line endings, +// a missing final newline, and empty files. func TestDiffFileKinds(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) testgit.Git(t, dir, "config", "core.autocrlf", "false") testgit.WriteFile(t, dir, "mode.sh", "echo\n") testgit.WriteFile(t, dir, "dir", "a file\n") @@ -152,27 +158,28 @@ func TestDiffFileKinds(t *testing.T) { } want := []vcs.File{ - {Path: "crlf", Status: vcs.StatusAdded, New: "a\r\nb\r\n"}, - {Path: "dir", Status: vcs.StatusDeleted, Old: "a file\n"}, - {Path: "dir/in", Status: vcs.StatusAdded, New: "in\n"}, + {Path: "crlf", Status: vcs.StatusAdded, NewText: "a\r\nb\r\n"}, + {Path: "dir", Status: vcs.StatusDeleted, OldText: "a file\n"}, + {Path: "dir/in", Status: vcs.StatusAdded, NewText: "in\n"}, {Path: "empty", Status: vcs.StatusAdded}, - // git keeps the target of a link. - {Path: "link", Status: vcs.StatusAdded, New: "a.txt"}, - {Path: "mode.sh", Status: vcs.StatusModified, Old: "echo\n", New: "echo\n"}, - {Path: "no-newline", Status: vcs.StatusAdded, New: "end"}, - {Path: "out", Status: vcs.StatusAdded, New: outside}, - {Path: "type", Status: vcs.StatusModified, Old: "a file\n", New: "a.txt"}, + // git stores a symbolic link as its target path. + {Path: "link", Status: vcs.StatusAdded, NewText: "a.txt"}, + {Path: "mode.sh", Status: vcs.StatusModified, OldText: "echo\n", NewText: "echo\n"}, + {Path: "no-newline", Status: vcs.StatusAdded, NewText: "end"}, + {Path: "out", Status: vcs.StatusAdded, NewText: outside}, + {Path: "type", Status: vcs.StatusModified, OldText: "a file\n", NewText: "a.txt"}, } if got := changes(t, r, head(t, r), snapshot(t, r)); !slices.Equal(got, want) { t.Errorf("Diff = \n%+v\nwant:\n%+v", got, want) } } -// TestSnapshotNestedRepo checks that Snapshot adds the other files when git -// cannot add a nested repository with no commit. +// TestSnapshotNestedRepo checks that SnapshotTree still adds the other +// files when git cannot add a nested repository with no commits, and adds +// a nested repository with a commit as a submodule. func TestSnapshotNestedRepo(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) commitAll(t, dir) testgit.InitRepo(t, filepath.Join(dir, "empty")) testgit.WriteFile(t, dir, "empty/x", "x\n") @@ -182,18 +189,19 @@ func TestSnapshotNestedRepo(t *testing.T) { testgit.WriteFile(t, dir, "z.txt", "z\n") got := changes(t, r, head(t, r), snapshot(t, r)) - if names(got) != "Afull Az.txt" || !strings.HasPrefix(got[0].New, "Subproject commit ") { + if names(got) != "Afull Az.txt" || !strings.HasPrefix(got[0].NewText, "Subproject commit ") { t.Errorf("Diff = %+v, want full as a submodule and z.txt", got) } } -// TestReadFileStaysInRoot checks that ReadFile reads only the regular files -// in the root. +// TestReadFileStaysInRoot checks that ReadFile refuses paths and symbolic +// links that lead outside the root, devices, directories and named pipes, +// but follows a link to a regular file inside the root. func TestReadFileStaysInRoot(t *testing.T) { t.Parallel() - // A repository can link to a secret file, or to a device that does not - // end, for example /dev/zero. - r, dir := newRepo(t) + // A repository can contain a link to a secret file, or to a device + // that never ends, such as /dev/zero. + r, dir := openTestRepo(t) secret := filepath.Join(filepath.Dir(dir), "secret") testgit.WriteFile(t, filepath.Dir(dir), "secret", "secret\n") links := map[string]string{ @@ -223,12 +231,14 @@ func TestReadFileStaysInRoot(t *testing.T) { } } -// TestGitWorktree checks Diff and Snapshot in a working tree that -// "git worktree add" creates. +// TestGitWorktree checks Diff and SnapshotTree in a working tree created +// by "git worktree add", and that the snapshot does not change what git +// status reports there. func TestGitWorktree(t *testing.T) { t.Parallel() - // Such a working tree has its own index. Snapshot must not change it. - _, dir := newRepo(t) + // Such a working tree has its own index, which SnapshotTree must not + // change. + _, dir := openTestRepo(t) wt := filepath.Join(t.TempDir(), "wt") testgit.Git(t, dir, "worktree", "add", "-q", "-b", "other", wt, "main") testgit.WriteFile(t, wt, "a.txt", "changed\n") @@ -249,7 +259,7 @@ func TestGitWorktree(t *testing.T) { // TestDetachedHead checks Diff, Commits, and Branch when HEAD is detached. func TestDetachedHead(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) ctx := t.Context() testgit.Git(t, dir, "checkout", "-q", "--detach", "main") if got := uncommitted(t, r); got != "A.gitignore Ma.txt Anew.txt" { @@ -264,14 +274,16 @@ func TestDetachedHead(t *testing.T) { } } -// TestGrepIsLiteral checks that Grep finds the text, and does not read it -// as a pattern or as an option. It also checks the smart case. +// TestGrepIsLiteral checks that Grep searches for the query as literal +// text, not as a pattern or an option, and that it ignores case only when +// the query has no upper case letter. func TestGrepIsLiteral(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) ctx := t.Context() testgit.WriteFile(t, dir, "g.txt", "abc\na.c\n-v\n--help\n[x\n(y\n\\d\n*\n^a\nx$\nAbc\n") - // grep.column adds a field to the output. + // The grep.column setting adds a field to the output, which Grep must + // turn off. testgit.Git(t, dir, "config", "grep.column", "true") tests := []struct { q string @@ -286,7 +298,7 @@ func TestGrepIsLiteral(t *testing.T) { {"*", []string{"*"}}, {"^a", []string{"^a"}}, {"x$", []string{"x$"}}, - {"abc\na.c", nil}, // no line has a newline + {"abc\na.c", nil}, // a single line cannot contain a newline {"abc", []string{"abc", "Abc"}}, {"Abc", []string{"Abc"}}, } @@ -306,11 +318,12 @@ func TestGrepIsLiteral(t *testing.T) { } } -// TestCommitsHostileSubject checks that Commits keeps each byte of a -// subject, for example the field separator of the log. +// TestCommitsHostileSubject checks that Commits keeps every byte of a +// commit subject, including the field separator that Commits uses and +// escape sequences. func TestCommitsHostileSubject(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) subjects := []string{"a\x1fb", "\x1b[31mred\x1b[0m", "tab\there"} for _, s := range subjects { testgit.Git(t, dir, "commit", "-q", "--allow-empty", "-m", s) @@ -332,16 +345,17 @@ func TestCommitsHostileSubject(t *testing.T) { } } -// TestPullRequestBadJSON checks the output of gh that is not a normal pull -// request. -func TestPullRequestBadJSON(t *testing.T) { +// TestPRBadJSON checks how PR handles gh output that is not a normal pull +// request: an empty object, text that is not JSON, a number out of range, +// and null fields. +func TestPRBadJSON(t *testing.T) { t.Parallel() tests := []struct { name, out string - wantErr error // nil for an error of any type - ok bool // true if PullRequest must not fail + wantErr error // the error to match, or nil to accept any error + ok bool // true if PR must succeed }{ - {name: "empty object", out: `{}`, wantErr: vcs.ErrNoPullRequest}, + {name: "empty object", out: `{}`, wantErr: vcs.ErrNoPR}, {name: "not JSON", out: `warning: no pull request`}, {name: "huge number", out: `{"title": "T", "number": 1e30}`}, { @@ -357,30 +371,30 @@ func TestPullRequestBadJSON(t *testing.T) { bin := t.TempDir() fakeTool(t, bin, "gh", map[string]string{ghArgs: tt.out}) r, _ := repoAt(t, testgit.ToolEnv(t, bin), "https://github.com/o/r") - pr, err := r.PullRequest(t.Context(), vcs.Hosts{}) + pr, err := r.PR(t.Context(), vcs.Hosts{}) switch { case tt.ok && (err != nil || pr.Title != "T"): - t.Errorf("PullRequest = %+v, %v, want the title T", pr, err) + t.Errorf("PR = %+v, %v, want the title T", pr, err) case !tt.ok && err == nil: - t.Errorf("PullRequest = %+v, want an error", pr) + t.Errorf("PR = %+v, want an error", pr) case tt.wantErr != nil && !errors.Is(err, tt.wantErr): - t.Errorf("PullRequest: err = %v, want %v", err, tt.wantErr) + t.Errorf("PR: err = %v, want %v", err, tt.wantErr) } }) } } -// TestGrepDeadline checks that Grep returns the matches that it finds -// before the deadline of ctx. more is true, as with the time budget of -// herdr-reviewr. +// TestGrepDeadline checks that when the budget or the deadline of ctx +// passes, Grep stops git, returns the matches found so far without the +// incomplete one, and sets more. func TestGrepDeadline(t *testing.T) { t.Parallel() env, link := gitLink(t) - r, _ := newRepoEnv(t, env) - // The fake git takes the place of git after Open. It prints one match - // and a cut record, then hangs. With the file slow beside it, it first - // waits much longer than the budget. With the FIFO block beside it, it - // waits in the shell until it stops, so it prints nothing. + r, _ := openTestRepoEnv(t, env) + // After Open, a fake git replaces the real one. It prints one match and + // an incomplete record, then hangs. If a file named slow is next to it, + // it first sleeps for much longer than the budget. If a FIFO named + // block is next to it, it waits on the FIFO and prints nothing. removeGit(t, link) bin := filepath.Dir(link) testgit.Script(t, bin, "git", `d="${0%/*}" @@ -390,8 +404,7 @@ printf 'a.go\0001\000hit\nb.go\00012' exec /bin/sleep 10 `) grep := func(timeout, budget time.Duration) ([]vcs.Match, bool, error) { - ctx, cancel := context.WithTimeoutCause(t.Context(), timeout, - fmt.Errorf("test grep: %v timeout: %w", timeout, context.DeadlineExceeded)) + ctx, cancel := xctx.WithTimeout(t.Context(), timeout, "test grep") defer cancel() return r.Grep(ctx, "hit", 10, budget) } @@ -414,15 +427,16 @@ exec /bin/sleep 10 } } -// TestSnapshotWaitStops checks that a Snapshot that waits for another -// Snapshot returns the cause of its context when the context ends. +// TestSnapshotWaitStops checks that a SnapshotTree call that is waiting +// for another call returns the cause of its context when its context +// ends, and that the running call returns its own cause when cancelled. func TestSnapshotWaitStops(t *testing.T) { t.Parallel() env, link := gitLink(t) - r, _ := newRepoEnv(t, env) - // The fake git takes the place of git after Open. It writes to the FIFO - // started, so the test knows that the first Snapshot holds the lock. - // Then it waits on the FIFO block until it stops. + r, _ := openTestRepoEnv(t, env) + // After Open, a fake git replaces the real one. It writes to the FIFO + // named started, which tells the test that the first SnapshotTree holds + // the lock, and then waits on the FIFO named block. removeGit(t, link) bin := filepath.Dir(link) for _, name := range []string{"started", "block"} { @@ -439,17 +453,16 @@ read -r _ < "$d/block" defer cancel(fmt.Errorf("test: done: %w", context.Canceled)) done := make(chan error, 1) go func() { - _, err := r.Snapshot(ctx) + _, err := r.SnapshotTree(ctx) done <- err }() if _, err := os.ReadFile(filepath.Join(bin, "started")); err != nil { t.Fatal(err) } - ctx2, cancel2 := context.WithTimeoutCause(t.Context(), 50*time.Millisecond, - fmt.Errorf("second snapshot: 50ms timeout: %w", context.DeadlineExceeded)) + ctx2, cancel2 := xctx.WithTimeout(t.Context(), 50*time.Millisecond, "second snapshot") defer cancel2() - _, err := r.Snapshot(ctx2) + _, err := r.SnapshotTree(ctx2) if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("Snapshot that waits = %v, want the cause of its context", err) } diff --git a/vcs/pr.go b/vcs/pr.go index 73f7fca..e5a73e4 100644 --- a/vcs/pr.go +++ b/vcs/pr.go @@ -12,29 +12,28 @@ import ( ) var ( - // ErrNoPullRequest means that the branch has no pull request. - ErrNoPullRequest = errors.New("no pull request for this branch") + // ErrNoPR means that the current branch has no pull request. + ErrNoPR = errors.New("no pull request for this branch") errNoRemote = errors.New("no git remote") errUnsupported = errors.New("the remote is not on GitHub, GitLab, or Azure DevOps") ) -// maxComments is the number of the most recent comments that -// [Repo.PullRequest] gets. maxChecks is the number of the checks that it -// gets. The two limits are the same as in herdr-reviewr. +// Limits for [Repo.PR]: it keeps at most maxComments of the most recent +// comments and at most maxChecks checks, though it counts every check. const ( maxComments = 100 maxChecks = 100 ) -// Hosts are the hosts of self-hosted forges. An empty value means only the -// public host. +// Hosts holds the host names of self-hosted forges. An empty field means +// that only the public host of that forge is recognised. type Hosts struct { GitHub, GitLab, Azure string } -// PullRequest is a pull request, or a merge request on GitLab. -type PullRequest struct { +// PR is a pull request, or a merge request on GitLab. +type PR struct { Forge Forge Number int Title string @@ -44,8 +43,13 @@ type PullRequest struct { Head string // the source branch Base string // the target branch Body string // Markdown + // Checks holds at most 100 of the CI checks, in the forge's order. If + // there are more, it keeps the failed checks before the others, so + // that every failure it can hold is named. Tally counts all of them. Checks []Check - // Comments are the most recent comments, the oldest first. + Tally Tally + // Comments holds at most 100 of the most recent comments, oldest + // first. Comments []PRComment } @@ -55,18 +59,43 @@ type Check struct { Result Result } -// Forge is the service that hosts a pull request. Its value is the name of -// the service. +// Tally counts the CI checks of a pull request by result. +type Tally struct { + Pass, Fail, Pending, Skip int +} + +// Total returns the number of checks. +func (t *Tally) Total() int { + return t.Pass + t.Fail + t.Pending + t.Skip +} + +// add counts a check with the result res. +func (t *Tally) add(res Result) { + switch res { + case ResultPass: + t.Pass++ + case ResultFail: + t.Fail++ + case ResultSkip: + t.Skip++ + default: + t.Pending++ + } +} + +// Forge is the service that hosts a pull request. Its value is the +// service's display name. type Forge string -// The forges that [Repo.PullRequest] supports. +// The forges that [Repo.PR] supports. const ( ForgeGitHub Forge = "GitHub" ForgeGitLab Forge = "GitLab" ForgeAzure Forge = "Azure DevOps" ) -// PRState is the state of a pull request. It is the same for each forge. +// PRState is the state of a pull request, using the same values for every +// forge. type PRState string // The values of PRState. @@ -74,41 +103,42 @@ const ( PROpen PRState = "open" PRDraft PRState = "draft" // open, but not ready for review PRMerged PRState = "merged" - PRClosed PRState = "closed" // closed without a merge + PRClosed PRState = "closed" // closed without being merged ) -// Result is the result of a CI check. It is the same for each forge. +// Result is the result of a CI check, using the same values for every +// forge. type Result string // The values of Result. const ( ResultPass Result = "pass" - ResultFail Result = "fail" // also an error, a cancel, or a timeout - ResultPending Result = "pending" // in the queue, or not finished + ResultFail Result = "fail" // also an error, a cancellation or a timeout + ResultPending Result = "pending" // queued or still running ResultSkip Result = "skip" // skipped, or neutral ) // PRComment is a comment on a pull request. type PRComment struct { Author string - When string // the time, as the forge gives it + When string // the creation time, as the forge formats it Body string // Markdown } -// PullRequest returns the pull request of the current branch. It selects the -// forge from the host of the remote "upstream", or else "origin". Then it runs -// the command-line tool of the forge: gh, glab, or az. The tool must be -// installed and signed in. The text of the pull request has no control -// characters. If the branch has no pull request, PullRequest returns -// [ErrNoPullRequest]. -func (r *Repo) PullRequest(ctx context.Context, h Hosts) (*PullRequest, error) { +// PR returns the pull request for the current branch. It chooses the forge +// from the host of the remote "upstream", or of "origin" if there is no +// upstream, and runs that forge's command-line tool: gh, glab or az. The +// tool must be installed and signed in. PR removes control characters +// from all text fields. If the branch has no pull request, PR returns +// [ErrNoPR]. +func (r *Repo) PR(ctx context.Context, h Hosts) (*PR, error) { remote := r.remoteURL(ctx) if remote == "" { return nil, errNoRemote } host := remoteHost(remote) is := func(name string) bool { return name != "" && strings.EqualFold(host, name) } - var pr *PullRequest + var pr *PR var err error switch { case is("github.com") || is(h.GitHub): @@ -125,15 +155,15 @@ func (r *Repo) PullRequest(ctx context.Context, h Hosts) (*PullRequest, error) { return nil, err } if pr.Title == "" { - return nil, ErrNoPullRequest + return nil, ErrNoPR } - cleanPR(pr) + sanitizePR(pr) return pr, nil } -// remoteHost returns the host of the remote URL u, in lower case. u is a -// URL such as "ssh://git@host:22/path", or has the scp form -// "git@host:path", as herdr-reviewr reads it. A local path has no host. +// remoteHost returns the host name in the remote URL u, in lower case. u +// is either a URL such as "ssh://git@host:22/path" or in the scp-like form +// "git@host:path". For a local path, it returns "". func remoteHost(u string) string { var auth string // [user@]host[:port] if _, rest, ok := strings.Cut(u, "://"); ok { @@ -148,9 +178,9 @@ func remoteHost(u string) string { return strings.ToLower(host) } -// cleanPR removes the control characters from the text of pr, because the -// text comes from other users. -func cleanPR(pr *PullRequest) { +// sanitizePR removes control characters from the text fields of pr, +// because other users write that text. +func sanitizePR(pr *PR) { texts := []*string{&pr.Title, &pr.URL, &pr.Author, &pr.Head, &pr.Base, &pr.Body} for i := range pr.Checks { texts = append(texts, &pr.Checks[i].Name) @@ -165,8 +195,8 @@ func cleanPR(pr *PullRequest) { } // runJSON runs a forge tool in the root directory and decodes its JSON -// output into v. env adds to the environment. The error names the tool -// and its subcommand, for example "gh pr". +// output into v. env holds extra environment variables. The error names +// the tool and its subcommand, for example "gh pr". func (r *Repo) runJSON(ctx context.Context, env []string, v any, name string, args ...string) error { cmd, stderr := r.command(ctx, env, name, args...) out, err := output(ctx, cmd, stderr) @@ -179,10 +209,12 @@ func (r *Repo) runJSON(ctx context.Context, env []string, v any, name string, ar return nil } -// noPR reports whether err is the error of a tool that finds no pull -// request. Such a tool exits with 1 and says so with the text msg. -func noPR(err error, msg string) bool { - return exitedOne(err) && strings.Contains(strings.ToLower(err.Error()), msg) +// isNoPR reports whether err means that the forge tool found no pull +// request: the tool exited with status 1 and its message contains msg. +func isNoPR(err error, msg string) bool { + // gh and glab exit with status 1 for every kind of failure and have no + // error code for "no pull request", so the message text is the only sign. + return isExitOne(err) && strings.Contains(strings.ToLower(err.Error()), msg) } // lastN returns the last n elements of xs, or all of them if there are @@ -220,9 +252,9 @@ type gitHubPR struct { } `json:"statusCheckRollup"` } -// gitHub gets the pull request with gh. A host that is not empty is the -// host of a self-hosted GitHub. -func (r *Repo) gitHub(ctx context.Context, host string) (*PullRequest, error) { +// gitHub fetches the pull request with gh. A non-empty host is the host +// name of a self-hosted GitHub. +func (r *Repo) gitHub(ctx context.Context, host string) (*PR, error) { var env []string if host != "" { env = []string{"GH_HOST=" + host} @@ -231,13 +263,13 @@ func (r *Repo) gitHub(ctx context.Context, host string) (*PullRequest, error) { "headRefName,baseRefName,comments,statusCheckRollup" var v gitHubPR err := r.runJSON(ctx, env, &v, "gh", "pr", "view", "--json", fields) - if noPR(err, "no pull requests found") { - return nil, ErrNoPullRequest + if isNoPR(err, "no pull requests found") { + return nil, ErrNoPR } if err != nil { return nil, err } - pr := &PullRequest{ + pr := &PR{ Forge: ForgeGitHub, Number: v.Number, Title: v.Title, @@ -248,14 +280,16 @@ func (r *Repo) gitHub(ctx context.Context, host string) (*PullRequest, error) { Base: v.BaseRefName, Body: v.Body, } - // A check run has a conclusion when it is done. A status has a state. - // As in herdr-reviewr, the checks are the first that gh gives. - for _, c := range v.StatusCheckRollup[:min(len(v.StatusCheckRollup), maxChecks)] { - pr.Checks = append(pr.Checks, Check{ + // A finished check run has a conclusion, and a commit status has a + // state. + var checks []Check + for _, c := range v.StatusCheckRollup { + checks = append(checks, Check{ Name: cmp.Or(c.Name, c.Context), Result: gitHubResult(cmp.Or(c.Conclusion, c.State)), }) } + pr.Checks, pr.Tally = keepChecks(checks, maxChecks) for _, c := range lastN(v.Comments, maxComments) { pr.Comments = append(pr.Comments, PRComment{ Author: c.Author.Login, @@ -266,8 +300,36 @@ func (r *Repo) gitHub(ctx context.Context, host string) (*PullRequest, error) { return pr, nil } +// keepChecks counts all the checks and returns at most n of them, in their +// original order. If there are more than n, it keeps the failed checks +// first and fills the rest with the earliest of the others. +func keepChecks(checks []Check, n int) ([]Check, Tally) { + var t Tally + for _, c := range checks { + t.add(c.Result) + } + if len(checks) <= n { + return checks, t + } + // Room left for checks that did not fail, after the failed ones. + others := max(n-t.Fail, 0) + kept := make([]Check, 0, n) + for _, c := range checks { + if len(kept) == n { + break + } + if c.Result == ResultFail { + kept = append(kept, c) + } else if others > 0 { + kept = append(kept, c) + others-- + } + } + return kept, t +} + // gitHubState returns the state of a GitHub pull request from its state -// and its draft flag. +// and draft flag. func gitHubState(s string, draft bool) PRState { switch { case s == "MERGED": @@ -280,8 +342,8 @@ func gitHubState(s string, draft bool) PRState { return PROpen } -// gitLabState returns the state of a GitLab merge request. A locked merge -// request is closed. +// gitLabState returns the state of a GitLab merge request. It treats a +// locked merge request as closed. func gitLabState(s string) PRState { switch s { case "opened": @@ -293,7 +355,7 @@ func gitLabState(s string) PRState { } // azureState returns the state of an Azure DevOps pull request from its -// status. An abandoned pull request is closed. +// status. It treats an abandoned pull request as closed. func azureState(s string) PRState { switch s { case "active": @@ -347,17 +409,17 @@ type gitLabDiscussion struct { } `json:"notes"` } -// gitLab gets the merge request with glab. -func (r *Repo) gitLab(ctx context.Context) (*PullRequest, error) { +// gitLab fetches the merge request with glab. +func (r *Repo) gitLab(ctx context.Context) (*PR, error) { var v gitLabMR err := r.runJSON(ctx, nil, &v, "glab", "mr", "view", "--output", "json") - if noPR(err, "no open merge request") { - return nil, ErrNoPullRequest + if isNoPR(err, "no open merge request") { + return nil, ErrNoPR } if err != nil { return nil, err } - pr := &PullRequest{ + pr := &PR{ Forge: ForgeGitLab, Number: v.IID, Title: v.Title, @@ -368,8 +430,7 @@ func (r *Repo) gitLab(ctx context.Context) (*PullRequest, error) { Base: v.Target, Body: v.Description, } - // The comments are optional. If glab cannot get them, return the merge - // request without them. + // The comments are optional. If glab cannot list them, leave them out. var ds []gitLabDiscussion args := []string{"mr", "note", "list", "--output", "json"} if err := r.runJSON(ctx, nil, &ds, "glab", args...); err == nil { @@ -378,9 +439,8 @@ func (r *Repo) gitLab(ctx context.Context) (*PullRequest, error) { return pr, nil } -// gitLabComments returns the most recent notes of the discussions, the -// oldest first. It skips the notes that GitLab writes, for example "added -// 1 commit". +// gitLabComments returns the most recent notes in the discussions, oldest +// first. It skips system notes, such as "added 1 commit". func gitLabComments(ds []gitLabDiscussion) []PRComment { var cs []PRComment for _, d := range ds { @@ -414,8 +474,8 @@ type azurePR struct { } `json:"repository"` } -// azure gets the pull request of the current branch with az. -func (r *Repo) azure(ctx context.Context) (*PullRequest, error) { +// azure fetches the pull request for the current branch with az. +func (r *Repo) azure(ctx context.Context) (*PR, error) { branch, err := r.Branch(ctx) if err != nil { return nil, err @@ -427,10 +487,10 @@ func (r *Repo) azure(ctx context.Context) (*PullRequest, error) { return nil, err } if len(v) == 0 { - return nil, ErrNoPullRequest + return nil, ErrNoPR } p := v[0] - return &PullRequest{ + return &PR{ Forge: ForgeAzure, Number: p.PullRequestID, Title: p.Title, diff --git a/vcs/pr_test.go b/vcs/pr_test.go index 5b33a94..6300528 100644 --- a/vcs/pr_test.go +++ b/vcs/pr_test.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" "slices" + "strconv" "strings" "testing" @@ -12,9 +13,9 @@ import ( "github.com/rednafi/rexdiff/vcs" ) -// fakeTool writes a script called name in dir. If the arguments of the -// script are a key of calls, it prints the value. Else it fails with -// "unexpected call". +// fakeTool writes a fake command called name to dir. When its arguments +// match a key of calls, it prints the corresponding value. Otherwise it +// fails with "unexpected call". func fakeTool(t *testing.T, dir, name string, calls map[string]string) { t.Helper() var script strings.Builder @@ -26,27 +27,29 @@ func fakeTool(t *testing.T, dir, name string, calls map[string]string) { testgit.Script(t, dir, name, script.String()) } -// repoAt returns the test repository with the environment env, on the -// branch feat, with origin at the URL remote. +// repoAt returns a copy of the test repository, on the branch feat, that +// uses the environment env and has origin set to the URL remote. func repoAt(t *testing.T, env []string, remote string) (*vcs.Repo, string) { t.Helper() - r, dir := newRepoEnv(t, env) + r, dir := openTestRepoEnv(t, env) testgit.Git(t, dir, "remote", "add", "origin", remote) return r, dir } -// ghArgs are the arguments of the gh call that gets a pull request. +// ghArgs are the arguments that PR passes to gh to fetch a pull request. const ghArgs = "pr view --json number,title,state,isDraft,url,body,author," + "headRefName,baseRefName,comments,statusCheckRollup" -// azArgs returns the arguments of the az call that gets the pull request of -// branch. +// azArgs returns the arguments that PR passes to az to fetch the pull +// request for branch. func azArgs(branch string) string { return "repos pr list --source-branch " + branch + " --status all --top 1 --output json" } -// TestPullRequest checks the pull request that each forge tool gives. -func TestPullRequest(t *testing.T) { +// TestPR checks that PR converts the output of gh, glab and az into a +// [vcs.PR], including states, check results and comments, and that it +// recognises a self-hosted GitLab host regardless of case. +func TestPR(t *testing.T) { t.Parallel() dir := t.TempDir() env := testgit.ToolEnv(t, dir) @@ -69,7 +72,7 @@ func TestPullRequest(t *testing.T) { "mr view --output json": `{"iid": 3, "title": "Fix y", "state": "opened", "web_url": "https://gitlab.com/o/r/-/merge_requests/3", "description": "D", "source_branch": "fix", "target_branch": "main", "author": {"username": "cy"}}`, - // A list of discussions, as glab gives it. Each has its notes. + // glab returns a list of discussions, each with its own notes. "mr note list --output json": `[ {"id": "d1", "notes": [ {"body": "added 1 commit", "system": true, "author": {"username": "cy"}}]}, @@ -89,9 +92,9 @@ func TestPullRequest(t *testing.T) { name string remote string hosts vcs.Hosts - want *vcs.PullRequest + want *vcs.PR }{ - {"github ssh", "git@github.com:o/r.git", vcs.Hosts{}, &vcs.PullRequest{ + {"github ssh", "git@github.com:o/r.git", vcs.Hosts{}, &vcs.PR{ Forge: vcs.ForgeGitHub, Number: 7, Title: "Add x", @@ -109,11 +112,12 @@ func TestPullRequest(t *testing.T) { {Name: "docs", Result: vcs.ResultSkip}, {Name: "e2e", Result: vcs.ResultPending}, }, + Tally: vcs.Tally{Pass: 1, Fail: 2, Pending: 2, Skip: 1}, Comments: []vcs.PRComment{ {Author: "bob", When: "2026-01-02T03:04:05Z", Body: "LGTM"}, }, }}, - {"gitlab custom host", "https://Git.Corp/o/r", vcs.Hosts{GitLab: "git.CORP"}, &vcs.PullRequest{ + {"gitlab custom host", "https://Git.Corp/o/r", vcs.Hosts{GitLab: "git.CORP"}, &vcs.PR{ Forge: vcs.ForgeGitLab, Number: 3, Title: "Fix y", @@ -128,7 +132,7 @@ func TestPullRequest(t *testing.T) { {Author: "cy", When: "2026-01-03T03:04:05Z", Body: "Speed."}, }, }}, - {"azure", "https://dev.azure.com/o/p/_git/r", vcs.Hosts{}, &vcs.PullRequest{ + {"azure", "https://dev.azure.com/o/p/_git/r", vcs.Hosts{}, &vcs.PR{ Forge: vcs.ForgeAzure, Number: 9, Title: "Z", @@ -143,20 +147,21 @@ func TestPullRequest(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() r, _ := repoAt(t, env, tt.remote) - pr, err := r.PullRequest(t.Context(), tt.hosts) + pr, err := r.PR(t.Context(), tt.hosts) if err != nil { - t.Fatalf("PullRequest = %v, want no error", err) + t.Fatalf("PR = %v, want no error", err) } if !reflect.DeepEqual(pr, tt.want) { - t.Errorf("PullRequest = \n%+v\nwant:\n%+v", pr, tt.want) + t.Errorf("PR = \n%+v\nwant:\n%+v", pr, tt.want) } }) } } -// TestPullRequestErrors checks the error for a missing remote, an unknown -// forge, a missing pull request, and a missing tool. -func TestPullRequestErrors(t *testing.T) { +// TestPRErrors checks the errors that PR returns for a missing remote, an +// unsupported host, a branch with no pull request and a missing forge +// tool. +func TestPRErrors(t *testing.T) { t.Parallel() bin := t.TempDir() env := testgit.ToolEnv(t, bin) @@ -169,9 +174,9 @@ func TestPullRequestErrors(t *testing.T) { remote, branch string hosts vcs.Hosts wantErr error // if not nil, err must wrap it - errText string // if not empty, a part of the text of err + errText string // if not empty, text that err must contain }{ - // The host selects the forge, not a name in the path. + // Only the host chooses the forge, not a forge name in the path. { name: "github in the path", remote: "https://example.com/github.com/r.git", @@ -211,7 +216,7 @@ func TestPullRequestErrors(t *testing.T) { errText: "unexpected call: " + azArgs("other"), }, {name: "no remote", branch: "feat", errText: "no git remote"}, - {name: "azure no pull request", remote: azure, branch: "none", wantErr: vcs.ErrNoPullRequest}, + {name: "azure no pull request", remote: azure, branch: "none", wantErr: vcs.ErrNoPR}, { name: "azure other branch", remote: azure, @@ -228,29 +233,29 @@ func TestPullRequestErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - r, dir := newRepoEnv(t, env) + r, dir := openTestRepoEnv(t, env) if tt.remote != "" { testgit.Git(t, dir, "remote", "add", "origin", tt.remote) } if tt.branch != "feat" { testgit.Git(t, dir, "checkout", "-qb", tt.branch) } - _, err := r.PullRequest(t.Context(), tt.hosts) + _, err := r.PR(t.Context(), tt.hosts) switch { case err == nil: - t.Error("PullRequest: no error, want an error") + t.Error("PR: no error, want an error") case tt.wantErr != nil && !errors.Is(err, tt.wantErr): - t.Errorf("PullRequest: err = %v, want %v", err, tt.wantErr) + t.Errorf("PR: err = %v, want %v", err, tt.wantErr) case tt.errText != "" && !strings.Contains(err.Error(), tt.errText): - t.Errorf("PullRequest: err = %q, want %q in it", err, tt.errText) + t.Errorf("PR: err = %q, want %q in it", err, tt.errText) } }) } } -// TestPullRequestHostile checks that the text of a pull request has no -// control characters. -func TestPullRequestHostile(t *testing.T) { +// TestPRHostile checks that PR removes control characters, such as an +// escape sequence that sets the terminal title, from every text field. +func TestPRHostile(t *testing.T) { t.Parallel() dir := t.TempDir() const osc = `\u001b]0;owned\u0007` @@ -260,46 +265,84 @@ func TestPullRequestHostile(t *testing.T) { "comments": [{"author": {"login": "bob"}, "body": "x` + osc + `"}], "statusCheckRollup": [{"name": "t` + osc + `", "conclusion": "SUCCESS"}]}`}) r, _ := repoAt(t, testgit.ToolEnv(t, dir), "https://github.com/o/r") - pr, err := r.PullRequest(t.Context(), vcs.Hosts{}) + pr, err := r.PR(t.Context(), vcs.Hosts{}) if err != nil { t.Fatal(err) } if len(pr.Comments) != 1 || len(pr.Checks) != 1 { - t.Fatalf("PullRequest has %d comments and %d checks, want 1 and 1", + t.Fatalf("PR has %d comments and %d checks, want 1 and 1", len(pr.Comments), len(pr.Checks)) } got := []string{pr.Title, pr.Body, pr.Author, pr.Comments[0].Body, pr.Checks[0].Name} want := []string{"Add]0;owned", "a]0;owned\nb", "ann]0;owned", "x]0;owned", "t]0;owned"} if !slices.Equal(got, want) { - t.Errorf("PullRequest texts = %q, want %q", got, want) + t.Errorf("PR texts = %q, want %q", got, want) } } -// TestPullRequestCheckLimit checks that a pull request has at most 100 -// checks, the first that gh gives, as in herdr-reviewr. -func TestPullRequestCheckLimit(t *testing.T) { +// TestPRCheckLimit checks that PR counts every check that gh returns but +// keeps only 100, the failed ones first, in the order gh gives them. +func TestPRCheckLimit(t *testing.T) { t.Parallel() - dir := t.TempDir() - checks := make([]string, 101) - for i := range checks { - checks[i] = fmt.Sprintf(`{"name": "c%d", "conclusion": "SUCCESS"}`, i) - } - fakeTool(t, dir, "gh", map[string]string{ghArgs: `{"number": 7, "title": "T", - "statusCheckRollup": [` + strings.Join(checks, ",") + `]}`}) - r, _ := repoAt(t, testgit.ToolEnv(t, dir), "https://github.com/o/r") - pr, err := r.PullRequest(t.Context(), vcs.Hosts{}) - if err != nil { - t.Fatal(err) + tests := []struct { + name string + pass, fail int + wantTally vcs.Tally + wantFailed int // the number of failed checks kept + wantLast string + }{ + {"101 passing", 101, 0, vcs.Tally{Pass: 101}, 0, "c99"}, + {"100 passing then 50 failing", 100, 50, vcs.Tally{Pass: 100, Fail: 50}, 50, "c149"}, + {"10 passing then 150 failing", 10, 150, vcs.Tally{Pass: 10, Fail: 150}, 100, "c109"}, } - if n := len(pr.Checks); n != 100 || pr.Checks[99].Name != "c99" { - t.Errorf("PullRequest has %d checks %+v, want 100, the last c99", - n, pr.Checks[max(n-3, 0):]) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + var checks []string + for i := range tt.pass + tt.fail { + conclusion := "SUCCESS" + if i >= tt.pass { + conclusion = "FAILURE" + } + checks = append(checks, fmt.Sprintf(`{"name": "c%d", "conclusion": %q}`, i, conclusion)) + } + fakeTool(t, dir, "gh", map[string]string{ghArgs: `{"number": 7, "title": "T", + "statusCheckRollup": [` + strings.Join(checks, ",") + `]}`}) + r, _ := repoAt(t, testgit.ToolEnv(t, dir), "https://github.com/o/r") + pr, err := r.PR(t.Context(), vcs.Hosts{}) + if err != nil { + t.Fatal(err) + } + if pr.Tally != tt.wantTally { + t.Errorf("Tally = %+v, want %+v", pr.Tally, tt.wantTally) + } + n := len(pr.Checks) + if n != 100 || pr.Checks[n-1].Name != tt.wantLast { + t.Fatalf("PR has %d checks %+v, want 100, the last %s", + n, pr.Checks[max(n-3, 0):], tt.wantLast) + } + failed, prev := 0, -1 + for _, c := range pr.Checks { + if c.Result == vcs.ResultFail { + failed++ + } + cur, _ := strconv.Atoi(strings.TrimPrefix(c.Name, "c")) + if cur <= prev { + t.Fatalf("check c%d comes after c%d, want the order gh gives", cur, prev) + } + prev = cur + } + if failed != tt.wantFailed { + t.Errorf("PR keeps %d failed checks, want %d", failed, tt.wantFailed) + } + }) } } -// TestNoPullRequest checks that a gh or a glab that finds no pull request -// gives ErrNoPullRequest. Both exit with 1 and say so. -func TestNoPullRequest(t *testing.T) { +// TestNoPR checks that PR returns ErrNoPR when gh or glab exits with +// status 1 and says that the branch has no pull request. +func TestNoPR(t *testing.T) { t.Parallel() dir := t.TempDir() env := testgit.ToolEnv(t, dir) @@ -307,8 +350,8 @@ func TestNoPullRequest(t *testing.T) { testgit.Script(t, dir, "glab", "echo 'no open merge request available for \"feat\"' >&2\nexit 1\n") for _, remote := range []string{"https://github.com/o/r", "https://gitlab.com/o/r"} { r, _ := repoAt(t, env, remote) - if _, err := r.PullRequest(t.Context(), vcs.Hosts{}); !errors.Is(err, vcs.ErrNoPullRequest) { - t.Errorf("PullRequest(%s) = %v, want ErrNoPullRequest", remote, err) + if _, err := r.PR(t.Context(), vcs.Hosts{}); !errors.Is(err, vcs.ErrNoPR) { + t.Errorf("PR(%s) = %v, want ErrNoPR", remote, err) } } } diff --git a/vcs/refs.go b/vcs/refs.go index 488f2da..500353c 100644 --- a/vcs/refs.go +++ b/vcs/refs.go @@ -13,11 +13,12 @@ import ( "sync/atomic" "time" "unicode" + "unicode/utf8" "github.com/rednafi/rexdiff/internal/proc" ) -// Commit is a commit in the log. +// Commit is a commit in the history of HEAD. type Commit struct { SHA, Short string Subject string @@ -25,28 +26,33 @@ type Commit struct { When string // the relative date, for example "2 hours ago" } -// LastCommits is the number of commits that [Repo.Commits] returns with no -// since. -const LastCommits = 50 +// Limits on the number of commits that [Repo.Commits] returns. +const ( + // LastCommits is the number of commits when since is "". + LastCommits = 50 + // MaxCommits is the most commits when since is not "". + MaxCommits = 1000 +) -// Commits returns the first-parent commits of HEAD after the commit since, -// the newest first, as herdr-reviewr lists them. If since is "", it returns -// the last [LastCommits] commits. A HEAD with no commit yet has no commits. +// Commits returns the first-parent commits of HEAD that come after the +// commit since, newest first, up to [MaxCommits] of them. If since is "", +// it returns the last [LastCommits] commits. If HEAD has no commits yet, +// it returns none. func (r *Repo) Commits(ctx context.Context, since string) ([]Commit, error) { - // The subject is last, because it can have the separator. + // The subject comes last, because it can contain the separator. const ( format = "--format=%H%x1f%h%x1f%ar%x1f%an%x1f%s" nFields = 5 // the number of fields in format ) _, err := r.run(ctx, nil, "rev-parse", "-q", "--verify", "HEAD") - if exitedOne(err) { + if isExitOne(err) { return nil, nil } args := []string{"log", "--first-parent", format} if since == "" { args = append(args, "-n", strconv.Itoa(LastCommits), "--end-of-options", "HEAD") } else { - args = append(args, "--end-of-options", since+"..HEAD") + args = append(args, "-n", strconv.Itoa(MaxCommits), "--end-of-options", since+"..HEAD") } out, err := r.run(ctx, nil, args...) if err != nil { @@ -69,17 +75,17 @@ func (r *Repo) Commits(ctx context.Context, since string) ([]Commit, error) { return cs, nil } -// Branches returns the names of the local branches and of the branches -// of origin, the newest commit first, as herdr-reviewr lists them. A -// branch of origin, for example "origin/main", hides the local branch -// with the same name. +// Branches returns the names of the local branches and of the branches on +// origin, ordered by their latest commit, newest first. A branch on +// origin, for example "origin/main", hides the local branch with the same +// name. func (r *Repo) Branches(ctx context.Context) ([]string, error) { out, err := r.run(ctx, nil, "for-each-ref", "--sort=-committerdate", "--format=%(refname)", "refs/remotes/origin", "refs/heads") if err != nil { return nil, err } - refs := strings.Fields(out) // a ref name has no space + refs := strings.Fields(out) // a ref name cannot contain a space onOrigin := map[string]bool{} for _, ref := range refs { if name, ok := strings.CutPrefix(ref, "refs/remotes/origin/"); ok { @@ -100,31 +106,32 @@ func (r *Repo) Branches(ctx context.Context) ([]string, error) { return bs, nil } -// DefaultBase returns the default branch of the repository. This is the -// target of origin/HEAD if it exists, or the first that exists of main, -// master, trunk, origin/main, and origin/master. If there is none, it -// returns "HEAD". +// DefaultBase returns the default branch of the repository: the branch +// that origin/HEAD points to, or else the first of main, master, trunk, +// origin/main and origin/master that exists. If none exists, it returns +// "HEAD". func (r *Repo) DefaultBase(ctx context.Context) string { if ref, err := r.run(ctx, nil, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"); err == nil { - // fetch --prune can leave the symbolic ref without a target. + // git fetch --prune can delete the branch that the symbolic ref + // points to. ref = strings.TrimSpace(ref) - if _, err := r.Resolve(ctx, ref); err == nil { + if _, err := r.ResolveRev(ctx, ref); err == nil { return ref } } for _, b := range []string{"main", "master", "trunk", "origin/main", "origin/master"} { - if _, err := r.Resolve(ctx, b); err == nil { + if _, err := r.ResolveRev(ctx, b); err == nil { return b } } return "HEAD" } -// Branch returns the name of the branch that HEAD points to. With a -// detached HEAD, it returns "HEAD". +// Branch returns the name of the branch that HEAD points to. If HEAD is +// detached, it returns "HEAD". func (r *Repo) Branch(ctx context.Context) (string, error) { out, err := r.run(ctx, nil, "symbolic-ref", "--quiet", "--short", "HEAD") - if exitedOne(err) { + if isExitOne(err) { return "HEAD", nil } if err != nil { @@ -133,8 +140,8 @@ func (r *Repo) Branch(ctx context.Context) (string, error) { return strings.TrimSpace(out), nil } -// remoteURL returns the URL of the remote "upstream", so that forks work, or -// else of the remote "origin". If there is no remote, it returns "". +// remoteURL returns the URL of the remote "upstream", or of "origin" if +// there is no upstream. If neither remote exists, it returns "". func (r *Repo) remoteURL(ctx context.Context) string { for _, name := range []string{"upstream", "origin"} { if u, err := r.run(ctx, nil, "remote", "get-url", name); err == nil { @@ -144,8 +151,9 @@ func (r *Repo) remoteURL(ctx context.Context) string { return "" } -// Editor returns the editor that git uses (core.editor), or "". -func (r *Repo) Editor(ctx context.Context) string { +// GitEditor returns the editor that git is configured to use +// (core.editor), or "" if it is not set. +func (r *Repo) GitEditor(ctx context.Context) string { out, _ := r.run(ctx, nil, "config", "core.editor") return strings.TrimSpace(out) } @@ -153,24 +161,34 @@ func (r *Repo) Editor(ctx context.Context) string { // Entry is a path in the working tree. type Entry struct { Path string - // Ignored is true if git ignores the path. An ignored directory has one - // entry, with a slash at the end of its path. + // Ignored is true if git ignores the path. An ignored directory appears + // as a single entry whose path ends with a slash. Ignored bool } -// listArgs are the arguments of git ls-files that list the tracked and -// the untracked files. In a merge conflict, git lists a path once for each -// stage. --deduplicate lists it once. -var listArgs = []string{"-co", "--deduplicate", "--exclude-standard"} +// lsFilesArgs are the git ls-files arguments that list the tracked and +// untracked files. --deduplicate lists a file with a merge conflict once, +// not once for each conflict stage. +var lsFilesArgs = []string{"-co", "--deduplicate", "--exclude-standard"} -// Files returns the tracked and the untracked files, and the ignored paths, -// sorted by path. +// Files returns the tracked files, the untracked files and the ignored +// paths, sorted by path. It leaves out tracked files that were deleted +// from the working tree, because there is nothing to show for them; the +// diff still shows the deletion. func (r *Repo) Files(ctx context.Context) ([]Entry, error) { + gone, err := r.lsFiles(ctx, "-d") + if err != nil { + return nil, err + } + deleted := make(map[string]bool, len(gone)) + for _, p := range gone { + deleted[p] = true + } lists := []struct { args []string ignored bool }{ - {listArgs, false}, + {lsFilesArgs, false}, {[]string{"-oi", "--exclude-standard", "--directory"}, true}, } var es []Entry @@ -180,6 +198,9 @@ func (r *Repo) Files(ctx context.Context) ([]Entry, error) { return nil, err } for _, p := range ps { + if !l.ignored && deleted[p] { + continue + } es = append(es, Entry{Path: p, Ignored: l.ignored}) } } @@ -190,10 +211,9 @@ func (r *Repo) Files(ctx context.Context) ([]Entry, error) { } // IgnoredDir returns the entries of the ignored directory dir, sorted by -// name. It reads them from the disk, as herdr-reviewr does, because git -// lists an ignored directory as one path. The path of a directory ends -// with a slash, as in [Repo.Files]. A directory that it cannot read has -// no entries. +// name. It reads them from disk, because git lists an ignored directory as +// a single path. As in [Repo.Files], the path of a directory ends with a +// slash. If it cannot read dir, it returns nil. func (r *Repo) IgnoredDir(dir string) []Entry { des, err := os.ReadDir(filepath.Join(r.root, filepath.FromSlash(dir))) if err != nil { @@ -210,30 +230,30 @@ func (r *Repo) IgnoredDir(dir string) []Entry { return es } -// Match is a line that [Repo.Grep] finds. +// Match is a line that [Repo.Grep] found. type Match struct { Path string - Line int // the line number, from 1 - Text string // the line, without the leading and trailing space + Line int // the line number, starting at 1 + // Text is the line, with leading and trailing white space removed. Of + // a line longer than 512 bytes, it holds only the start. + Text string } -// Grep returns at most limit lines of the tracked and the untracked files -// that contain the text q. The search ignores binary files. As in -// herdr-reviewr, it has smart case. A q with an upper case letter matches -// only the same case. Else the search ignores case. When it finds limit -// lines, it stops git, and more is true if there are others. -// -// After the time budget, Grep stops git when it has a line, and returns -// the lines with more set to true. At the deadline of ctx, Grep stops git -// and returns the lines that it finds before it, with more set to true. +// maxGrepLineBytes is the most bytes of a line that [Repo.Grep] keeps. +const maxGrepLineBytes = 512 + +// Grep returns up to limit lines of the tracked and untracked non-binary +// files that contain the literal text q, ignoring case unless q has an +// upper case letter. After budget, Grep stops once it has found a line, +// and at the deadline of ctx it returns the lines found so far. more is +// true then, or if more lines match than Grep returns. func (r *Repo) Grep(ctx context.Context, q string, limit int, budget time.Duration) (ms []Match, more bool, err error) { if strings.Contains(q, "\n") { - // git reads each line of q as a pattern. A match is one line, so no - // match can have the newline of q. + // A match is a single line, so it cannot contain a newline. return nil, false, nil } - // --no-column overrides the git setting grep.column. That setting adds a - // field to each match. + // --no-column overrides the grep.column setting, which adds a field to + // each match. args := []string{"grep", "-n", "--no-column", "-z", "-I", "-F", "--untracked", "--no-color"} if !strings.ContainsFunc(q, unicode.IsUpper) { args = append(args, "-i") @@ -244,13 +264,13 @@ func (r *Repo) Grep(ctx context.Context, q string, limit int, budget time.Durati return nil, false, err } if err := cmd.Start(); err != nil { - if pastDeadline(ctx) { + if isPastDeadline(ctx) { return nil, true, nil } return nil, false, fmt.Errorf("git grep: %w", proc.Error(ctx, "git", err, nil)) } - // Past the budget, Grep stops git if it has a line, or else at the - // next line. + // Once budget has passed, stop git if it has found a line, or else as + // soon as it finds one. var found, late, killed atomic.Bool timer := time.AfterFunc(budget, func() { late.Store(true) @@ -270,9 +290,9 @@ func (r *Repo) Grep(ctx context.Context, q string, limit int, budget time.Durati } err = cmd.Wait() switch { - case err == nil, more, exitedOne(err): // done, stopped, or no match + case err == nil, more, isExitOne(err): // finished, stopped early, or no match return ms, more, nil - case pastDeadline(ctx): + case isPastDeadline(ctx): return ms, true, nil case ctx.Err() != nil: return nil, false, context.Cause(ctx) @@ -280,28 +300,29 @@ func (r *Repo) Grep(ctx context.Context, q string, limit int, budget time.Durati return nil, false, fmt.Errorf("git grep: %w", proc.Error(ctx, "git", err, stderr.Bytes())) } -// pastDeadline reports whether ctx ends at its deadline. -func pastDeadline(ctx context.Context) bool { +// isPastDeadline reports whether ctx ended because its deadline passed. +func isPastDeadline(ctx context.Context) bool { return errors.Is(ctx.Err(), context.DeadlineExceeded) } -// readMatches reads at most limit matches of "git grep -n -z". Each match -// is "path NUL line NUL text LF". Only the text ends with a newline, -// because the path can have a newline. After each match, a true stop ends -// the read. more is true if there is more output. +// readMatches reads up to limit matches from the output of +// "git grep -n -z". Each match is "path NUL line NUL text LF". Only the +// text is ended by a newline, because the path can contain one. readMatches +// calls stop after each match and returns early if it reports true. more +// is true if there is more output. func readMatches(r *bufio.Reader, limit int, stop func() bool) (ms []Match, more bool) { for len(ms) < limit { path, err := r.ReadString(0) if err != nil { return ms, false } - // A cut at the deadline can end the output in a record. Such a - // record is not a match. + // Stopping git at the deadline can cut off the last record. An + // incomplete record is not a match. num, err := r.ReadString(0) if err != nil { return ms, true } - text, err := r.ReadString('\n') + text, err := readStart(r, maxGrepLineBytes) if err != nil { return ms, true } @@ -319,63 +340,90 @@ func readMatches(r *bufio.Reader, limit int, stop func() bool) (ms []Match, more return ms, err == nil } -// The refs that keep the choices of rexdiff. git keeps a ref under -// refs/worktree/ for each worktree. +// readStart reads up to and including the next newline, and returns the +// first n bytes of what it read, without cutting a UTF-8 character in +// two. It keeps no more than that of a long line in memory. +func readStart(r *bufio.Reader, n int) (string, error) { + var b []byte + for { + chunk, err := r.ReadSlice('\n') + if len(b)+len(chunk) > n { + b = append(b, chunk[:n-len(b)]...) + // Drop the start of a character that the cut leaves incomplete. + k := len(b) - 1 + for k > 0 && len(b)-k < utf8.UTFMax && !utf8.RuneStart(b[k]) { + k-- + } + if k >= 0 && !utf8.FullRune(b[k:]) { + b = b[:k] + } + n = len(b) // keep nothing more of this line + } else { + b = append(b, chunk...) + } + if !errors.Is(err, bufio.ErrBufferFull) { + return string(b), err + } + } +} + +// Refs where rexdiff saves the user's choices. Refs under refs/worktree/ +// are separate for each worktree. const ( baseRef = "refs/worktree/rexdiff/base-pick" turnRef = "refs/worktree/rexdiff/turn-base" ) -// SavedBase returns the base that [Repo.SaveBase] keeps for this worktree. -// It returns "" if no base is saved, or if the saved value is not a ref -// name. +// SavedBase returns the base that [Repo.SaveBase] saved for this worktree. +// It returns "" if no base is saved or if the saved value is not a valid +// ref name. func (r *Repo) SavedBase(ctx context.Context) string { out, err := r.run(ctx, nil, "cat-file", "blob", baseRef) name := strings.TrimSpace(out) - if err != nil || !refShaped(name) { + if err != nil || !isRefName(name) { return "" } return name } -// refShaped reports whether s is one line of printable text that is not a -// git option, as herdr-reviewr checks a saved base. -func refShaped(s string) bool { +// isRefName reports whether s is non-empty printable text with no spaces +// or control characters that git cannot mistake for an option. +func isRefName(s string) bool { return s != "" && !strings.HasPrefix(s, "-") && !strings.ContainsFunc(s, func(c rune) bool { return c <= ' ' || c == 0x7f }) } -// SaveBase keeps name as the base of this worktree. For the default branch -// ([Repo.DefaultBase]), it deletes the saved base. +// SaveBase saves name as the base branch for this worktree. If name is the +// default branch ([Repo.DefaultBase]), it deletes the saved base instead. func (r *Repo) SaveBase(ctx context.Context, name string) error { if name == r.DefaultBase(ctx) { return r.deleteRef(ctx, baseRef) } - if !refShaped(name) { + if !isRefName(name) { return fmt.Errorf("bad base %q", name) } - blob, err := r.runInput(ctx, nil, strings.NewReader(name), + blobID, err := r.runInput(ctx, nil, strings.NewReader(name), "hash-object", "-w", "--stdin") if err != nil { return err } - _, err = r.run(ctx, nil, "update-ref", baseRef, strings.TrimSpace(blob)) + _, err = r.run(ctx, nil, "update-ref", baseRef, strings.TrimSpace(blobID)) return err } -// SavedTurn returns the tree that [Repo.SaveTurn] keeps for this worktree. -// It returns "" if no tree is saved, or if git cannot resolve the ref. +// SavedTurn returns the tree that [Repo.SaveTurn] saved for this worktree. +// It returns "" if no tree is saved or if git cannot resolve the ref. func (r *Repo) SavedTurn(ctx context.Context) string { - tree, err := r.Resolve(ctx, turnRef) + tree, err := r.ResolveRev(ctx, turnRef) if err != nil { return "" } return tree } -// SaveTurn keeps tree as the tree at the start of the last turn in this -// worktree. The ref keeps the tree from git gc. An empty tree deletes the -// saved tree. +// SaveTurn saves tree as the state of this worktree at the start of the +// agent's last turn. The ref stops git gc from deleting the tree. An empty +// tree deletes the saved tree. func (r *Repo) SaveTurn(ctx context.Context, tree string) error { if tree == "" { return r.deleteRef(ctx, turnRef) @@ -384,7 +432,7 @@ func (r *Repo) SaveTurn(ctx context.Context, tree string) error { return err } -// deleteRef deletes ref. A missing ref is no error. +// deleteRef deletes ref. It is not an error if ref does not exist. func (r *Repo) deleteRef(ctx context.Context, ref string) error { _, err := r.run(ctx, nil, "update-ref", "-d", ref) return err diff --git a/vcs/repo.go b/vcs/repo.go index b9e2c6d..f0fd166 100644 --- a/vcs/repo.go +++ b/vcs/repo.go @@ -1,13 +1,10 @@ -// Package vcs reads the changes in a git repository and gets the pull -// request of its branch. +// Package vcs reads the changes in a git repository and finds the pull +// request for its current branch. // -// A [Repo] compares trees. A tree is the ID of a git tree object. -// [Repo.Snapshot] gets a tree of the working tree. [Repo.Diff] gets the files -// that differ between two trees. -// -// [Open] takes the environment of git and of the forge tools as a value, -// as in [exec.Cmd.Env]. The package does not read the environment of the -// process. +// A [Repo] compares trees, identified by their git tree object IDs. +// [Repo.SnapshotTree] records the working tree as a tree object, and +// [Repo.Diff] returns the files that differ between two trees. [Open] +// takes the environment as a value, as in [exec.Cmd.Env]. package vcs import ( @@ -22,9 +19,12 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "slices" "strconv" "strings" + "sync" + "sync/atomic" "time" "github.com/rednafi/rexdiff/internal/proc" @@ -33,61 +33,87 @@ import ( // emptyTree is the ID of the tree that has no files. const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" -// The size limits are the same as in herdr-reviewr. [Repo.Diff] applies -// them to the sum of the two versions of a file. [Repo.ReadFile] applies -// them to one file. +// Size limits for one version of a file. They keep the diff and the view +// of a file fast. [Repo.Diff] applies them to the old and new versions +// separately, and [Repo.ReadFile] to the file it reads. const ( maxFileSize = 2_000_000 // bytes maxLines = 50_000 ) +// Limits for a whole diff. [Repo.Diff] keeps the text of both versions of +// every changed file, so without a limit a large change could fill the +// memory. +const ( + // maxDiffBytes is the most text, old and new versions together, that + // Diff reads. A caller can hold two diffs while it reloads. + maxDiffBytes = 128 << 20 + // maxChangedFiles is the most changed files that Diff returns. + maxChangedFiles = 20_000 +) + var ( // ErrBinary means that a file is binary. ErrBinary = errors.New("binary file") // ErrTooBig means that a file is larger than 2,000,000 bytes or has // more than 50,000 lines. ErrTooBig = errors.New("file too large") + // errDiffTooBig means that the changed files together are larger than + // maxDiffBytes, so [Repo.Diff] does not read this one. It wraps + // ErrTooBig. + errDiffTooBig = fmt.Errorf("changes too large: %w", ErrTooBig) + // errNoCommits means that HEAD does not point to a commit yet, as in a + // new repository. + errNoCommits = errors.New("no commits yet") ) // Repo is a git repository. type Repo struct { root string - index string // the path of the real index - env []string // the environment of git and of the forge tools - - // snapLock is the lock of the private index of Snapshot. A send takes - // the lock, and a receive gives it back. A channel lets Snapshot stop - // its wait when ctx ends. The lock guards snapDir, snapSeed and - // closed. snapDir is the directory of the private index, or "" before - // the first Snapshot. snapSeed is the file status of the real index at - // the last copy. + index string // the path of the repository's real index + env []string // the environment for git and the forge tools + + // snapLock guards the private index of SnapshotTree and the fields + // below it. Sending acquires it and receiving releases it, so a waiting + // caller can give up when its ctx ends. snapLock chan struct{} - snapDir string - snapSeed string + snapDir string // the directory of the private index, or "" before the first snapshot + snapSeed string // the size and modification time of the real index when it was last copied closed bool + + // blobs caches the objects that the last Diff read, keyed by object + // ID. blobMu guards blobs. + blobMu sync.Mutex + blobs map[string]blob + + // maxDiffBytes is the limit that Diff applies. Tests lower it. + maxDiffBytes int } -// errClosed is the error of a Snapshot after Close. +// errClosed is the error that SnapshotTree returns after Close. var errClosed = errors.New("the repository is closed") // Open returns the repository that contains dir. The repository runs git -// and the forge tools with the environment env, and finds them in the -// PATH of env. Open does not change env. +// and the forge tools with the environment env, and looks them up in the +// PATH from env. Open does not modify env. func Open(ctx context.Context, env []string, dir string) (*Repo, error) { - // git gets the root in PWD. PWD must be an absolute path. + // git receives the root directory in PWD, which must be absolute. abs, err := filepath.Abs(dir) if err != nil { return nil, err } - r := &Repo{root: abs, env: env, snapLock: make(chan struct{}, 1)} - // Get each path with its own git process, because a path can have a - // newline. A path can also end with a space, so remove only the last - // newline. + r := &Repo{ + root: abs, env: env, snapLock: make(chan struct{}, 1), + maxDiffBytes: maxDiffBytes, + } + // Ask for each path with a separate git command, because a path can + // contain a newline. A path can also end with a space, so remove only + // the final newline. root, err := r.run(ctx, nil, "rev-parse", "--show-toplevel") if err != nil { return nil, err } - // Resolve the links once, so Contains and Owns compare real paths. + // Resolve symbolic links once, so Contains and Owns compare real paths. r.root = evalSymlinks(strings.TrimSuffix(root, "\n")) index, err := r.run(ctx, nil, "rev-parse", "--path-format=absolute", "--git-path", "index") @@ -98,8 +124,8 @@ func Open(ctx context.Context, env []string, dir string) (*Repo, error) { return r, nil } -// Close removes the files that the repository keeps for [Repo.Snapshot]. -// Close waits for the Snapshot that runs. +// Close removes the temporary files that [Repo.SnapshotTree] creates. If a +// SnapshotTree call is running, Close waits for it to finish. func (r *Repo) Close() error { r.snapLock <- struct{}{} defer func() { <-r.snapLock }() @@ -115,12 +141,12 @@ func (r *Repo) Close() error { func (r *Repo) Root() string { return r.root } // run runs git in the root directory and returns its standard output. env -// adds to the environment of the repository. +// holds extra variables on top of the repository's environment. func (r *Repo) run(ctx context.Context, env []string, args ...string) (string, error) { return r.runInput(ctx, env, nil, args...) } -// runInput is like run. git reads its standard input from in. +// runInput is like run, but git reads its standard input from in. func (r *Repo) runInput(ctx context.Context, env []string, in io.Reader, args ...string) (string, error) { cmd, stderr := r.command(ctx, env, "git", args...) cmd.Stdin = in @@ -131,12 +157,11 @@ func (r *Repo) runInput(ctx context.Context, env []string, in io.Reader, args .. return out, nil } -// command returns the command "name args..." in the root directory. env -// adds to the environment of the repository. The buffer gets the standard -// error. +// command returns a command that runs the program name with args in the +// root directory. env holds extra variables on top of the repository's +// environment. The returned buffer collects the standard error. func (r *Repo) command(ctx context.Context, env []string, name string, args ...string) (*exec.Cmd, *bytes.Buffer) { - // PWD is the directory of the command, as exec sets it for a nil Env. - // A copy keeps r.env as it is. + // Set PWD, as exec does when Env is nil. Concat does not modify r.env. env = slices.Concat(r.env, []string{"PWD=" + r.root}, env) cmd := proc.Command(ctx, env, name, args...) cmd.Dir = r.root @@ -145,10 +170,9 @@ func (r *Repo) command(ctx context.Context, env []string, name string, args ...s return cmd, &stderr } -// output runs cmd and returns its standard output. ctx is the context of -// cmd. The error has the first line of the standard error, or the cause of -// ctx. It wraps the error of the process, so callers can examine its exit -// code. +// output runs cmd and returns its standard output. The error includes the +// first line of standard error, or the cause of ctx if ctx ended. It wraps +// the process error, so callers can check the exit code. func output(ctx context.Context, cmd *exec.Cmd, stderr *bytes.Buffer) (string, error) { out, err := cmd.Output() if err != nil { @@ -157,16 +181,17 @@ func output(ctx context.Context, cmd *exec.Cmd, stderr *bytes.Buffer) (string, e return string(out), nil } -// exitedOne reports whether err is from a process that exited with code 1. -func exitedOne(err error) bool { +// isExitOne reports whether err comes from a process that exited with +// status 1. +func isExitOne(err error) bool { ee, ok := errors.AsType[*exec.ExitError](err) return ok && ee.ExitCode() == 1 } -// Resolve returns the object ID of rev, for example "HEAD" or "main^{tree}". -func (r *Repo) Resolve(ctx context.Context, rev string) (string, error) { +// ResolveRev returns the object ID of rev, for example "HEAD" or "main^{tree}". +func (r *Repo) ResolveRev(ctx context.Context, rev string) (string, error) { out, err := r.run(ctx, nil, "rev-parse", "--verify", "--quiet", rev) - if exitedOne(err) { + if isExitOne(err) { return "", fmt.Errorf("git: unknown revision %q", rev) } if err != nil { @@ -175,35 +200,35 @@ func (r *Repo) Resolve(ctx context.Context, rev string) (string, error) { return strings.TrimSpace(out), nil } -// Tree returns the tree of the commit rev. +// Tree returns the tree ID of the commit rev. func (r *Repo) Tree(ctx context.Context, rev string) (string, error) { - return r.Resolve(ctx, rev+"^{tree}") + return r.ResolveRev(ctx, rev+"^{tree}") } -// HeadTree returns the tree of HEAD. If HEAD is a branch with no commits yet, -// as in a new repository, it returns the empty tree. +// HeadTree returns the tree ID of HEAD. If HEAD is a branch with no +// commits yet, as in a new repository, it returns the empty tree. func (r *Repo) HeadTree(ctx context.Context) (string, error) { t, err := r.Tree(ctx, "HEAD") if err == nil { return t, nil } - // rev-parse exits with 1 if HEAD names no commit yet. A broken HEAD, - // for example one whose commit is missing, is an error. + // rev-parse exits with status 1 if HEAD does not point to a commit yet. + // A broken HEAD, for example one whose commit is missing, is an error. _, rerr := r.run(ctx, nil, "rev-parse", "-q", "--verify", "HEAD") - if exitedOne(rerr) { + if isExitOne(rerr) { return emptyTree, nil } return "", err } -// ParentTree returns the tree of the first parent of the commit rev. If rev has -// no parent, it returns the empty tree. +// ParentTree returns the tree ID of the first parent of the commit rev. +// If rev has no parent, it returns the empty tree. func (r *Repo) ParentTree(ctx context.Context, rev string) (string, error) { - c, err := r.Resolve(ctx, rev+"^{commit}") + c, err := r.ResolveRev(ctx, rev+"^{commit}") if err != nil { return "", err } - // The output has the parents, one on each line. + // The output lists the parents, one per line. out, err := r.run(ctx, nil, "rev-parse", c+"^@") if err != nil { return "", err @@ -215,8 +240,8 @@ func (r *Repo) ParentTree(ctx context.Context, rev string) (string, error) { return r.Tree(ctx, first) } -// MergeBaseTree returns the tree of the commit that [Repo.MergeBaseCommit] -// returns. +// MergeBaseTree returns the tree ID of the commit that +// [Repo.MergeBaseCommit] returns. func (r *Repo) MergeBaseTree(ctx context.Context, base string) (string, error) { c, err := r.MergeBaseCommit(ctx, base) if err != nil { @@ -226,26 +251,28 @@ func (r *Repo) MergeBaseTree(ctx context.Context, base string) (string, error) { } // MergeBaseCommit returns the best common ancestor commit of base and -// HEAD, as git merge-base finds it. If git exits with an error, the error -// wraps the [*exec.ExitError] of git. +// HEAD, as git merge-base finds it. If HEAD has no commits yet, it returns +// an error that says so. If git exits with another error, the error wraps +// the [*exec.ExitError] of git. func (r *Repo) MergeBaseCommit(ctx context.Context, base string) (string, error) { out, err := r.run(ctx, nil, "merge-base", "--end-of-options", base, "HEAD") if err != nil { + // rev-parse exits with status 1 if HEAD does not point to a commit. + if _, rerr := r.run(ctx, nil, "rev-parse", "-q", "--verify", "HEAD"); isExitOne(rerr) { + return "", errNoCommits + } return "", fmt.Errorf("no merge base with %s: %w", base, err) } return strings.TrimSpace(out), nil } -// Snapshot writes the working tree as a tree object and returns its ID. -// The tree has the tracked and the untracked files. It does not have the -// ignored files. It also does not have the files that git cannot add, for -// example a nested repository with no commit. Snapshot uses a private -// index, so the real index does not change. The private index keeps the -// file status of the last Snapshot, also of the untracked files, so git -// reads only the files that change. [Repo.Close] removes it. One Snapshot -// runs at a time. If ctx ends while Snapshot waits for another, -// Snapshot returns the cause of ctx. -func (r *Repo) Snapshot(ctx context.Context) (string, error) { +// SnapshotTree writes the tracked and untracked files in the working tree +// as a tree object and returns its ID. It skips ignored files and files +// that git cannot add, such as a nested repository with no commits. It +// uses a private index, so the real index does not change. [Repo.Close] +// removes the private index. Only one SnapshotTree call runs at a time; if +// ctx ends while a call waits for another, it returns the cause of ctx. +func (r *Repo) SnapshotTree(ctx context.Context) (string, error) { select { case r.snapLock <- struct{}{}: case <-ctx.Done(): @@ -259,12 +286,12 @@ func (r *Repo) Snapshot(ctx context.Context) (string, error) { if err != nil { return "", fmt.Errorf("snapshot: %w", err) } - if err := r.dropIgnored(ctx, env); err != nil { + if err := r.removeIgnored(ctx, env); err != nil { return "", fmt.Errorf("snapshot: %w", err) } _, err = r.run(ctx, env, "add", "-A", "--ignore-errors") - if exitedOne(err) { - err = nil // git cannot add some files, but it adds the others + if isExitOne(err) { + err = nil // git could not add some files, but it added the others } if err != nil { return "", fmt.Errorf("snapshot: %w", err) @@ -276,14 +303,14 @@ func (r *Repo) Snapshot(ctx context.Context) (string, error) { return strings.TrimSpace(out), nil } -// privateIndex returns the environment that points git at the private -// index of Snapshot. It copies the real index when the real index changes, -// for example after a commit or a "git add -f". A clean of the temporary -// directory makes a new directory. The caller must hold snapLock. +// privateIndex returns the environment variable that points git at the +// private index. It copies the real index again whenever the real index +// changes, and creates a new directory if the old one was deleted. The +// caller must hold snapLock. func (r *Repo) privateIndex() ([]string, error) { seed := "" - if st, err := os.Stat(r.index); err == nil { - seed = fmt.Sprint(st.Size(), st.ModTime().UnixNano()) + if info, err := os.Stat(r.index); err == nil { + seed = fmt.Sprint(info.Size(), info.ModTime().UnixNano()) } _, err := os.Stat(r.snapDir) if r.snapDir == "" || err != nil || seed != r.snapSeed { @@ -303,16 +330,16 @@ func (r *Repo) privateIndex() ([]string, error) { return []string{"GIT_INDEX_FILE=" + filepath.Join(r.snapDir, "index")}, nil } -// dropIgnored removes from the private index of env the files that git -// ignores now and that the real index does not have. "add -A" keeps such -// files, for example after an edit of a .gitignore file. -func (r *Repo) dropIgnored(ctx context.Context, env []string) error { +// removeIgnored removes from the private index in env the files that git +// now ignores and that the real index does not track. "git add -A" does +// not remove such files, for example after a .gitignore file changes. +func (r *Repo) removeIgnored(ctx context.Context, env []string) error { ignored := []string{"ls-files", "-z", "-c", "-i", "--exclude-standard"} out, err := r.run(ctx, env, ignored...) if err != nil || out == "" { return err } - tracked, err := r.run(ctx, nil, ignored...) // force-added files stay + tracked, err := r.run(ctx, nil, ignored...) // keep files added with "git add -f" if err != nil { return err } @@ -320,27 +347,27 @@ func (r *Repo) dropIgnored(ctx context.Context, env []string) error { for p := range strings.SplitSeq(tracked, "\x00") { keep[p] = true } - var drop strings.Builder + var removed strings.Builder for p := range strings.SplitSeq(strings.TrimSuffix(out, "\x00"), "\x00") { if !keep[p] { - drop.WriteString(p) - drop.WriteString("\x00") + removed.WriteString(p) + removed.WriteString("\x00") } } - if drop.Len() == 0 { + if removed.Len() == 0 { return nil } - _, err = r.runInput(ctx, env, strings.NewReader(drop.String()), + _, err = r.runInput(ctx, env, strings.NewReader(removed.String()), "rm", "--cached", "-q", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul") return err } // copyIndex copies the real index to dst. If there is no real index, it -// does nothing. The copy keeps the modification time of the index. git -// uses this time to find files that changed in the same second as the -// index. +// does nothing. The copy keeps the modification time of the original, +// because git uses it to detect files that changed in the same second as +// the index was written. func (r *Repo) copyIndex(dst string) error { - st, err := os.Stat(r.index) + info, err := os.Stat(r.index) if errors.Is(err, fs.ErrNotExist) { return nil // a new repository has no index } @@ -354,11 +381,11 @@ func (r *Repo) copyIndex(dst string) error { if err := os.WriteFile(dst, b, 0o600); err != nil { return err } - return os.Chtimes(dst, time.Time{}, st.ModTime()) + return os.Chtimes(dst, time.Time{}, info.ModTime()) } -// FileStatus is the type of change to a file. Its value is the letter that git -// uses, for example 'A'. +// FileStatus is the kind of change to a file. Its value is the status +// letter that git uses, for example 'A'. type FileStatus byte // The values of FileStatus that [Repo.Diff] returns. @@ -368,45 +395,58 @@ const ( StatusDeleted FileStatus = 'D' StatusRenamed FileStatus = 'R' - // statusTypeChange is a change of type, for example from a file to a - // symbolic link. [Repo.Diff] shows it as [StatusModified]. + // statusTypeChange is a change of file type, for example from a regular + // file to a symbolic link. [Repo.Diff] reports it as [StatusModified]. statusTypeChange FileStatus = 'T' ) -// File is a file that is different between two trees. Old and New are the -// two versions of its content. They are empty if the file is binary, too -// big, or not in the tree. For a submodule, each version is one line, -// "Subproject commit ", as git diff shows it. +// File is a file that differs between two trees. OldText and NewText are +// its content in the old and new tree. They are empty if the file is +// binary, too big, or missing from that tree. For a submodule, each +// version is the single line "Subproject commit ". type File struct { - Path string - From string // for a rename, the old path - Status FileStatus - Old, New string - // Skip is ErrBinary if a version of the file has a NUL byte. It is - // ErrTooBig if the two versions together are too big. Diff does not - // read a version larger than 2,000,000 bytes. Thus that version gives - // ErrTooBig, also if it is binary. Else Skip is nil. - Skip error + Path string + OldPath string // the old path of a renamed file + Status FileStatus + OldText, NewText string + // Err tells why OldText and NewText are empty: ErrBinary if a version + // contains a NUL byte, ErrTooBig if a version is too big, and an error + // that wraps ErrTooBig if the files before it already hold too much + // text. Err is nil if the file was read. A version larger than + // 2,000,000 bytes gives ErrTooBig even if it is binary. + Err error } // gitlink is the mode of a submodule in a tree. const gitlink = "160000" -// Diff returns the files that are different between the trees from and to, -// sorted by path. -func (r *Repo) Diff(ctx context.Context, from, to string) ([]File, error) { +// TooManyFilesError is the error that [Repo.Diff] returns when more than +// 20,000 files differ. +type TooManyFilesError struct { + Count int // the number of files that differ +} + +// Error returns the number of files and says that they are too many. +func (e *TooManyFilesError) Error() string { + return fmt.Sprintf("%d files changed, too many to show", e.Count) +} + +// Diff returns the files that differ between oldTree and newTree, sorted +// by path. If more than 20,000 files differ, it reads none of them and +// returns a [*TooManyFilesError]. +func (r *Repo) Diff(ctx context.Context, oldTree, newTree string) ([]File, error) { out, err := r.run(ctx, nil, "diff", "--raw", "-z", "--no-abbrev", "--find-renames", - "--end-of-options", from, to) + "--end-of-options", oldTree, newTree) if err != nil { return nil, err } - // Each file is ": ", - // then its path. A rename has its old and new paths. + // Each file is ": " + // followed by its path. A rename is followed by the old and new paths. fields := strings.Split(strings.TrimSuffix(out, "\x00"), "\x00") var files []File - var ids []string // the old and the new object of each file, or "" for none - var blobs []blob // the content of each ID + var ids []string // the old and new object ID of each file, or "" if there is none + var blobs []blob // the content for each entry of ids for len(fields) >= 2 { meta := strings.Fields(strings.TrimPrefix(fields[0], ":")) if len(meta) != 5 { @@ -418,35 +458,39 @@ func (r *Repo) Diff(ctx context.Context, from, to string) ([]File, error) { } fields = fields[2:] if f.Status == StatusRenamed && len(fields) > 0 { - f.From, f.Path, fields = f.Path, fields[0], fields[1:] + f.OldPath, f.Path, fields = f.Path, fields[0], fields[1:] } files = append(files, f) + if len(files) > maxChangedFiles { + return nil, &TooManyFilesError{Count: len(files) + countRecords(fields)} + } for k := range 2 { // the old side, then the new side id, b := diffSide(meta[k], meta[2+k]) ids = append(ids, id) blobs = append(blobs, b) } } - if err := r.readBlobs(ctx, ids, blobs); err != nil { + if err := r.readLimited(ctx, ids, blobs); err != nil { return nil, err } + r.keepCache(ids, blobs) for i := range files { f := &files[i] - f.Old, f.New = f.use(blobs[2*i]), f.use(blobs[2*i+1]) - if f.Skip == nil && isTooBig(f.Old, f.New) { - f.Skip = ErrTooBig + f.OldText, f.NewText = f.use(blobs[2*i]), f.use(blobs[2*i+1]) + if f.Err == nil && isTooBig(f.OldText, f.NewText) { + f.Err = ErrTooBig } - if f.Skip != nil { - f.Old, f.New = "", "" + if f.Err != nil { + f.OldText, f.NewText = "", "" } } slices.SortFunc(files, func(a, b File) int { return strings.Compare(a.Path, b.Path) }) return files, nil } -// diffSide returns the object to read for one side of a diff, from its -// mode and its ID. It returns "" if there is no object to read. Then the -// blob has the content, if any. +// diffSide returns the ID of the object to read for one side of a diff, +// given the mode and ID from git diff. If there is no object to read, it +// returns "" and a blob that already holds the content, if any. func diffSide(mode, id string) (string, blob) { switch { case strings.Trim(id, "0") == "": // this side has no file @@ -458,37 +502,225 @@ func diffSide(mode, id string) (string, blob) { } // use returns the content of b. If b is binary or too big, use sets -// f.Skip. A binary side wins over a side that is too big. +// f.Err. ErrBinary takes priority over ErrTooBig. func (f *File) use(b blob) string { - if f.Skip == nil || errors.Is(b.err, ErrBinary) { - f.Skip = cmp.Or(b.err, f.Skip) + if f.Err == nil || errors.Is(b.err, ErrBinary) { + f.Err = cmp.Or(b.err, f.Err) } return b.data } // blob is the content of an object. If the object is binary or too big, -// data is empty and err is ErrBinary or ErrTooBig. +// data is empty and err is ErrBinary or ErrTooBig. read is true if git +// returned the object, and false if the object is missing or was not +// read. type blob struct { data string err error + read bool + size int // the size of the object, if read } -// readBlobs reads the objects with the given IDs into blobs[i] for each -// ids[i], with one git process. It skips an empty ID. It does not keep the -// content of an object that is larger than maxFileSize. -func (r *Repo) readBlobs(ctx context.Context, ids []string, blobs []blob) error { - var batch []int // the indices of the objects to read +// kept returns the number of bytes that b counts for in the limit of a +// diff: the size of the object, or 0 if it is too big to keep. +func (b blob) kept() int { + if b.size > maxFileSize { + return 0 + } + return b.size +} + +// countRecords returns the number of files in the rest of the output of +// git diff --raw -z, split at NUL bytes. Each file is a field of metadata +// followed by its path, or by two paths for a rename or a copy. It reads +// the fields in that order rather than looking for the ':' that starts the +// metadata, because a path can also start with ':'. +func countRecords(fields []string) int { + n := 0 + for len(fields) > 0 { + paths := 1 + if meta := strings.Fields(fields[0]); len(meta) == 5 && + (meta[4][0] == byte(StatusRenamed) || meta[4][0] == 'C') { + paths = 2 + } + fields = fields[min(1+paths, len(fields)):] + n++ + } + return n +} + +// fromCache copies into blobs[i] the object ids[i] if the previous Diff +// read it. It returns the IDs still to read, with "" for the others. +func (r *Repo) fromCache(ids []string, blobs []blob) (fresh []string) { + r.blobMu.Lock() + defer r.blobMu.Unlock() + fresh = slices.Clone(ids) + for i, id := range ids { + if b, ok := r.blobs[id]; ok && id != "" { + blobs[i], fresh[i] = b, "" + } + } + return fresh +} + +// keepCache makes the cache hold the objects of ids that were read, and +// no others. +func (r *Repo) keepCache(ids []string, blobs []blob) { + next := make(map[string]blob, len(ids)) + for i, id := range ids { + if id != "" && blobs[i].read { + next[id] = blobs[i] + } + } + r.blobMu.Lock() + r.blobs = next + r.blobMu.Unlock() +} + +// readLimited is like readBlobs, but it reuses the objects that the +// previous call read, and keeps the text within r.maxDiffBytes. blobs +// holds the old and new version of each file in turn. If the objects do +// not fit, limitTotal chooses the files to leave out, so that they are +// the same each time, and readLimited reads the rest. +func (r *Repo) readLimited(ctx context.Context, ids []string, blobs []blob) error { + fresh := r.fromCache(ids, blobs) + var left atomic.Int64 // the bytes that the fresh objects can take + left.Store(int64(r.maxDiffBytes)) + for _, b := range blobs { + left.Add(-int64(b.kept())) + } + err := errNoRoom // the objects from the cache can already be too many + if left.Load() >= 0 { + err = r.readBlobs(ctx, fresh, blobs, &left) + } + if !errors.Is(err, errNoRoom) { + return err + } + for i := range fresh { + if blobs[i].read { + fresh[i] = "" // read before there was no room + } + } + if err := r.limitTotal(ctx, fresh, blobs); err != nil { + return err + } + return r.readBlobs(ctx, fresh, blobs, nil) +} + +// limitTotal keeps the text that Diff holds within r.maxDiffBytes. blobs +// holds the old and new version of each file in turn, and fresh the IDs +// that are still to be read. Going through the files in order, limitTotal +// gives both versions of each file that does not fit the error +// errDiffTooBig, and removes them from fresh so that they are not read. +// It asks git for the sizes of the fresh objects. +func (r *Repo) limitTotal(ctx context.Context, fresh []string, blobs []blob) error { + sizes, err := r.sizes(ctx, fresh) + if err != nil { + return err + } + total := 0 + for i := 0; i+1 < len(blobs); i += 2 { + size := 0 + for k := i; k < i+2; k++ { + size += blobs[k].kept() + if sizes[k] <= maxFileSize { // a larger object is not kept + size += sizes[k] + } + } + if total+size <= r.maxDiffBytes { + total += size + continue + } + for k := i; k < i+2; k++ { + blobs[k], fresh[k] = blob{err: errDiffTooBig}, "" + } + } + return nil +} + +// sizes returns the size in bytes of each object ids[i], or 0 if ids[i] +// is "" or the object is missing. +func (r *Repo) sizes(ctx context.Context, ids []string) ([]int, error) { + sizes := make([]int, len(ids)) var in strings.Builder + var asked []int // the indices of the objects asked for for i, id := range ids { if id != "" { - batch = append(batch, i) in.WriteString(id) in.WriteString("\n") + asked = append(asked, i) + } + } + if len(asked) == 0 { + return sizes, nil + } + out, err := r.runInput(ctx, nil, strings.NewReader(in.String()), "cat-file", "--batch-check") + if err != nil { + return nil, err + } + // Each line is " ", or " missing". + lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n") + if len(lines) != len(asked) { + return nil, fmt.Errorf("git cat-file: %d lines for %d objects", len(lines), len(asked)) + } + for k, l := range lines { + if f := strings.Fields(l); len(f) == 3 { + sizes[asked[k]], _ = strconv.Atoi(f[2]) } } + return sizes, nil +} + +// errNoRoom means that the objects did not fit in the bytes left for +// them. +var errNoRoom = errors.New("no room left for the objects") + +// readBlobs reads the object ids[i] into blobs[i] for each non-empty +// ids[i]. It does not keep the content of an object larger than +// maxFileSize. If left is not nil, each object takes its size from left, +// and readBlobs fails with errNoRoom once left would fall below zero. It +// splits a large batch across several git processes, because +// decompressing objects is slow. +func (r *Repo) readBlobs(ctx context.Context, ids []string, blobs []blob, left *atomic.Int64) error { + var batch []int // the indices of the objects to read + for i, id := range ids { + if id != "" { + batch = append(batch, i) + } + } + n := min(runtime.GOMAXPROCS(0), maxCatFiles, len(batch)/minCatFileBatch) + if n <= 1 { + return r.catFile(ctx, ids, batch, blobs, left) + } + errs := make([]error, n) + var wg sync.WaitGroup + for k := range n { + part := batch[k*len(batch)/n : (k+1)*len(batch)/n] + wg.Go(func() { errs[k] = r.catFile(ctx, ids, part, blobs, left) }) + } + wg.Wait() + return errors.Join(errs...) +} + +// Limits for the parallel reads in readBlobs. Each git process reads at +// least minCatFileBatch objects, and at most maxCatFiles processes run at +// once. +const ( + minCatFileBatch = 128 + maxCatFiles = 8 +) + +// catFile reads the object ids[i] into blobs[i] for each i in batch, +// using one git process. left is as in readBlobs. +func (r *Repo) catFile(ctx context.Context, ids []string, batch []int, blobs []blob, left *atomic.Int64) error { if len(batch) == 0 { return nil } + var in strings.Builder + for _, i := range batch { + in.WriteString(ids[i]) + in.WriteString("\n") + } cmd, stderr := r.command(ctx, nil, "git", "cat-file", "--batch") cmd.Stdin = strings.NewReader(in.String()) out, err := cmd.StdoutPipe() @@ -498,7 +730,7 @@ func (r *Repo) readBlobs(ctx context.Context, ids []string, blobs []blob) error if err := cmd.Start(); err != nil { return fmt.Errorf("git cat-file: %w", proc.Error(ctx, "git", err, nil)) } - err = readBatch(bufio.NewReader(out), batch, blobs) + err = readBatch(bufio.NewReader(out), batch, blobs, left) if err != nil { _ = cmd.Process.Kill() } @@ -511,87 +743,99 @@ func (r *Repo) readBlobs(ctx context.Context, ids []string, blobs []blob) error return nil } -// errShort is the error of output that ends too soon. +// errShort means that the output of git cat-file ended too early. var errShort = errors.New("short output") // readBatch reads the output of "git cat-file --batch" into blobs[i] for -// each i in batch. For each object, the output has -// " \n\n", or one line that ends with "missing" -// or "ambiguous". -func readBatch(r *bufio.Reader, batch []int, blobs []blob) error { +// each i in batch. For each object, the output is either +// " \n\n" or a single line that ends with +// "missing" or "ambiguous". left is as in readBlobs. +func readBatch(r *bufio.Reader, batch []int, blobs []blob, left *atomic.Int64) error { + buf := make([]byte, 32<<10) // the copy buffer, reused for every object for _, i := range batch { hdr, err := r.ReadString('\n') if err != nil { return errShort } f := strings.Fields(hdr) - if len(f) != 3 { // " missing" + if len(f) != 3 { // " missing" or " ambiguous" continue } n, err := strconv.Atoi(f[2]) if err != nil { - return fmt.Errorf("bad size %q", f[2]) + return fmt.Errorf("bad size %q: %w", f[2], err) } if f[1] != "blob" || n > maxFileSize { if _, err := r.Discard(n + 1); err != nil { return errShort } if f[1] == "blob" { - blobs[i].err = ErrTooBig + blobs[i] = blob{err: ErrTooBig, read: true, size: n} } continue } - b := make([]byte, n+1) - if _, err := io.ReadFull(r, b); err != nil { + if left != nil && left.Add(-int64(n)) < 0 { + return errNoRoom + } + // Read the content straight into the string, without an extra copy. + var b strings.Builder + b.Grow(n) + if k, _ := io.CopyBuffer(&b, io.LimitReader(r, int64(n)), buf); k != int64(n) { return errShort } - if s := string(b[:n]); isBinary(s) { + if _, err := r.Discard(1); err != nil { // the newline after the content + return errShort + } + if s := b.String(); isBinary(s) { blobs[i].err = ErrBinary } else { blobs[i].data = s } + blobs[i].read, blobs[i].size = true, n } return nil } -// isBinary reports whether s has a NUL byte. +// isBinary reports whether s contains a NUL byte. func isBinary(s string) bool { return strings.Contains(s, "\x00") } -// isTooBig reports whether the versions together are larger than -// maxFileSize bytes or have more than maxLines lines. A last line without -// a newline counts as a line, as in diff.Lines. +// isTooBig reports whether any of the versions is larger than maxFileSize +// bytes or has more than maxLines lines. A final line without a newline +// counts as a line, as in diff.Lines. func isTooBig(versions ...string) bool { - size, lines := 0, 0 for _, s := range versions { - size += len(s) - lines += strings.Count(s, "\n") + lines := strings.Count(s, "\n") if s != "" && !strings.HasSuffix(s, "\n") { lines++ } + if len(s) > maxFileSize || lines > maxLines { + return true + } } - return size > maxFileSize || lines > maxLines + return false } // ReadFile reads the file at path in the working tree. The path is relative -// to the root. ReadFile reads only a regular file in the root, also through -// a symbolic link. For another file, for example a directory or a named -// pipe, it returns an error. If the file is binary or too big, ReadFile -// returns ErrBinary or ErrTooBig. +// to the root. ReadFile reads only regular files inside the root, including +// through symbolic links that stay inside it. For anything else, such as a +// directory or a named pipe, it returns an error. If the file is binary or +// too big, ReadFile returns ErrBinary or ErrTooBig. func (r *Repo) ReadFile(path string) (string, error) { root, err := os.OpenRoot(r.root) if err != nil { return "", err } defer func() { _ = root.Close() }() - st, err := root.Stat(path) + info, err := root.Stat(path) if err != nil { return "", err } - // Do not read a device or a named pipe. The read can block or not end. - if !st.Mode().IsRegular() { + // Do not read a device or a named pipe, because the read can block or + // never end. + if !info.Mode().IsRegular() { return "", fmt.Errorf("%q is not a regular file", path) } - if st.Size() > maxFileSize { + if info.Size() > maxFileSize { return "", ErrTooBig } b, err := root.ReadFile(path) @@ -609,27 +853,27 @@ func (r *Repo) ReadFile(path string) (string, error) { } // Contains reports whether dir is the root or a directory below it. It -// follows symbolic links, so /tmp and /private/tmp on macOS are the same. -// Contains does not run git, thus it does not see a nested repository. +// follows symbolic links, so on macOS /tmp and /private/tmp are the same. +// Contains does not run git, so it does not detect nested repositories. func (r *Repo) Contains(dir string) bool { dir = evalSymlinks(dir) return dir == r.root || strings.HasPrefix(dir, r.root+string(filepath.Separator)) } -// Owns reports whether dir is in the working tree of r, and not in another -// repository or worktree below the root. As in herdr-reviewr, a relative -// dir is not in it. Owns runs git only if [Repo.Contains] reports true. -// An error means that git cannot run, and the answer is not known. +// Owns reports whether dir is in the working tree of r and not in another +// repository or worktree below the root. It reports false for a relative +// dir. Owns runs git only if [Repo.Contains] reports true. An error means +// that git could not run, so the answer is unknown. func (r *Repo) Owns(ctx context.Context, dir string) (bool, error) { if !filepath.IsAbs(dir) || !r.Contains(dir) { return false, nil } cmd, stderr := r.command(ctx, nil, "git", "-C", dir, "rev-parse", "--show-toplevel") out, err := output(ctx, cmd, stderr) - // A git that a signal stops, for example at the end of ctx, does not - // answer. + // If a signal killed git, for example because ctx ended, the answer is + // unknown. Only a normal exit means that dir is not in a working tree. if ee, ok := errors.AsType[*exec.ExitError](err); ok && ee.Exited() { - return false, nil // dir is in no working tree + return false, nil // dir is not in any working tree } if err != nil { return false, fmt.Errorf("git rev-parse: %w", err) @@ -638,8 +882,8 @@ func (r *Repo) Owns(ctx context.Context, dir string) (bool, error) { return top != "" && evalSymlinks(top) == r.root, nil } -// evalSymlinks returns p with its symbolic links resolved, or p if that -// fails. +// evalSymlinks returns p with its symbolic links resolved, or p unchanged +// if that fails. func evalSymlinks(p string) string { if q, err := filepath.EvalSymlinks(p); err == nil { return q diff --git a/vcs/repo_test.go b/vcs/repo_test.go index b98804d..5f0d0ab 100644 --- a/vcs/repo_test.go +++ b/vcs/repo_test.go @@ -12,15 +12,17 @@ import ( "testing" "testing/fstest" "time" + "unicode/utf8" "github.com/rednafi/rexdiff/internal/proc" "github.com/rednafi/rexdiff/internal/testgit" "github.com/rednafi/rexdiff/vcs" ) -// testRepo is the repository of newRepo. It has two commits: "init" on -// main and "b" on the branch feat. The working tree has uncommitted -// changes, an untracked file, and an ignored file. +// testRepo is the fixture repository that openTestRepo copies. It has two +// commits: "init" on main and "b" on the branch feat, which is checked +// out. The working tree has an uncommitted edit, an untracked file, and +// ignored files. var testRepo = testgit.NewFixture(func(tb testing.TB, dir string) { tb.Helper() testgit.InitRepo(tb, dir) @@ -42,32 +44,33 @@ var testRepo = testgit.NewFixture(func(tb testing.TB, dir string) { testgit.WriteFile(tb, dir, "build/out", "x\n") }) -// newRepo copies testRepo to a new directory, and returns the repository -// and its directory. The repository has the environment of +// openTestRepo copies testRepo to a new directory and returns the opened +// repository and its directory. The repository uses the environment from // [testgit.ToolEnv]. -func newRepo(t *testing.T) (*vcs.Repo, string) { +func openTestRepo(t *testing.T) (*vcs.Repo, string) { t.Helper() - return newRepoEnv(t, testgit.ToolEnv(t)) + return openTestRepoEnv(t, testgit.ToolEnv(t)) } -// newRepoEnv is like newRepo, but the repository has the environment env. -func newRepoEnv(t *testing.T, env []string) (*vcs.Repo, string) { +// openTestRepoEnv is like openTestRepo, but the repository uses the +// environment env. +func openTestRepoEnv(t *testing.T, env []string) (*vcs.Repo, string) { t.Helper() - dir := filepath.Join(t.TempDir(), "a repo ") // git keeps the space + dir := filepath.Join(t.TempDir(), "a repo ") // the trailing space must survive testRepo.Copy(t, dir) - // Open from a subdirectory. Open must find the root. + // Open a subdirectory, from which Open must find the root. return openEnv(t, filepath.Join(dir, "build"), env), dir } -// open opens the repository at dir with the environment of -// [testgit.ToolEnv], and closes it at the end of the test. +// open opens the repository at dir with the environment from +// [testgit.ToolEnv], and closes it when the test ends. func open(t *testing.T, dir string) *vcs.Repo { t.Helper() return openEnv(t, dir, testgit.ToolEnv(t)) } -// openEnv is like open, but the repository has the environment env. +// openEnv is like open, but the repository uses the environment env. func openEnv(t *testing.T, dir string, env []string) *vcs.Repo { t.Helper() r, err := vcs.Open(t.Context(), env, dir) @@ -78,9 +81,9 @@ func openEnv(t *testing.T, dir string, env []string) *vcs.Repo { return r } -// gitLink returns an environment with a PATH of only a new directory, and -// the link to git in that directory. A test removes the link to take git -// away from a repository that is open. +// gitLink returns an environment whose PATH holds only a new directory, +// and the path of a symbolic link to git in that directory. A test removes +// the link to make git unavailable to a repository that is already open. func gitLink(t *testing.T) (env []string, link string) { t.Helper() bin := t.TempDir() @@ -88,7 +91,7 @@ func gitLink(t *testing.T) (env []string, link string) { return append(testgit.Env(), "PATH="+bin), filepath.Join(bin, "git") } -// removeGit removes the link to git of gitLink. +// removeGit removes the link to git that gitLink created. func removeGit(t *testing.T, link string) { t.Helper() if err := os.Remove(link); err != nil { @@ -96,17 +99,17 @@ func removeGit(t *testing.T, link string) { } } -// snapshot returns the tree of the working tree. +// snapshot returns the ID of a tree that records the working tree. func snapshot(t *testing.T, r *vcs.Repo) string { t.Helper() - tree, err := r.Snapshot(t.Context()) + tree, err := r.SnapshotTree(t.Context()) if err != nil { t.Fatal(err) } return tree } -// head returns the tree of HEAD. +// head returns the ID of the tree of HEAD. func head(t *testing.T, r *vcs.Repo) string { t.Helper() tree, err := r.HeadTree(t.Context()) @@ -116,7 +119,7 @@ func head(t *testing.T, r *vcs.Repo) string { return tree } -// parent returns the tree of the parent of rev. +// parent returns the tree ID of the first parent of rev. func parent(t *testing.T, r *vcs.Repo, rev string) string { t.Helper() tree, err := r.ParentTree(t.Context(), rev) @@ -126,18 +129,18 @@ func parent(t *testing.T, r *vcs.Repo, rev string) string { return tree } -// changes returns the files that differ from the tree from to the tree to. -func changes(t *testing.T, r *vcs.Repo, from, to string) []vcs.File { +// changes returns the files that differ between oldTree and newTree. +func changes(t *testing.T, r *vcs.Repo, oldTree, newTree string) []vcs.File { t.Helper() - fs, err := r.Diff(t.Context(), from, to) + fs, err := r.Diff(t.Context(), oldTree, newTree) if err != nil { t.Fatal(err) } return fs } -// names returns the status and the path of each file, for example -// "Aa.txt Mb.txt". +// names returns the status letter and path of each file, separated by +// spaces, for example "Aa.txt Mb.txt". func names(fs []vcs.File) string { var n []string for _, f := range fs { @@ -146,14 +149,15 @@ func names(fs []vcs.File) string { return strings.Join(n, " ") } -// uncommitted returns the names of the files that differ from HEAD to the -// working tree. +// uncommitted returns, in the format of names, the files that differ +// between HEAD and the working tree. func uncommitted(t *testing.T, r *vcs.Repo) string { t.Helper() return names(changes(t, r, head(t, r), snapshot(t, r))) } -// paths returns the path of each entry. An ignored path starts with "!". +// paths returns the path of each entry, with "!" in front of ignored +// paths. func paths(es []vcs.Entry) []string { var out []string for _, e := range es { @@ -166,20 +170,23 @@ func paths(es []vcs.Entry) []string { return out } -// TestScopes checks the trees that each scope of a review compares. +// TestScopes checks the changes that each review scope shows: uncommitted +// changes, the branch since its merge base, the last agent turn, and a +// single commit. It also checks that a snapshot does not change the real +// index. func TestScopes(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) ctx := t.Context() now := snapshot(t, r) unc := changes(t, r, head(t, r), now) if got := names(unc); got != "A.gitignore Ma.txt Anew.txt" { - t.Fatalf("uncommitted: %s, want A.gitignore Ma.txt Anew.txt", got) + t.Fatalf("Diff(HEAD, snapshot) = %s, want A.gitignore Ma.txt Anew.txt", got) } - if unc[1].Old != "one\n" || unc[1].New != "one\ntwo\n" { - t.Errorf("a.txt: Old %q, New %q, want %q, %q", - unc[1].Old, unc[1].New, "one\n", "one\ntwo\n") + if unc[1].OldText != "one\n" || unc[1].NewText != "one\ntwo\n" { + t.Errorf("Diff(HEAD, snapshot) for a.txt: Old %q, New %q, want %q, %q", + unc[1].OldText, unc[1].NewText, "one\n", "one\ntwo\n") } mb, err := r.MergeBaseTree(ctx, "main") @@ -187,21 +194,22 @@ func TestScopes(t *testing.T) { t.Fatal(err) } if got := names(changes(t, r, mb, now)); got != "A.gitignore Ma.txt Ab.txt Anew.txt" { - t.Errorf("branch: %s, want A.gitignore Ma.txt Ab.txt Anew.txt", got) + t.Errorf("Diff(merge base, snapshot) = %s, want A.gitignore Ma.txt Ab.txt Anew.txt", got) } _, err = r.MergeBaseTree(ctx, "nosuchbranch") if _, ok := errors.AsType[*exec.ExitError](err); !ok { t.Errorf("MergeBaseTree(nosuchbranch) = %v, want the error of git", err) } - // In a turn, rexdiff takes a snapshot and then the agent edits a file. + // At the start of a turn, rexdiff takes a snapshot, and then the agent + // edits a file. testgit.WriteFile(t, dir, "new.txt", "untracked\nmore\n") tf := changes(t, r, now, snapshot(t, r)) - if got := names(tf); got != "Mnew.txt" || tf[0].New != "untracked\nmore\n" { - t.Errorf("turn: %s, want Mnew.txt with the new line", got) + if got := names(tf); got != "Mnew.txt" || tf[0].NewText != "untracked\nmore\n" { + t.Errorf("Diff(turn start, snapshot) = %s, want Mnew.txt with the new line", got) } - // Snapshot must not change the real index. + // SnapshotTree must not change the real index. st := testgit.Git(t, dir, "status", "--porcelain") if !strings.Contains(st, "?? new.txt") { t.Errorf("git status after Snapshot = %q, want ?? new.txt", st) @@ -216,7 +224,7 @@ func TestScopes(t *testing.T) { t.Fatal(err) } if got := names(changes(t, r, parent(t, r, cs[0].SHA), tree)); got != "Ab.txt" { - t.Errorf("commit b: %s, want Ab.txt", got) + t.Errorf("Diff(parent of b, b) = %s, want Ab.txt", got) } // emptyTree is the ID of the tree that has no files. const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" @@ -225,12 +233,13 @@ func TestScopes(t *testing.T) { } } -// TestMissingCommit checks that HeadTree and ParentTree fail when a commit -// object is missing. +// TestMissingCommit checks that HeadTree and ParentTree return an error, +// not the empty tree, when a commit object is missing. func TestMissingCommit(t *testing.T) { t.Parallel() - // The empty tree is not a correct result. It shows every file as added. - r, dir := newRepo(t) + // Returning the empty tree would be wrong, because then every file + // would appear as added. + r, dir := openTestRepo(t) ctx := t.Context() cs, err := r.Commits(ctx, "") if err != nil || len(cs) != 2 { @@ -256,31 +265,70 @@ func TestMissingCommit(t *testing.T) { } } -// TestDiffSkipsBinaryAndBigFiles checks that Diff and ReadFile mark binary -// and big files, and do not read their content. The limits are the same as -// in herdr-reviewr. Diff adds the sizes of the two versions. +// TestDiffManyFiles checks that Diff returns the right content for enough +// files to need several git processes, and that a second Diff returns the +// new content of a file edited in between. +func TestDiffManyFiles(t *testing.T) { + t.Parallel() + r, dir := openTestRepo(t) + const n = 600 + name := func(i int) string { return fmt.Sprintf("many/f%03d.txt", i) } + for i := range n { + testgit.WriteFile(t, dir, name(i), fmt.Sprintf("old %d\n", i)) + } + testgit.Git(t, dir, "add", ".") + testgit.Git(t, dir, "commit", "-qm", "many") + for i := range n { + testgit.WriteFile(t, dir, name(i), fmt.Sprintf("new %d\n", i)) + } + check := func(edited string) { + t.Helper() + got := map[string]vcs.File{} + for _, f := range changes(t, r, head(t, r), snapshot(t, r)) { + got[f.Path] = f + } + for i := range n { + want := fmt.Sprintf("new %d\n", i) + if i == 7 && edited != "" { + want = edited + } + if f := got[name(i)]; f.OldText != fmt.Sprintf("old %d\n", i) || f.NewText != want { + t.Fatalf("%s: Old %q, New %q, want %q", name(i), f.OldText, f.NewText, want) + } + } + } + check("") + testgit.WriteFile(t, dir, name(7), "edited\n") + check("edited\n") +} + +// TestDiffSkipsBinaryAndBigFiles checks that Diff and ReadFile report +// binary and oversized files with ErrBinary and ErrTooBig and drop their +// content, and that the limits apply to each version separately. func TestDiffSkipsBinaryAndBigFiles(t *testing.T) { t.Parallel() - r, dir := newRepo(t) - half := strings.Repeat("x", 1_100_000) + "\n" // two make 2,200,002 bytes - lines := strings.Repeat("x\n", 30_000) // two make 60,000 lines - // Two versions with 25,000 lines each are not too big. - limit := strings.Repeat("y\n", 25_000) - testgit.WriteFile(t, dir, "bytes", half) + r, dir := openTestRepo(t) + full := strings.Repeat("x", 1_999_999) + "\n" // exactly at the byte limit + lines := strings.Repeat("x\n", 50_000) // exactly at the line limit + // A file whose old and new versions are both at the limit is not too + // big. + limit := strings.Repeat("y\n", 50_000) + testgit.WriteFile(t, dir, "bytes", full) testgit.WriteFile(t, dir, "lines", lines) testgit.WriteFile(t, dir, "limit", limit) testgit.Git(t, dir, "add", ".") testgit.Git(t, dir, "commit", "-qm", "base") - testgit.WriteFile(t, dir, "bytes", "y"+half) - testgit.WriteFile(t, dir, "lines", "y"+lines) - testgit.WriteFile(t, dir, "limit", "z"+limit) + testgit.WriteFile(t, dir, "bytes", "y"+full) + testgit.WriteFile(t, dir, "lines", "y\n"+lines) + testgit.WriteFile(t, dir, "limit", "z"+limit[2:]) err := os.CopyFS(dir, fstest.MapFS{ "bin": {Data: []byte("a\x00b")}, - // The NUL of "late" is at byte 9000. + // The NUL byte in "late" is at offset 9000. "late": {Data: []byte(strings.Repeat("x", 9000) + "\x00")}, "big": {Data: []byte(strings.Repeat("x\n", 50_001))}, "huge": {Data: []byte(strings.Repeat("x", 2_000_001))}, - // Diff reads this file after the skipped content. + // Diff reads this file after the skipped ones, so skipping must not + // break the reads that follow. "small": {Data: []byte("after\n")}, }) if err != nil { @@ -291,29 +339,31 @@ func TestDiffSkipsBinaryAndBigFiles(t *testing.T) { got[f.Path] = f } for _, p := range []string{"bin", "late"} { - if f := got[p]; !errors.Is(f.Skip, vcs.ErrBinary) || f.New != "" { - t.Errorf("%s: %+v, want Skip ErrBinary with no New", p, f) + if f := got[p]; !errors.Is(f.Err, vcs.ErrBinary) || f.NewText != "" { + t.Errorf("%s: %+v, want Err ErrBinary with no New", p, f) } } for _, p := range []string{"bytes", "lines", "big", "huge"} { - if f := got[p]; !errors.Is(f.Skip, vcs.ErrTooBig) || f.Old != "" || f.New != "" { - t.Errorf("%s: Skip = %v, Old has %d bytes, New has %d bytes, want ErrTooBig, 0, 0", - p, f.Skip, len(f.Old), len(f.New)) + if f := got[p]; !errors.Is(f.Err, vcs.ErrTooBig) || f.OldText != "" || f.NewText != "" { + t.Errorf("%s: Err = %v, Old has %d bytes, New has %d bytes, want ErrTooBig, 0, 0", + p, f.Err, len(f.OldText), len(f.NewText)) } } - if f := got["limit"]; f.Skip != nil || f.New != "z"+limit { - t.Errorf("limit: Skip = %v, New has %d bytes, want nil, %d", - f.Skip, len(f.New), len(limit)+1) + if f := got["limit"]; f.Err != nil || f.NewText != "z"+limit[2:] { + t.Errorf("limit: Err = %v, New has %d bytes, want nil, %d", + f.Err, len(f.NewText), len(limit)-1) } - if f := got["small"]; f.New != "after\n" { - t.Errorf("small: New = %q, want %q", f.New, "after\n") + if f := got["small"]; f.NewText != "after\n" { + t.Errorf("small: New = %q, want %q", f.NewText, "after\n") } for p, want := range map[string]error{ "bin": vcs.ErrBinary, "late": vcs.ErrBinary, "big": vcs.ErrTooBig, "huge": vcs.ErrTooBig, - "bytes": nil, + "bytes": vcs.ErrTooBig, + "lines": vcs.ErrTooBig, + "limit": nil, } { if _, err := r.ReadFile(p); !errors.Is(err, want) { t.Errorf("ReadFile(%s): err = %v, want %v", p, err, want) @@ -321,8 +371,81 @@ func TestDiffSkipsBinaryAndBigFiles(t *testing.T) { } } -// TestFilesInConflict checks that Files lists a path in a merge conflict -// once. +// TestDiffTotalLimit checks that Diff stops reading files once their +// text passes the limit, keeps the files after them that still fit, and +// gives the same result when the objects come from its cache. +func TestDiffTotalLimit(t *testing.T) { + t.Parallel() + r, dir := openTestRepo(t) + big := strings.Repeat("x", 999) + "\n" + for _, p := range []string{"big1", "big2", "big3"} { + testgit.WriteFile(t, dir, p, big) + } + // The other changes take less than 100 bytes, so big3 is the file that + // passes the limit, and new.txt after it still fits. + r.SetMaxDiffBytes(2100) + for range 2 { + got := map[string]vcs.File{} + for _, f := range changes(t, r, head(t, r), snapshot(t, r)) { + got[f.Path] = f + } + for _, p := range []string{"big1", "big2"} { + if f := got[p]; f.Err != nil || f.NewText != big { + t.Errorf("%s: Err = %v, New has %d bytes, want nil, %d", p, f.Err, len(f.NewText), len(big)) + } + } + if f := got["big3"]; !errors.Is(f.Err, vcs.ErrTooBig) || f.NewText != "" { + t.Errorf("big3: Err = %v, New has %d bytes, want ErrTooBig, 0", f.Err, len(f.NewText)) + } + if f := got["new.txt"]; f.Err != nil || f.NewText != "untracked\n" { + t.Errorf("new.txt: Err = %v, New = %q, want nil, %q", f.Err, f.NewText, "untracked\n") + } + } +} + +// TestDiffTooManyFiles checks that Diff reads no file and reports the +// number of changed files when there are more than 20,000. The count is +// right when paths start with ':', as git's metadata does, and when files +// are renamed, which gives two paths. git fast-import writes the two +// commits, so the test writes no files. +func TestDiffTooManyFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + testgit.InitRepo(t, dir) + const renamed, added = 5, 20_000 + var b strings.Builder + b.WriteString("blob\nmark :1\ndata 2\nx\n") + for i := range renamed { + text := fmt.Sprintf("file %d\nwith some text\n", i) + fmt.Fprintf(&b, "blob\nmark :%d\ndata %d\n%s", i+2, len(text), text) + } + b.WriteString("commit refs/heads/main\ncommitter a 0 +0000\ndata 4\nold\n") + for i := range renamed { + fmt.Fprintf(&b, "M 100644 :%d old/%d\n", i+2, i) + } + b.WriteString("commit refs/heads/main\ncommitter a 1 +0000\ndata 4\nnew\n") + for i := range renamed { + fmt.Fprintf(&b, "R old/%d :renamed%d\n", i, i) + } + for i := range added { + fmt.Fprintf(&b, "M 100644 :1 :added%d\n", i) + } + cmd := proc.Command(t.Context(), testgit.ToolEnv(t), "git", "fast-import", "--quiet") + cmd.Dir = dir + cmd.Stdin = strings.NewReader(b.String()) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git fast-import: %v: %s", err, out) + } + r := open(t, dir) + oldTree, newTree := parent(t, r, "main"), strings.TrimSpace(testgit.Git(t, dir, "rev-parse", "main^{tree}")) + _, err := r.Diff(t.Context(), oldTree, newTree) + if e, ok := errors.AsType[*vcs.TooManyFilesError](err); !ok || e.Count != renamed+added { + t.Errorf("Diff: err = %v, want a TooManyFilesError with Count = %d", err, renamed+added) + } +} + +// TestFilesInConflict checks that Files lists a file with a merge conflict +// only once. func TestFilesInConflict(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -355,11 +478,11 @@ func TestFilesInConflict(t *testing.T) { } } -// TestWorktree checks Files, Grep, ReadFile, and Contains in the working -// tree. +// TestWorktree checks Files, IgnoredDir, Grep, ReadFile and Contains on +// the working tree of the test repository. func TestWorktree(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) ctx := t.Context() es, err := r.Files(ctx) @@ -380,12 +503,12 @@ func TestWorktree(t *testing.T) { t.Errorf("IgnoredDir(no such dir) = %v, want none", es) } - // The search has smart case. An upper case letter matches only the - // same case. A query in lower case ignores case. + // A query with an upper case letter is case-sensitive. A query in + // lower case ignores case. if ms, _, err := r.Grep(ctx, "TWO", 10, time.Minute); err != nil || len(ms) != 0 { t.Errorf("Grep(TWO) = %+v, %v, want none, nil", ms, err) } - // git quotes this name unless -z. + // git quotes this name unless it is run with -z. testgit.WriteFile(t, dir, "a:é\n.txt", "x: Two\n") ms, _, err := r.Grep(ctx, "x: two", 10, time.Minute) match := vcs.Match{Path: "a:é\n.txt", Line: 1, Text: "x: Two"} @@ -406,7 +529,7 @@ func TestWorktree(t *testing.T) { }{ {filepath.Join(dir, "build"), true}, {filepath.Dir(dir), false}, - {dir + "x", false}, // a sibling with the same prefix + {dir + "x", false}, // a sibling directory whose name starts with the root's name } for _, tt := range tests { if got := r.Contains(tt.path); got != tt.want { @@ -415,12 +538,12 @@ func TestWorktree(t *testing.T) { } } -// TestOwns checks which directories are in the working tree, as in -// herdr-reviewr. A subdirectory is in it. A nested repository, a relative -// path, and an outside path are not. +// TestOwns checks that Owns reports true for the root and a subdirectory, +// and false for a nested repository, a relative path, an empty path, a +// path outside the root, and a missing directory. func TestOwns(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) nested := filepath.Join(dir, "build", "nested") testgit.InitRepo(t, nested) tests := []struct { @@ -430,7 +553,7 @@ func TestOwns(t *testing.T) { {dir, true}, {filepath.Join(dir, "build"), true}, {nested, false}, - {"build", false}, // relative + {"build", false}, // a relative path {"", false}, {filepath.Dir(dir), false}, {filepath.Join(dir, "gone"), false}, @@ -442,12 +565,13 @@ func TestOwns(t *testing.T) { } } -// TestOwnsWithoutGit checks that Owns fails without git. No git runs for -// a path outside the root. +// TestOwnsWithoutGit checks that Owns returns an error when git is +// missing, except for a path outside the root, for which it does not need +// to run git. func TestOwnsWithoutGit(t *testing.T) { t.Parallel() env, link := gitLink(t) - r, dir := newRepoEnv(t, env) + r, dir := openTestRepoEnv(t, env) removeGit(t, link) if _, err := r.Owns(t.Context(), dir); err == nil { t.Error("Owns without git gives no error") @@ -457,10 +581,12 @@ func TestOwnsWithoutGit(t *testing.T) { } } -// TestRefs checks Branch, Branches, DefaultBase, and Resolve. +// TestRefs checks Branches, DefaultBase, ResolveRev and Branch, including +// remote branches, a dangling origin/HEAD and a repository with no +// commits. func TestRefs(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) ctx := t.Context() if bs, err := r.Branches(ctx); err != nil || strings.Join(bs, " ") != "feat main" { t.Errorf("Branches = %v, %v, want [feat main], nil", bs, err) @@ -468,9 +594,9 @@ func TestRefs(t *testing.T) { if b := r.DefaultBase(ctx); b != "main" { t.Errorf("DefaultBase = %q, want main", b) } - // As in herdr-reviewr, the list has only the branches of origin, and - // origin/main hides main. The HEAD of origin is not a branch. The - // branch old has the oldest commit, so it is last. + // origin/main hides main, and origin/HEAD is not listed. Branches of + // remotes other than origin are not listed either. The branch old has + // the oldest commit, so it comes last. for _, remote := range []string{"origin", "up"} { testgit.Git(t, dir, "update-ref", "refs/remotes/"+remote+"/main", "main") testgit.Git(t, dir, "symbolic-ref", "refs/remotes/"+remote+"/HEAD", @@ -486,8 +612,8 @@ func TestRefs(t *testing.T) { if bs, err := r.Branches(ctx); err != nil || strings.Join(bs, " ") != want { t.Errorf("Branches with remotes = %v, %v, want %s", bs, err, want) } - if _, err := r.Resolve(ctx, "nosuchref"); err == nil { - t.Error("Resolve(nosuchref): no error, want an error") + if _, err := r.ResolveRev(ctx, "nosuchref"); err == nil { + t.Error("ResolveRev(nosuchref): no error, want an error") } testgit.Git(t, dir, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/gone") if b := r.DefaultBase(ctx); b != "main" { @@ -501,21 +627,23 @@ func TestRefs(t *testing.T) { } } -// TestEditor checks that Editor returns core.editor, or "" if it is not set. +// TestEditor checks that GitEditor returns core.editor, or "" if it is not +// set. func TestEditor(t *testing.T) { t.Parallel() - r, dir := newRepo(t) - if got := r.Editor(t.Context()); got != "" { + r, dir := openTestRepo(t) + if got := r.GitEditor(t.Context()); got != "" { t.Errorf("Editor with no core.editor = %q, want \"\"", got) } testgit.Git(t, dir, "config", "core.editor", "vim -f") - if got := r.Editor(t.Context()); got != "vim -f" { + if got := r.GitEditor(t.Context()); got != "vim -f" { t.Errorf("Editor = %q, want \"vim -f\"", got) } } -// TestDiffRenameAndSubmodule checks the old path of a rename and the -// content that Diff gives for a submodule. +// TestDiffRenameAndSubmodule checks that Diff sets OldPath for a renamed +// file and returns a "Subproject commit" line for each side of a changed +// submodule. func TestDiffRenameAndSubmodule(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -534,24 +662,24 @@ func TestDiffRenameAndSubmodule(t *testing.T) { ctx := t.Context() r := open(t, dir) - from, to := parent(t, r, "HEAD"), head(t, r) - got, err := r.Diff(ctx, from, to) + oldTree, newTree := parent(t, r, "HEAD"), head(t, r) + got, err := r.Diff(ctx, oldTree, newTree) if err != nil { t.Fatal(err) } want := []vcs.File{ { - Path: "new.txt", - From: "old.txt", - Status: vcs.StatusRenamed, - Old: "one\ntwo\nthree\n", - New: "one\ntwo\nthree\n", + Path: "new.txt", + OldPath: "old.txt", + Status: vcs.StatusRenamed, + OldText: "one\ntwo\nthree\n", + NewText: "one\ntwo\nthree\n", }, { - Path: "sub", - Status: vcs.StatusModified, - Old: "Subproject commit " + a + "\n", - New: "Subproject commit " + b + "\n", + Path: "sub", + Status: vcs.StatusModified, + OldText: "Subproject commit " + a + "\n", + NewText: "Subproject commit " + b + "\n", }, } if !slices.Equal(got, want) { @@ -567,8 +695,9 @@ func TestOpenOutsideARepo(t *testing.T) { } } -// TestNewRepo checks Open, Root, HeadTree, and Snapshot in a repository -// with no commit and no index. The path of a root can have a newline. +// TestNewRepo checks Open, Root, HeadTree and SnapshotTree in a repository +// with no commits and no index, including one whose root path contains a +// newline. func TestNewRepo(t *testing.T) { t.Parallel() tests := []struct{ name, root string }{ @@ -595,31 +724,32 @@ func TestNewRepo(t *testing.T) { } } -// TestSnapshotBadIndex checks that Snapshot fails if it cannot read the -// real index. Only a missing index means no index. +// TestSnapshotBadIndex checks that SnapshotTree fails if the real index +// exists but cannot be read. Only a missing index is treated as an empty +// one. func TestSnapshotBadIndex(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) idx := filepath.Join(dir, ".git", "index") if err := os.Remove(idx); err != nil { t.Fatal(err) } - if err := os.Symlink("index", idx); err != nil { // a loop + if err := os.Symlink("index", idx); err != nil { // a link to itself t.Fatal(err) } - if tree, err := r.Snapshot(t.Context()); err == nil { + if tree, err := r.SnapshotTree(t.Context()); err == nil { t.Errorf("Snapshot with a bad index = %s, want an error", tree) } } -// TestSnapshotRacyEdit checks that Snapshot sees a same-size edit in the -// same second as the last write of the index. +// TestSnapshotRacyEdit checks that SnapshotTree notices an edit that keeps +// the file size and is made in the same second as the last write of the +// index. func TestSnapshotRacyEdit(t *testing.T) { t.Parallel() - // git checks such "racily clean" files by their content. It finds them by - // the modification time of the index. The copy of the index must keep - // that time. - r, dir := newRepo(t) + // git detects "racily clean" files by comparing with the index's + // modification time, so the copy of the index must keep that time. + r, dir := openTestRepo(t) testgit.Git(t, dir, "config", "core.trustctime", "false") testgit.Git(t, dir, "add", "-A") testgit.Git(t, dir, "commit", "-qm", "all") @@ -632,10 +762,10 @@ func TestSnapshotRacyEdit(t *testing.T) { } testgit.WriteFile(t, dir, "b.txt", "bee\n") touch("b.txt") - // The index entry gets the time of b.txt. + // Record the modification time of b.txt in the index entry. testgit.Git(t, dir, "update-index", "--really-refresh") touch(".git/index") - testgit.WriteFile(t, dir, "b.txt", "BEE\n") // the same size and time + testgit.WriteFile(t, dir, "b.txt", "BEE\n") // same size; touch then restores the time touch("b.txt") if got := uncommitted(t, r); got != "Mb.txt" { @@ -643,11 +773,12 @@ func TestSnapshotRacyEdit(t *testing.T) { } } -// TestCommitsLikeHerdrReviewr checks the commits of the commit picker. -// They are the first-parent commits after since, else the last 50. -func TestCommitsLikeHerdrReviewr(t *testing.T) { +// TestCommitsFirstParent checks that Commits returns only first-parent +// commits after since, the last [vcs.LastCommits] commits when since is +// empty, and no commits in an empty repository. +func TestCommitsFirstParent(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) ctx := t.Context() run := func(args ...string) { t.Helper() @@ -694,11 +825,32 @@ func TestCommitsLikeHerdrReviewr(t *testing.T) { } } -// TestGrepLimit checks that Grep stops at the limit and tells that there -// are more matches. +// TestGrepLimit checks that Grep returns at most limit matches, in file +// TestGrepLongLines checks that Grep keeps only the start of a long line, +// without cutting a character in two, and reads the matches after it. +func TestGrepLongLines(t *testing.T) { + t.Parallel() + r, dir := openTestRepo(t) + long := "hit " + strings.Repeat("é", 1_000_000) + testgit.WriteFile(t, dir, "long.txt", long+"\n"+long+"\nhit short\n") + ms, more, err := r.Grep(t.Context(), "hit", 10, time.Minute) + if err != nil || more || len(ms) != 3 { + t.Fatalf("Grep = %d matches, more %t, %v, want 3, false, nil", len(ms), more, err) + } + for _, m := range ms[:2] { + if len(m.Text) > 512 || len(m.Text) < 500 || !utf8.ValidString(m.Text) || !strings.HasPrefix(long, m.Text) { + t.Errorf("line %d: Text has %d bytes, want the start of the line, valid UTF-8, at most 512 bytes", m.Line, len(m.Text)) + } + } + if want := (vcs.Match{Path: "long.txt", Line: 3, Text: "hit short"}); ms[2] != want { + t.Errorf("third match = %+v, want %+v", ms[2], want) + } +} + +// order, and sets more only when there are more matches. func TestGrepLimit(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) ctx := t.Context() testgit.WriteFile(t, dir, "many.txt", strings.Repeat("hit\n", 5000)) tests := []struct { @@ -724,11 +876,11 @@ func TestGrepLimit(t *testing.T) { } } -// TestGrepCancelled checks that Grep returns the cause of a cancelled -// context. +// TestGrepCancelled checks that Grep returns the cancellation cause of a +// cancelled context. func TestGrepCancelled(t *testing.T) { t.Parallel() - r, _ := newRepo(t) + r, _ := openTestRepo(t) ctx, cancel := context.WithCancelCause(t.Context()) errStop := errors.New("the test stops the search") cancel(errStop) @@ -737,12 +889,12 @@ func TestGrepCancelled(t *testing.T) { } } -// TestSnapshotFollowsIndex checks that a Snapshot after an earlier one -// drops a file that git now ignores, and keeps a file that the user adds -// with "git add -f". +// TestSnapshotFollowsIndex checks that after a .gitignore change, a new +// SnapshotTree leaves out a file that git now ignores but keeps a file +// added with "git add -f". func TestSnapshotFollowsIndex(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) testgit.WriteFile(t, dir, "build.log", "x\n") testgit.WriteFile(t, dir, "keep.env", "k\n") if got := uncommitted(t, r); got != "A.gitignore Ma.txt Abuild.log Akeep.env Anew.txt" { diff --git a/vcs/saved_test.go b/vcs/saved_test.go index 251d565..411e06a 100644 --- a/vcs/saved_test.go +++ b/vcs/saved_test.go @@ -8,11 +8,12 @@ import ( "github.com/rednafi/rexdiff/internal/testgit" ) -// TestSavedBase checks that SaveBase keeps a base, and that the default -// branch deletes it. +// TestSavedBase checks that SavedBase returns the base that SaveBase +// saved, that saving the default branch clears it, and that values that +// are not valid ref names cannot be saved or read back. func TestSavedBase(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) ctx := t.Context() if got := r.SavedBase(ctx); got != "" { t.Errorf("SavedBase before a save = %q, want \"\"", got) @@ -29,7 +30,7 @@ func TestSavedBase(t *testing.T) { if got := r.SavedBase(ctx); got != "" { t.Errorf("SavedBase after a save of the default = %q, want \"\"", got) } - // A value that is not a base is not read. + // A value that is not a valid ref name can be neither saved nor read. for _, bad := range []string{"-x", "a b", "a\x7f"} { if err := r.SaveBase(ctx, bad); err == nil { t.Errorf("SaveBase(%q): no error, want an error", bad) @@ -42,7 +43,8 @@ func TestSavedBase(t *testing.T) { } } -// hashObject writes s as a blob in the repository dir and returns its ID. +// hashObject stores s as a blob in the repository at dir and returns the +// blob's ID. func hashObject(t *testing.T, dir, s string) string { t.Helper() tmp := t.TempDir() @@ -51,13 +53,13 @@ func hashObject(t *testing.T, dir, s string) string { return strings.TrimSpace(out) } -// TestSavedTurn checks that SaveTurn keeps a tree, also after git gc, and -// that "" deletes it. +// TestSavedTurn checks that a tree saved with SaveTurn survives git gc, +// and that saving "" deletes it, also when it is already deleted. func TestSavedTurn(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) ctx := t.Context() - tree := snapshot(t, r) // a tree that no commit has + tree := snapshot(t, r) // a tree that no commit refers to if err := r.SaveTurn(ctx, tree); err != nil { t.Fatal(err) } @@ -68,7 +70,7 @@ func TestSavedTurn(t *testing.T) { if _, err := r.Diff(ctx, tree, head(t, r)); err != nil { t.Errorf("the tree after gc: %v", err) } - for range 2 { // a second delete is no error + for range 2 { // deleting twice is not an error if err := r.SaveTurn(ctx, ""); err != nil { t.Fatal(err) } @@ -78,11 +80,11 @@ func TestSavedTurn(t *testing.T) { } } -// TestSavedPerWorktree checks that each worktree has its own saved base -// and turn. +// TestSavedPerWorktree checks that the base and turn saved in one +// worktree are not visible in another worktree of the same repository. func TestSavedPerWorktree(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) ctx := t.Context() other := filepath.Join(t.TempDir(), "wt") testgit.Git(t, dir, "worktree", "add", "-q", other, "main") diff --git a/vcs/watch.go b/vcs/watch.go index 5fb2fdf..69753af 100644 --- a/vcs/watch.go +++ b/vcs/watch.go @@ -10,49 +10,59 @@ import ( "path/filepath" "slices" "strings" + "sync" "syscall" "time" ) -// pauseFactor sets the pause after a check of a [Watcher]. The next check -// waits this many times as long as the last check took. Thus the checks -// use less than 1/21 of a core. herdr-reviewr uses the same factor. -const pauseFactor = 20 +// Pauses between the checks of a [Watcher]. After a check, the next one +// waits pauseFactor times as long as the check took, so checking uses less +// than 1% of a CPU core, but never more than maxPause. The time spent +// listing the files with git does not count: listing happens only after +// a change, and a pause after it would delay noticing the next edit by +// seconds in a large repository. +const ( + pauseFactor = 100 + maxPause = 30 * time.Second +) -// Watcher reports whether the working tree or the refs can have changed. It -// compares the file status of the files that git lists, of their -// directories, and of the git files. While nothing changes, it runs no -// git process. After a change, it runs git to list the files again. +// Watcher reports whether the working tree or the refs may have changed. +// It compares the file metadata of the files that git lists, their +// directories and git's own files. While nothing changes, it runs no git +// process. After a change, it runs git to list the files again. +// +// A Watcher does not notice changes to the global core.excludesFile, or a +// file replaced by a copy with the same size and modification time. // -// A Watcher does not see a change of the global core.excludesFile. It -// also does not see a copy that keeps the size and the modification time -// of a file. +// A Watcher is safe for concurrent use by multiple goroutines. type Watcher struct { + mu sync.Mutex // held by every method r *Repo gitDir, common string // absolute paths - now func() time.Time // the clock, time.Now - next time.Time // no check before this time + now func() time.Time // the clock; time.Now outside tests + next time.Time // the earliest time of the next check - started bool - since time.Time // the start of the watch - listed bool // files and dirs are from the last list - files []string // the absolute paths that git lists - dirs []string // the root and each directory above a listed path - shown []string // the absolute paths of the ignored dirs that show + started bool + reset bool // the next check reports a change to the files + since time.Time // when the watch started + listed bool // files and dirs come from a complete listing + files []string // the absolute paths that git lists + dirs []string // the root and every directory that contains a listed path + included []string // the absolute paths set by Include - // The sums of the last check. - list, refs, index, dirSum, fileSum, shownSum uint64 + // The sums from the last check. + list, refs, index, dirSum, fileSum, includedSum uint64 } -// Change tells what can have changed since the last call to +// Change describes what may have changed since the last call to // [Watcher.Changed]. type Change struct { - Files bool // the working tree or the index - Refs bool // HEAD, a ref, or the config + Files bool // the working tree or the index may have changed + Refs bool // HEAD, a ref or the git config may have changed } -// Watch returns a Watcher of the repository. Its first call to -// [Watcher.Changed] reports a change of both. +// Watch returns a Watcher for the repository. The first call to +// [Watcher.Changed] reports both kinds of change. func (r *Repo) Watch(ctx context.Context) (*Watcher, error) { gitDir, err := r.gitPath(ctx, "--git-dir") if err != nil { @@ -63,32 +73,47 @@ func (r *Repo) Watch(ctx context.Context) (*Watcher, error) { return nil, err } w := &Watcher{r: r, gitDir: gitDir, common: common, now: time.Now, since: time.Now()} - // The list is ready at once, for LastWrite. + // List the files now, so LastWrite works before the first call to + // Changed. w.index = w.sumIndex() if w.list, err = w.relist(ctx); err != nil { return nil, err } - w.dirSum = sumPaths(w.dirs) + // Sum the files now too, or else the first call to Changed would see + // them change and list them again. + w.dirSum, w.fileSum = sumPaths(w.dirs), sumFiles(w.files) return w, nil } -// Show sets the paths that show and that git does not list. These are the -// ignored directories that the user expands, and the open file. The open -// file can be ignored or a link. paths are relative to the root. Changed -// also reports an edit of a shown file or of the target of a shown link. -// It also reports a file that a shown directory gets or loses. A new set -// reports no change. -func (w *Watcher) Show(paths []string) { +// Include sets extra paths to watch that git does not list, for example +// expanded ignored directories and the file that is open. paths are +// relative to the root. Changed then also reports edits to such a file or +// to its symbolic link target, and files added to or removed from such a +// directory. Calling Include does not itself count as a change. +func (w *Watcher) Include(paths []string) { + w.mu.Lock() + defer w.mu.Unlock() abs := make([]string, len(paths)) for i, p := range paths { abs[i] = filepath.Join(w.r.root, filepath.FromSlash(p)) } - if !slices.Equal(abs, w.shown) { - w.shown, w.shownSum = abs, sumPaths(abs) + if !slices.Equal(abs, w.included) { + w.included, w.includedSum = abs, sumPaths(abs) } } -// gitPath returns the absolute path that rev-parse gives for flag. +// Reset makes the next call to [Watcher.Changed] check at once and report +// a change to the files. Call it after reading the files without a +// reported change, for example on a key press. Then the reread that follows +// that report starts after the check, and the checks compare with what is +// shown. +func (w *Watcher) Reset() { + w.mu.Lock() + defer w.mu.Unlock() + w.reset, w.next = true, time.Time{} +} + +// gitPath returns the absolute path that git rev-parse prints for flag. func (r *Repo) gitPath(ctx context.Context, flag string) (string, error) { out, err := r.run(ctx, nil, "rev-parse", "--path-format=absolute", flag) if err != nil { @@ -97,18 +122,27 @@ func (r *Repo) gitPath(ctx context.Context, flag string) (string, error) { return strings.TrimSuffix(out, "\n"), nil } -// Changed tells what can have changed since the last call. The first call -// reports a change of both. After a call that takes the time d, Changed -// reports no change for 20 times d. A change in that time shows in the -// next call after it. +// Changed reports what may have changed since the last call. The first +// call reports both kinds of change. If a call takes the time d, not +// counting the time spent listing files with git, calls in the following +// 100 times d, but at most 30 seconds, report no change without checking. +// The first call after that period reports a change made in it. func (w *Watcher) Changed(ctx context.Context) (Change, error) { + w.mu.Lock() + defer w.mu.Unlock() start := w.now() if start.Before(w.next) { return Change{}, nil } + var listing time.Duration // the time spent in relist + relist := func() (uint64, error) { + t0 := w.now() + defer func() { listing += w.now().Sub(t0) }() + return w.relist(ctx) + } defer func() { end := w.now() - w.next = end.Add(pauseFactor * end.Sub(start)) + w.next = end.Add(min(pauseFactor*(end.Sub(start)-listing), maxPause)) }() refs, index := w.sumRefs(), w.sumIndex() @@ -117,20 +151,20 @@ func (w *Watcher) Changed(ctx context.Context) (Change, error) { list := w.list if !w.listed || dirs != w.dirSum || index != w.index { var err error - if list, err = w.relist(ctx); err != nil { + if list, err = relist(); err != nil { return Change{}, err } dirs, listed = sumPaths(w.dirs), true } - files, shown := sumFiles(w.files), sumPaths(w.shown) + files, included := sumFiles(w.files), sumPaths(w.included) ch := Change{ - Files: index != w.index || list != w.list || files != w.fileSum, + Files: w.reset || index != w.index || list != w.list || files != w.fileSum, Refs: refs != w.refs, } if ch.Files && !listed { - // An edit of a .gitignore file can change the list. + // Editing a .gitignore file can change which files git lists. var err error - if list, err = w.relist(ctx); err != nil { + if list, err = relist(); err != nil { return Change{}, err } dirs, files = sumPaths(w.dirs), sumFiles(w.files) @@ -139,23 +173,26 @@ func (w *Watcher) Changed(ctx context.Context) (Change, error) { w.started, ch = true, Change{Files: true, Refs: true} } w.list, w.refs, w.index, w.dirSum, w.fileSum = list, refs, index, dirs, files - if shown != w.shownSum { - w.shownSum, ch.Files = shown, true + w.reset = false + if included != w.includedSum { + w.includedSum, ch.Files = included, true } return ch, nil } -// LastWrite returns the newest modification time, not after until, of the -// listed files and of their directories, as they are now. A new or a -// removed file changes the time of its directory. LastWrite returns the -// zero time if no such time is after the start of the watch. It does not -// change what the next call to Changed reports. +// LastWrite returns the latest modification time, no later than until, of +// the listed files and their directories. Adding or removing a file +// updates the time of its directory. If no such time is after the watch +// started, LastWrite returns the zero time. It does not affect what the +// next call to Changed reports. func (w *Watcher) LastWrite(until time.Time) time.Time { + w.mu.Lock() + defer w.mu.Unlock() var last time.Time for _, p := range slices.Concat(w.files, w.dirs) { - fi, err := os.Lstat(p) - if err == nil && fi.ModTime().After(last) && !fi.ModTime().After(until) { - last = fi.ModTime() + info, err := os.Lstat(p) + if err == nil && info.ModTime().After(last) && !info.ModTime().After(until) { + last = info.ModTime() } } if !last.After(w.since) { @@ -164,11 +201,11 @@ func (w *Watcher) LastWrite(until time.Time) time.Time { return last } -// relist reads the list of the files and their directories again. It -// returns the sum of the list. +// relist lists the files and their directories again and returns a hash +// of the file list. func (w *Watcher) relist(ctx context.Context) (uint64, error) { w.listed = false - paths, err := w.r.lsFiles(ctx, listArgs...) + paths, err := w.r.lsFiles(ctx, lsFilesArgs...) if err != nil { return 0, err } @@ -189,7 +226,7 @@ func (w *Watcher) relist(ctx context.Context) (uint64, error) { s := newSum() for _, p := range paths { s.str(p) - // git lists a nested repository with a slash at the end. + // git lists a nested repository with a trailing slash. p = filepath.Join(root, strings.TrimSuffix(p, "/")) w.files = append(w.files, p) addDirs(filepath.Dir(p)) @@ -202,10 +239,10 @@ func (w *Watcher) relist(ctx context.Context) (uint64, error) { } // untrackedDirs returns the untracked directories, relative to the root, -// and each directory below them that git does not ignore. git lists no -// file in a directory that is empty or has only ignored files. A new file -// there changes only that directory, thus the [Watcher] must watch it. -// untrackedDirs does not go into a nested repository. +// and every directory below them that git does not ignore. The [Watcher] +// needs them because git lists no files for a directory that is empty or +// holds only ignored files. untrackedDirs does not descend into nested +// repositories. func (r *Repo) untrackedDirs(ctx context.Context) ([]string, error) { ps, err := r.lsFiles(ctx, "-o", "--directory", "--exclude-standard") if err != nil { @@ -223,9 +260,10 @@ func (r *Repo) untrackedDirs(ctx context.Context) ([]string, error) { for _, d := range level { abs := filepath.Join(r.root, filepath.FromSlash(d)) if _, err := os.Lstat(filepath.Join(abs, ".git")); err == nil { - continue // a nested repository + continue // skip a nested repository } - // A directory that goes away has no directories to watch. + // If the directory was just deleted, ReadDir fails and there is + // nothing below it to watch. des, _ := os.ReadDir(abs) for _, de := range des { if de.IsDir() { @@ -240,17 +278,17 @@ func (r *Repo) untrackedDirs(ctx context.Context) ([]string, error) { return dirs, nil } -// notIgnored returns the paths that git does not ignore. Each path gets -// the prefix "./", because git reads a path that starts with ":" as -// pathspec magic. git writes the paths with the prefix. +// notIgnored returns the paths that git does not ignore. It prefixes each +// path with "./" before passing it to git, because git treats a leading +// ":" as pathspec magic. func (r *Repo) notIgnored(ctx context.Context, paths []string) ([]string, error) { if len(paths) == 0 { return nil, nil } in := strings.NewReader("./" + strings.Join(paths, "\x00./")) out, err := r.runInput(ctx, nil, in, "check-ignore", "-z", "--stdin") - if exitedOne(err) { - return paths, nil // git ignores none + if isExitOne(err) { + return paths, nil // git ignores none of the paths } if err != nil { return nil, err @@ -262,8 +300,8 @@ func (r *Repo) notIgnored(ctx context.Context, paths []string) ([]string, error) return slices.DeleteFunc(paths, func(p string) bool { return ignored["./"+p] }), nil } -// sumRefs returns the sum of the files that change with HEAD, a ref, or -// the config. +// sumRefs returns a hash of the files that change when HEAD, a ref or the +// git config changes. func (w *Watcher) sumRefs() uint64 { paths := []string{ filepath.Join(w.gitDir, "HEAD"), @@ -273,11 +311,11 @@ func (w *Watcher) sumRefs() uint64 { filepath.Join(w.common, "config"), filepath.Join(w.common, "reftable", "tables.list"), } - // The branch of HEAD has its own file. The branches are in the common - // directory, which the worktrees of the repository share. + // The branch that HEAD points to has its own file, in the common + // directory that all worktrees share. if b, err := os.ReadFile(paths[0]); err == nil { ref, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "ref: ") - // Localize refuses a ".." or a "/" at the start. + // Localize rejects paths with ".." or a leading "/". if p, err := filepath.Localize(ref); ok && err == nil { paths = append(paths, filepath.Join(w.common, p)) } @@ -285,85 +323,107 @@ func (w *Watcher) sumRefs() uint64 { return sumPaths(paths) } -// sumIndex returns the sum of the files that change the list of files. +// sumIndex returns a hash of the files that affect which files git lists: +// the index and info/exclude. func (w *Watcher) sumIndex() uint64 { return sumPaths([]string{w.r.index, filepath.Join(w.common, "info", "exclude")}) } -// sumPaths returns the sum of the path, the size, the modification time, -// the mode, and the inode of each file. It follows a symbolic link, so an -// edit of the target changes the sum. +// sumPaths returns a hash of the path, size, modification time, mode and +// inode of each file. It follows symbolic links, so editing a link's +// target changes the hash. func sumPaths(paths []string) uint64 { s := newSum() for _, p := range paths { - s.stat(p, os.Stat) + s.file(p, statOf(os.Stat, p)) } return s.Sum64() } -// sumFiles is like sumPaths. For a directory, it also adds the git files -// of the nested repository or the submodule in it. A commit there does not -// change the directory. +// sumFiles is like sumPaths, but it does not follow symbolic links. For a +// directory that holds a nested repository or submodule, it also hashes +// that repository's git files, because a commit there does not change the +// directory itself. func sumFiles(paths []string) uint64 { s := newSum() for _, p := range paths { - if s.stat(p, os.Lstat) { + if s.file(p, statOf(os.Lstat, p)) { s.nested(p) } } return s.Sum64() } -// sum is a 64-bit FNV-1a hash of paths and of the states of files. The -// sums stay in the process, so their values can change between versions. -// The Write of a hash never fails. -type sum struct{ hash.Hash64 } +// statOf returns the result of calling stat on p, or nil if that fails, +// for example because p does not exist. +func statOf(stat func(string) (fs.FileInfo, error), p string) fs.FileInfo { + info, err := stat(p) + if err != nil { + return nil + } + return info +} + +// sum is a 64-bit FNV-1a hash of paths and file metadata. Sums are never +// stored outside the process, so the hashed format can change between +// versions. Write never fails. buf is reused for every write to avoid an +// allocation. +type sum struct { + hash.Hash64 + buf []byte +} -// newSum returns the sum of no bytes. -func newSum() sum { return sum{fnv.New64a()} } +// newSum returns an empty sum. +func newSum() *sum { return &sum{Hash64: fnv.New64a()} } -// str adds v and a NUL, so that two strings do not join. -func (s sum) str(v string) { _, _ = s.Write(append([]byte(v), 0)) } +// str adds v followed by a NUL byte, so that adjacent strings stay +// distinct. +func (s *sum) str(v string) { + s.buf = append(append(s.buf[:0], v...), 0) + _, _ = s.Write(s.buf) +} -// num adds the 8 bytes of v. -func (s sum) num(v uint64) { _, _ = s.Write(binary.LittleEndian.AppendUint64(nil, v)) } +// num adds v as 8 bytes. +func (s *sum) num(v uint64) { + s.buf = binary.LittleEndian.AppendUint64(s.buf[:0], v) + _, _ = s.Write(s.buf) +} // int adds v, for example a size or a time. -func (s sum) int(v int64) { - s.num(uint64(v)) //nolint:gosec // a hash keeps all bits, so a wrap is fine +func (s *sum) int(v int64) { + s.num(uint64(v)) //nolint:gosec // the hash uses all 64 bits, so the conversion loses nothing } -// stat adds the path and the file status that stat gives for p. A missing -// file adds a marker. stat reports whether p is a directory. -func (s sum) stat(p string, stat func(string) (fs.FileInfo, error)) (isDir bool) { +// file adds the path p and its metadata info. A nil info means that the +// file does not exist. file reports whether p is a directory. +func (s *sum) file(p string, info fs.FileInfo) (isDir bool) { s.str(p) - fi, err := stat(p) - if err != nil { + if info == nil { s.num(0) return false } - s.int(fi.Size()) - s.int(fi.ModTime().UnixNano()) - s.num(uint64(fi.Mode())) - if st, ok := fi.Sys().(*syscall.Stat_t); ok { + s.int(info.Size()) + s.int(info.ModTime().UnixNano()) + s.num(uint64(info.Mode())) + if st, ok := info.Sys().(*syscall.Stat_t); ok { s.num(st.Ino) } - return fi.IsDir() + return info.IsDir() } -// nested adds the git files of the repository in the directory p, if p -// has one. A commit there changes HEAD or its log, and the directory of -// the git files. -func (s sum) nested(p string) { +// nested adds the git directory, HEAD and HEAD log of the repository in +// the directory p, if p contains one. +func (s *sum) nested(p string) { dotGit := filepath.Join(p, ".git") - fi, err := os.Lstat(dotGit) + info, err := os.Lstat(dotGit) if err != nil { return } s.str(dotGit) dir := dotGit - if !fi.IsDir() { - // A submodule has a file "gitdir: " in place of a directory. + if !info.IsDir() { + // In a submodule, .git is a file containing "gitdir: " + // instead of a directory. b, err := os.ReadFile(dotGit) gd, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "gitdir: ") if err != nil || !ok { @@ -374,13 +434,13 @@ func (s sum) nested(p string) { } } for _, f := range []string{dir, filepath.Join(dir, "HEAD"), filepath.Join(dir, "logs", "HEAD")} { - fi, err := os.Lstat(f) + info, err := os.Lstat(f) if err != nil { s.num(0) continue } - s.int(fi.Size()) - s.int(fi.ModTime().UnixNano()) + s.int(info.Size()) + s.int(info.ModTime().UnixNano()) } } diff --git a/vcs/watch_test.go b/vcs/watch_test.go index c2fa8d5..076e899 100644 --- a/vcs/watch_test.go +++ b/vcs/watch_test.go @@ -1,9 +1,11 @@ package vcs_test import ( + "fmt" "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -11,8 +13,9 @@ import ( "github.com/rednafi/rexdiff/vcs" ) -// newWatcher returns a Watcher of r after its first call. Its clock does -// not move, so no call waits for the pause. +// newWatcher returns a Watcher for r on which Changed has already been +// called once. Its clock never moves, so no call is skipped by the pause +// between checks. func newWatcher(t *testing.T, r *vcs.Repo) *vcs.Watcher { t.Helper() w, err := r.Watch(t.Context()) @@ -21,12 +24,13 @@ func newWatcher(t *testing.T, r *vcs.Repo) *vcs.Watcher { } w.SetClock(func() time.Time { return time.Time{} }) if ch := changed(t, w); ch != (vcs.Change{Files: true, Refs: true}) { - t.Fatalf("first call: %+v, want both", ch) + t.Fatalf("first Changed call = %+v, want both", ch) } return w } -// changed returns the change that w sees. +// changed calls w.Changed and returns the result, failing the test on +// error. func changed(t *testing.T, w *vcs.Watcher) vcs.Change { t.Helper() ch, err := w.Changed(t.Context()) @@ -36,8 +40,20 @@ func changed(t *testing.T, w *vcs.Watcher) vcs.Change { return ch } -// TestWatcher checks the changes that a Watcher sees and does not see. A -// change must have each part of want. No change must be none. +// covers reports whether got sets every field that want sets, and, if +// want is empty, whether got is empty too. +func covers(got, want vcs.Change) bool { + if want == (vcs.Change{}) { + return got == want + } + filesOK := !want.Files || got.Files + refsOK := !want.Refs || got.Refs + return filesOK && refsOK +} + +// TestWatcher checks which edits a Watcher reports and which it ignores. +// For each edit, Changed must set every field that want sets, must report +// nothing when want is empty, and must report nothing on the next call. func TestWatcher(t *testing.T) { t.Parallel() files := vcs.Change{Files: true} @@ -49,7 +65,7 @@ func TestWatcher(t *testing.T) { {"nothing", func(*testing.T, string) {}, vcs.Change{}}, {"same size, new time", func(t *testing.T, dir string) { t.Helper() - testgit.WriteFile(t, dir, "a.txt", "one\nxyz\n") // the same size + testgit.WriteFile(t, dir, "a.txt", "one\nxyz\n") // same size as before p := filepath.Join(dir, "a.txt") later := time.Now().Add(time.Hour) if err := os.Chtimes(p, later, later); err != nil { @@ -86,35 +102,33 @@ func TestWatcher(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) w := newWatcher(t, r) tt.edit(t, dir) got := changed(t, w) - if tt.want.Files && !got.Files || tt.want.Refs && !got.Refs || - tt.want == (vcs.Change{}) && got != tt.want { - t.Errorf("after the edit: %+v, want %+v", got, tt.want) + if !covers(got, tt.want) { + t.Errorf("Changed after the edit = %+v, want %+v", got, tt.want) } if got := changed(t, w); got != (vcs.Change{}) { - t.Errorf("second call: %+v, want none", got) + t.Errorf("second Changed call = %+v, want none", got) } }) } } -// TestWatcherLastWrite checks that LastWrite sees a new file after the -// start of the watch, but not after its limit, and that the change still -// shows in Changed. +// TestWatcherLastWrite checks that LastWrite returns the time of a file +// written after the watch started, ignores times after until, and does +// not stop Changed from reporting the new file. func TestWatcherLastWrite(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) w := newWatcher(t, r) if got := w.LastWrite(time.Now()); !got.IsZero() { t.Errorf("LastWrite before a write = %v, want zero", got) } testgit.WriteFile(t, dir, "c.txt", "c\n") - // The clock of the file system can be behind time.Now. So the test sets - // the times of the new file and of its directory to an hour after now. - // This is clearly after the start of the watch. + // The file system clock can lag behind time.Now, so set the times of + // the new file and its directory to one hour from now. later := time.Now().Add(time.Hour) for _, p := range []string{filepath.Join(dir, "c.txt"), dir} { if err := os.Chtimes(p, later, later); err != nil { @@ -128,99 +142,103 @@ func TestWatcherLastWrite(t *testing.T) { t.Errorf("LastWrite before the write = %v, want zero", got) } if got := changed(t, w); !got.Files { - t.Errorf("Changed after LastWrite: %+v, want Files", got) + t.Errorf("Changed after LastWrite = %+v, want Files", got) } } -// TestWatcherNewDirectory checks that an edit in a new untracked directory -// is a change. +// TestWatcherNewDirectory checks that Changed reports an edit to a file in +// a new untracked directory. func TestWatcherNewDirectory(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) w := newWatcher(t, r) testgit.WriteFile(t, dir, "x/c.txt", "c\n") changed(t, w) testgit.WriteFile(t, dir, "x/c.txt", "c\nd\n") if got := changed(t, w); !got.Files { - t.Errorf("after an edit in x: %+v, want Files", got) + t.Errorf("Changed after an edit in x = %+v, want Files", got) } } -// TestWatcherUnignore checks that a file shows after a .gitignore edit -// stops git from ignoring it. +// TestWatcherUnignore checks that Changed reports a .gitignore edit that +// stops git from ignoring a file, and then reports edits to that file. func TestWatcherUnignore(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) w := newWatcher(t, r) testgit.WriteFile(t, dir, "ignored.txt", "xx\n") if got := changed(t, w); got.Files { - t.Errorf("after an edit of an ignored file: %+v, want no Files", got) + t.Errorf("Changed after an edit of an ignored file = %+v, want no Files", got) } testgit.WriteFile(t, dir, ".gitignore", "build/\n") if got := changed(t, w); !got.Files { - t.Errorf("after the .gitignore edit: %+v, want Files", got) + t.Errorf("Changed after the .gitignore edit = %+v, want Files", got) } testgit.WriteFile(t, dir, "ignored.txt", "xxx\n") if got := changed(t, w); !got.Files { - t.Errorf("after an edit of the file: %+v, want Files", got) + t.Errorf("Changed after an edit of the file = %+v, want Files", got) } } -// TestWatcherShown checks that a file that an expanded ignored directory -// gets or loses is a change. An edit of a shown ignored file is also a -// change, as in herdr-reviewr. -func TestWatcherShown(t *testing.T) { +// TestWatcherIncluded checks that, after Include, Changed reports files +// added to or removed from an ignored directory, and edits to an ignored +// file or to the target of a symbolic link. Include itself is not a +// change. +func TestWatcherIncluded(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) w := newWatcher(t, r) testgit.WriteFile(t, dir, "build/new", "x\n") if got := changed(t, w); got.Files { - t.Errorf("a new file in a hidden build: %+v, want no Files", got) + t.Errorf("Changed after a new file in a hidden build = %+v, want no Files", got) } - w.Show([]string{"build"}) + w.Include([]string{"build"}) if got := changed(t, w); got.Files { - t.Errorf("after Show: %+v, want no Files", got) + t.Errorf("Changed after Include = %+v, want no Files", got) } testgit.WriteFile(t, dir, "build/more", "x\n") if got := changed(t, w); !got.Files { - t.Errorf("a new file in a shown build: %+v, want Files", got) + t.Errorf("Changed after a new file in a shown build = %+v, want Files", got) } if err := os.Remove(filepath.Join(dir, "build", "more")); err != nil { t.Fatal(err) } if got := changed(t, w); !got.Files { - t.Errorf("a removed file in a shown build: %+v, want Files", got) + t.Errorf("Changed after a removed file in a shown build = %+v, want Files", got) } - w.Show(nil) + w.Include(nil) testgit.WriteFile(t, dir, "build/more", "x\n") if got := changed(t, w); got.Files { - t.Errorf("a new file after Show(nil): %+v, want no Files", got) + t.Errorf("Changed after a new file after Include(nil) = %+v, want no Files", got) } - w.Show([]string{"ignored.txt"}) + w.Include([]string{"ignored.txt"}) testgit.WriteFile(t, dir, "ignored.txt", "an edit\n") if got := changed(t, w); !got.Files { - t.Errorf("an edit of a shown file: %+v, want Files", got) + t.Errorf("Changed after an edit of a shown file = %+v, want Files", got) } if err := os.Symlink("ignored.txt", filepath.Join(dir, "link")); err != nil { t.Fatal(err) } - w.Show([]string{"link"}) + w.Include([]string{"link"}) changed(t, w) testgit.WriteFile(t, dir, "ignored.txt", "one more edit\n") if got := changed(t, w); !got.Files { - t.Errorf("an edit of the target of a shown link: %+v, want Files", got) + t.Errorf("Changed after an edit of the target of a shown link = %+v, want Files", got) } } -// TestWatcherNested checks that a commit in a nested repository or in a -// submodule is a change. +// TestWatcherNested checks that Changed reports a commit in a nested +// repository or in a submodule. func TestWatcherNested(t *testing.T) { t.Parallel() for _, sub := range []bool{false, true} { - name := map[bool]string{false: "nested", true: "submodule"}[sub] + name := "nested" + if sub { + name = "submodule" + } t.Run(name, func(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) other := t.TempDir() testgit.InitRepo(t, other) testgit.WriteFile(t, other, "f", "1\n") @@ -237,7 +255,7 @@ func TestWatcherNested(t *testing.T) { testgit.WriteFile(t, inner, "f", "2\n") testgit.Git(t, inner, "commit", "-qam", "2") if got := changed(t, w); !got.Files { - t.Errorf("after a commit in %s: %+v, want Files", name, got) + t.Errorf("Changed after a commit in %s = %+v, want Files", name, got) } }) } @@ -248,28 +266,28 @@ func TestWatcherNested(t *testing.T) { func TestWatcherRunsNoGit(t *testing.T) { t.Parallel() env, link := gitLink(t) - r, _ := newRepoEnv(t, env) + r, _ := openTestRepoEnv(t, env) w := newWatcher(t, r) removeGit(t, link) for range 3 { ch, err := w.Changed(t.Context()) if err != nil || ch != (vcs.Change{}) { - t.Fatalf("idle call: %+v, %v, want none, nil", ch, err) + t.Fatalf("idle Changed call = %+v, %v, want none, nil", ch, err) } } } -// TestWatcherBudget checks that a call waits 20 times as long as the last -// call took. +// TestWatcherBudget checks that after a call, Changed reports nothing for +// 100 times as long as that call took, and reports the change after that. func TestWatcherBudget(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) w, err := r.Watch(t.Context()) if err != nil { t.Fatal(err) } - // Each reading of the clock moves it by 10 ms, so each call takes 10 - // ms and the next call waits 200 ms. + // Each read of the clock advances it by 10 ms, so each call takes + // 10 ms and the next check waits 1 s. now := time.Now() w.SetClock(func() time.Time { now = now.Add(10 * time.Millisecond) @@ -278,17 +296,31 @@ func TestWatcherBudget(t *testing.T) { changed(t, w) testgit.WriteFile(t, dir, "c.txt", "c\n") if got := changed(t, w); got != (vcs.Change{}) { - t.Errorf("call in the pause: %+v, want none", got) + t.Errorf("Changed in the pause = %+v, want none", got) } - now = now.Add(200 * time.Millisecond) + now = now.Add(time.Second) if got := changed(t, w); !got.Files { - t.Errorf("call after the pause: %+v, want Files", got) + t.Errorf("Changed after the pause = %+v, want Files", got) + } + // A call that takes 1 s would make the next one wait 100 s, but the + // pause is at most 30 s. + step := time.Second + w.SetClock(func() time.Time { + now = now.Add(step) + return now + }) + changed(t, w) + step = 0 + testgit.WriteFile(t, dir, "d.txt", "d\n") + now = now.Add(30 * time.Second) + if got := changed(t, w); !got.Files { + t.Errorf("Changed after 30 s = %+v, want Files", got) } } -// TestWatcherUntrackedDir checks that a new file in an untracked directory -// that git lists no file in is a change. A new file in an ignored -// directory in it is not. +// TestWatcherUntrackedDir checks that Changed reports a new file in an +// untracked directory that git lists no files for, but not a new file in +// an ignored directory inside it. Names that start with ":" must work too. func TestWatcherUntrackedDir(t *testing.T) { t.Parallel() tests := []struct { @@ -299,14 +331,14 @@ func TestWatcherUntrackedDir(t *testing.T) { {"only ignored files", "g/ignored.txt", "g/c.txt", true}, {"empty below empty", "d/e/", "d/e/c.txt", true}, {"ignored below untracked", "u/build/", "u/build/c.txt", false}, - // A path that starts with ":" is pathspec magic to git. + // git treats a path that starts with ":" as pathspec magic. {"colon in the name", ":(x/e/", ":(x/e/c.txt", true}, {"colon and exclude", ":!x/e/", ":!x/e/c.txt", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - r, dir := newRepo(t) + r, dir := openTestRepo(t) if d, ok := strings.CutSuffix(tt.setup, "/"); ok { if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil { t.Fatal(err) @@ -317,8 +349,51 @@ func TestWatcherUntrackedDir(t *testing.T) { w := newWatcher(t, r) testgit.WriteFile(t, dir, tt.edit, "c\n") if got := changed(t, w); got.Files != tt.want { - t.Errorf("after a new %s: %+v, want Files %v", tt.edit, got, tt.want) + t.Errorf("Changed after a new %s = %+v, want Files %v", tt.edit, got, tt.want) } }) } } + +// TestWatcherConcurrent checks that Include and LastWrite can run while +// another goroutine calls Changed, and that Changed then still reports an +// edit to one of many files. Run it with -race. +func TestWatcherConcurrent(t *testing.T) { + t.Parallel() + r, dir := openTestRepo(t) + for i := range 50 { + testgit.WriteFile(t, dir, fmt.Sprintf("many/f%02d.txt", i), "x\n") + } + testgit.Git(t, dir, "add", ".") + testgit.Git(t, dir, "commit", "-qm", "many") + w := newWatcher(t, r) + if err := os.Mkdir(filepath.Join(dir, "new"), 0o755); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + wg.Go(func() { + for i := range 20 { + // Each new file makes Changed list the files again. + p := filepath.Join(dir, "new", fmt.Sprintf("n%02d.txt", i)) + if err := os.WriteFile(p, []byte("n\n"), 0o644); err != nil { + t.Error(err) + return + } + if _, err := w.Changed(t.Context()); err != nil { + t.Error(err) + return + } + } + }) + for range 20 { + w.Include([]string{"build"}) + w.LastWrite(time.Now()) + w.Include(nil) + } + wg.Wait() + changed(t, w) + testgit.WriteFile(t, dir, "many/f37.txt", "an edit\n") + if got := changed(t, w); !got.Files { + t.Errorf("Changed after an edit of many/f37.txt = %+v, want Files", got) + } +}