From 727382c2283c8969ddcfbbb73b1cf28f2982fe23 Mon Sep 17 00:00:00 2001 From: Redowan Delowar Date: Thu, 29 Oct 2026 18:15:11 +0100 Subject: [PATCH 1/4] feat: fast and bounded review of large diffs, a roomier layout, and a settled library API Diff - Use znkr.io/diff in place of go-udiff. Diffs match git, also for large rewrites. A file over 10,000 lines uses the fast diff. - A file version over 2,000,000 bytes or 50,000 lines shows "file too large to show". A diff holds at most 128 MiB of text, and a change of more than 20,000 files shows only the count. Speed - Render lines only when they come into view, and highlight in the background with time limits. Slow lexers and very long lines show plain. - Read blobs in parallel with a cache. List files, watch, and load the commit picker off the UI goroutine. Reloads wait less after an edit. - Cap Markdown size, depth and work, PR text, find, and grep, so no input freezes the UI. Layout - A narrower file list, the full path above the diff, a thinner separator and gutter, and no rule above the footer. - The left arrow closes an expanded fold. The hover tooltip is gone. Fixes - Scroll through a wrapped line taller than the pane. Keep the cursor visible in a one-row body and in a tall rendered block. - Show invisible characters in names and code, and cut wide names to the list width. - ] and [ step over removed blocks in rendered Markdown. - The PR tab counts every check, and names the failed ones. - Send a review over 64 KB in parts. - -log files are private, hold no typed text, and a FIFO with no reader fails at once. - -check validates -scope and -layout. A branch with no commits, a binary file, and tiny panes show clear notes. - A file created and deleted around a reload leaves the list. API - diff: real Row, Fold, Kind and Side types; RenderFile takes FileOptions; SplitLines; always lazy. - vcs: PR with Tally, File.Err, TooManyFilesError.Count, Watcher.Reset. - rex: New and update.NewInstaller return (value, ok); Paste sends in parts; update.Restart takes env first. - Code names follow the herdr-reviewr settings (navigator, hunk, scope, allFiles). Settings, keys, flags and resume state are unchanged. --- cmd/rexdiff/install.go | 205 ++- cmd/rexdiff/main.go | 170 +- diff/bench_test.go | 156 ++ diff/cell.go | 122 +- diff/cell_test.go | 142 +- diff/comment.go | 145 +- diff/comment_test.go | 191 ++- diff/diff.go | 262 ++- diff/diff_test.go | 315 ++-- diff/file.go | 237 ++- diff/fold_test.go | 50 +- diff/fuzz_test.go | 218 ++- diff/hostile_test.go | 150 +- diff/lazy_test.go | 173 ++ diff/main_test.go | 6 + diff/review.go | 44 +- .../testdata/fuzz/FuzzRender/03c1cdf0ef3dea55 | 8 + .../testdata/fuzz/FuzzRender/597030a707a26025 | 8 + .../fuzz/FuzzRenderFile/3ed8df90f10ed889 | 7 + diff/theme.go | 19 +- go.mod | 4 +- go.sum | 4 + internal/diffview/edits.go | 94 ++ internal/diffview/fold.go | 91 +- internal/diffview/highlight.go | 598 +++++-- internal/diffview/render.go | 558 ++++-- internal/diffview/split.go | 70 +- internal/diffview/style.go | 71 +- internal/diffview/text.go | 99 +- internal/diffview/text_test.go | 33 + internal/diffview/view.go | 476 ++++-- internal/editor/editor.go | 68 +- internal/editor/editor_test.go | 77 +- internal/search/match.go | 58 +- internal/search/search.go | 95 +- internal/search/search_test.go | 89 +- internal/turn/turn.go | 72 +- internal/turn/turn_test.go | 72 +- rex/rex.go | 362 ++-- rex/rex_test.go | 166 +- test/changes_test.go | 275 ++- test/cli_test.go | 15 +- test/comment_test.go | 382 ++++- test/fake_test.go | 66 +- test/files_test.go | 172 +- test/main_test.go | 8 +- test/{upgrade_test.go => migratelua_test.go} | 35 +- test/navigator_test.go | 214 +++ test/norace_test.go | 2 +- test/race_test.go | 2 +- test/resize_test.go | 65 + test/rexdiff_test.go | 187 ++- test/testdata/script/cli.txt | 45 + test/update_test.go | 109 +- tui/agentpick.go | 31 +- tui/basepick.go | 54 +- tui/comment.go | 297 ++-- tui/commentlist.go | 107 -- tui/comments.go | 115 ++ tui/commitpick.go | 143 +- tui/config.go | 207 +-- tui/editor.go | 124 +- tui/find.go | 264 ++- tui/keys.go | 704 +++++--- tui/model.go | 1495 +++++++++++------ tui/mouse.go | 318 ++-- tui/nav.go | 1115 +++++++----- tui/navigator.go | 337 ++++ tui/overlay.go | 344 ++-- tui/palette.go | 26 +- tui/pr.go | 369 ++-- tui/preview.go | 100 +- tui/release.go | 67 +- tui/render.go | 1123 ++++++++++--- tui/resume.go | 165 +- tui/search.go | 223 ++- tui/send.go | 189 ++- tui/tree.go | 299 ---- tui/tui.go | 139 +- tui/turn.go | 243 +-- tui/view.go | 826 +++++---- update/update.go | 249 ++- update/update_test.go | 180 +- vcs/export_test.go | 8 +- vcs/hostile_test.go | 163 +- vcs/pr.go | 198 ++- vcs/pr_test.go | 159 +- vcs/refs.go | 234 ++- vcs/repo.go | 610 +++++-- vcs/repo_test.go | 434 +++-- vcs/saved_test.go | 28 +- vcs/watch.go | 298 ++-- vcs/watch_test.go | 209 ++- 93 files changed, 12876 insertions(+), 6680 deletions(-) create mode 100644 diff/bench_test.go create mode 100644 diff/lazy_test.go create mode 100644 diff/testdata/fuzz/FuzzRender/03c1cdf0ef3dea55 create mode 100644 diff/testdata/fuzz/FuzzRender/597030a707a26025 create mode 100644 diff/testdata/fuzz/FuzzRenderFile/3ed8df90f10ed889 create mode 100644 internal/diffview/edits.go create mode 100644 internal/diffview/text_test.go rename test/{upgrade_test.go => migratelua_test.go} (76%) create mode 100644 test/navigator_test.go create mode 100644 test/resize_test.go delete mode 100644 tui/commentlist.go create mode 100644 tui/comments.go create mode 100644 tui/navigator.go delete mode 100644 tui/tree.go 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/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/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/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/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/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/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/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/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/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/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/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) + } +} From 8911fc09e941df284736fa0bf5f11c84d6fc10c6 Mon Sep 17 00:00:00 2001 From: Redowan Delowar Date: Thu, 29 Oct 2026 18:15:11 +0100 Subject: [PATCH 2/4] test: check the limits, the PR checks, the config names and the log - Black-box tests for Markdown depth and size, tall lines, too many files, large files, PR checks past 100, the setting names, and a private log with no typed text. - Library tests for long lines and the highlighter time limits. --- diff/caps_test.go | 57 ++++++ internal/diffview/caps_test.go | 199 +++++++++++++++++++ internal/diffview/norace_test.go | 6 + internal/diffview/race_test.go | 6 + test/caps_test.go | 315 +++++++++++++++++++++++++++++++ test/checks_test.go | 37 ++++ test/config_test.go | 159 ++++++++++++++++ test/limits_test.go | 144 ++++++++++++++ test/log_test.go | 52 +++++ 9 files changed, 975 insertions(+) create mode 100644 diff/caps_test.go create mode 100644 internal/diffview/caps_test.go create mode 100644 internal/diffview/norace_test.go create mode 100644 internal/diffview/race_test.go create mode 100644 test/caps_test.go create mode 100644 test/checks_test.go create mode 100644 test/config_test.go create mode 100644 test/limits_test.go create mode 100644 test/log_test.go 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/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/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/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/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/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/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) + } + } +} From e5d3e02725dfe38192d2b6afe26a0522fe795aa4 Mon Sep 17 00:00:00 2001 From: Redowan Delowar Date: Thu, 29 Oct 2026 18:15:11 +0100 Subject: [PATCH 3/4] test: walk every feature in a real Rex with test/e2e.sh test/e2e.sh builds rexdiff and walks the user guide step by step in its own Rex session, with fake agent, gh, clipboard, editor and browser. It also checks resizes from 1x1 to 300x80, hostile names, a review over 64 KB, and the Rex JSON shapes that the fake rex copies. Each step polls the screen until it is whole and holds the text. It replaces TestRealRex. --- .doc/dev.md | 7 +- .doc/test.md | 14 +- test/e2e.sh | 811 +++++++++++++++++++++++++++++++++++++++++++++++ test/rex_test.go | 122 ------- 4 files changed, 827 insertions(+), 127 deletions(-) create mode 100755 test/e2e.sh delete mode 100644 test/rex_test.go 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/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/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) - } -} From 0004de4bcbcfe0080fadf784736f0917df5871ba Mon Sep 17 00:00:00 2001 From: Redowan Delowar Date: Thu, 29 Oct 2026 18:15:11 +0100 Subject: [PATCH 4/4] test: time rexdiff on large repositories, and add library benchmarks - test/perf_test.go times start-up, keys, reloads, memory and idle CPU. It runs only if REXDIFF_PERF names a directory. - Benchmarks for vcs and the file tree. --- internal/tree/bench_test.go | 19 + test/perf_test.go | 758 ++++++++++++++++++++++++++++++++++++ vcs/bench_test.go | 64 +++ 3 files changed, 841 insertions(+) create mode 100644 internal/tree/bench_test.go create mode 100644 test/perf_test.go create mode 100644 vcs/bench_test.go 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/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/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) + } + } + }) +}