mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-30 11:06:37 -04:00
Compare commits
125
Commits
@@ -63,7 +63,9 @@ Prefer a fine-grained commit history. Commits should be as small as possible
|
||||
while still being meaningful and self-contained.
|
||||
|
||||
- **Every commit must compile and pass all tests.** No "WIP" commits, no
|
||||
commits that leave the tree broken and rely on a follow-up to fix it.
|
||||
commits that leave the tree broken and rely on a follow-up to fix it. A
|
||||
`fixup!` is not such a follow-up; see "Iterate with `fixup!` commits" for
|
||||
what one may leave broken until it is folded in.
|
||||
- **Every commit must be `gofumpt`-formatted.** Run `just format` before
|
||||
committing.
|
||||
- **Every commit must be lint-clean.** Run `just lint` before committing —
|
||||
@@ -134,6 +136,32 @@ target, make the change, `git commit --fixup=<target>`, then
|
||||
branch. The fixup stays a separate, reviewable commit; only its position
|
||||
changes.
|
||||
|
||||
**A fixup may leave commits before it broken until it is folded in.** If a
|
||||
`fixup!` on an early commit deletes something that a later commit still uses,
|
||||
the later commit doesn't build until its own `fixup!`, right behind it, catches
|
||||
up; the same goes for lint. That is expected. The rules above about every
|
||||
commit compiling, testing and linting clean describe the history _after_
|
||||
autosquash, and I fold fixups in soon after reviewing them. Never amend a
|
||||
commit directly, or edit the commits between two fixups, to keep every commit
|
||||
of the un-squashed history green. The reviewable fixup is worth more than a
|
||||
green intermediate state. Verify at each fixup instead, since the tree there
|
||||
is what the folded-in history will have at that point, and say in the handoff
|
||||
which commits stay broken until which fixup.
|
||||
|
||||
**After a mid-stack fixup, check every branch tip above it, not just the stack
|
||||
tip.** A fixup that deletes or renames something rewrites every commit replayed
|
||||
above it, and a commit further up can hide the damage at the tip. A helper
|
||||
whose last caller the fixup deleted is flagged as unused by `just lint` at the
|
||||
tip of its own PR, but a later PR that calls it again makes the stack tip lint
|
||||
clean. Each PR is reviewed and merged on its own, so each PR branch tip has to
|
||||
be green on its own. After the replay, run `just build`, `just unit-test` and
|
||||
`just lint` at each branch tip from the insertion point up. If the fixup deleted
|
||||
or renamed a symbol, also build every replayed commit, for example with
|
||||
`git -c rebase.autosquash=false rebase -x 'go build ./...' <insertion point>`;
|
||||
unchanged commits are fast-forwarded, so their hashes stay, and the commits a
|
||||
fixup is expected to leave broken stop it, so `git rebase --continue` past
|
||||
those.
|
||||
|
||||
If the changes don't map cleanly onto existing commits — say they cut
|
||||
across several of them, or restructure something at a different layer
|
||||
than any existing commit naturally owns — stop and ask the user how to
|
||||
@@ -180,7 +208,7 @@ looks messy. The whole point of a fixup is that the iteration stays
|
||||
**visible and reviewable**; squashing it away yourself destroys exactly the
|
||||
artifact it exists to create. Collapsing fixups into their targets is the
|
||||
user's action, taken once they've reviewed the iterations. Every mention of
|
||||
`--autosquash` in this section describes what the *user* will eventually
|
||||
`--autosquash` in this section describes what the _user_ will eventually
|
||||
run, never a step for you to perform. If you think the history is ready to
|
||||
collapse, say so and leave it to them.
|
||||
|
||||
@@ -320,7 +348,7 @@ refactor to an earlier commit (but don't do it without asking first).
|
||||
## Don't read model state right after a `Refresh`
|
||||
|
||||
A `Refresh` (or `RefreshFromWorker`) does its git work on a worker and then
|
||||
*enqueues* the model update onto the UI thread. So when `Refresh` returns, the
|
||||
_enqueues_ the model update onto the UI thread. So when `Refresh` returns, the
|
||||
model is **not** updated yet — the write is still queued. Reading a field
|
||||
synchronously right after refreshing its scope reads the stale, pre-refresh
|
||||
value (and this is true even for SYNC refreshes):
|
||||
@@ -406,7 +434,7 @@ column. Applies only to `pkg/i18n/english.go`.
|
||||
|
||||
## Code comments are for future readers, not development history
|
||||
|
||||
Comments in source code explain *why this code is shaped the way it is*. They
|
||||
Comments in source code explain _why this code is shaped the way it is_. They
|
||||
are not the place to narrate the path we took during development — what was
|
||||
tried first, what didn't work, what's "more reliable" or "cleaner" than some
|
||||
alternative. That framing is interesting in the moment, but it's noise to
|
||||
@@ -422,7 +450,7 @@ Avoid phrasings like:
|
||||
- "X rather than Y", where Y is what the code did before the change
|
||||
|
||||
The iteration story is sometimes worth preserving — but it belongs in the
|
||||
commit message, which is the durable record of *why this change was made*. The
|
||||
commit message, which is the durable record of _why this change was made_. The
|
||||
code comment should make sense to someone who has never seen any prior version
|
||||
and is just trying to understand the file as it currently exists.
|
||||
|
||||
@@ -475,7 +503,7 @@ So:
|
||||
struct, run `just generate` and include the regenerated
|
||||
`docs-master/Config.md` (and `schema-master/config.json`) in your commit.
|
||||
- Don't hard-wrap the doc comments on `userConfig` fields. This applies
|
||||
*only* to `userConfig`, because those comments are fed through the doc
|
||||
_only_ to `userConfig`, because those comments are fed through the doc
|
||||
generator; comments on every other struct follow the normal Go wrapping
|
||||
conventions. For `userConfig` fields, write each sentence (or paragraph)
|
||||
as a single unwrapped line, however long — the generator re-wraps them for
|
||||
|
||||
@@ -68,10 +68,6 @@ bump-lazycore:
|
||||
record-demo:
|
||||
demo/record_demo.sh $(filter-out $@,$(MAKECMDGOALS))
|
||||
|
||||
.PHONY: rerecord-demos
|
||||
rerecord-demos:
|
||||
demo/rerecord_demos.sh $(filter-out $@,$(MAKECMDGOALS))
|
||||
|
||||
.PHONY: vendor
|
||||
vendor:
|
||||
go mod tidy && go mod vendor
|
||||
|
||||
@@ -47,8 +47,7 @@ A simple terminal UI for git commands
|
||||
|
||||
[](https://github.com/jesseduffield/lazygit/releases) [](https://goreportcard.com/report/github.com/jesseduffield/lazygit) [](https://app.codacy.com/gh/jesseduffield/lazygit/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [](https://app.codacy.com/gh/jesseduffield/lazygit/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage) [](https://golangci-lint.run/) [](https://github.com/jesseduffield/lazygit/releases/latest) [](https://formulae.brew.sh/formula/lazygit)
|
||||
|
||||
<!-- demo: commit_and_push -->
|
||||
<video src="https://github.com/user-attachments/assets/a1f28c26-7545-4ba8-ae79-b37f848b1b8e" controls></video>
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// author_colors_repo creates a git repository for checking that the colors
|
||||
// lazygit gives to authors are readable.
|
||||
//
|
||||
// If gui.authorColors names no color for an author, lazygit derives one from a
|
||||
// hash of their name. The authors of an ordinary repository rarely land near the
|
||||
// edges of the range that this color is picked from. Every commit in the
|
||||
// repository created here is by an author at one of those edges: the lowest or
|
||||
// highest lightness, combined with the lowest or highest saturation, at twelve
|
||||
// hues around the color wheel. The commit subject says which edge it is.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/author_colors_repo <path>
|
||||
//
|
||||
// Then open the repository with lazygit in each terminal theme you want to check.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
|
||||
)
|
||||
|
||||
type extreme int
|
||||
|
||||
const (
|
||||
lowest extreme = iota
|
||||
highest
|
||||
)
|
||||
|
||||
func (self extreme) String() string {
|
||||
if self == lowest {
|
||||
return "min"
|
||||
}
|
||||
return "max"
|
||||
}
|
||||
|
||||
func (self extreme) matches(fraction float64) bool {
|
||||
if self == lowest {
|
||||
return fraction < 0.01
|
||||
}
|
||||
return fraction >= 0.99
|
||||
}
|
||||
|
||||
const (
|
||||
numHues = 12
|
||||
|
||||
// Small enough that the windows of neighbouring hues don't overlap
|
||||
hueTolerance = 0.02
|
||||
)
|
||||
|
||||
type commit struct {
|
||||
author string
|
||||
subject string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
log.Fatalf("usage: %s <path>", os.Args[0])
|
||||
}
|
||||
path := os.Args[1]
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
log.Fatalf("%s already exists", path)
|
||||
}
|
||||
|
||||
extremes := []extreme{lowest, highest}
|
||||
commits := make([]commit, 0, len(extremes)*len(extremes)*numHues)
|
||||
for _, lightness := range extremes {
|
||||
for _, saturation := range extremes {
|
||||
for i := range numHues {
|
||||
hue := float64(i) / numHues
|
||||
author, actualHue := findAuthor(lightness, saturation, hue)
|
||||
subject := fmt.Sprintf("lightness %s, saturation %s, hue %.0f°", lightness, saturation, actualHue*360)
|
||||
commits = append(commits, commit{author: author, subject: subject})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := runGit("", nil, "init", "-q", path); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Commit in reverse, so that the commits panel lists them in the order above
|
||||
startTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := range commits {
|
||||
c := commits[len(commits)-1-i]
|
||||
date := fmt.Sprintf("%d +0000", startTime.Add(time.Duration(i)*time.Hour).Unix())
|
||||
env := []string{
|
||||
"GIT_AUTHOR_NAME=" + c.author,
|
||||
"GIT_AUTHOR_EMAIL=author@example.com",
|
||||
"GIT_AUTHOR_DATE=" + date,
|
||||
"GIT_COMMITTER_NAME=" + c.author,
|
||||
"GIT_COMMITTER_EMAIL=author@example.com",
|
||||
"GIT_COMMITTER_DATE=" + date,
|
||||
}
|
||||
if err := runGit(path, env, "commit", "-q", "--allow-empty", "-m", c.subject); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Created %s with %d commits\n", path, len(commits))
|
||||
}
|
||||
|
||||
// findAuthor returns the first name of the form "L<lightness> S<saturation> <n>"
|
||||
// whose color lies at the given extremes of lightness and saturation, and close
|
||||
// to the given hue. It also returns the hue that the name lands on.
|
||||
//
|
||||
// Every name of this form has the same initials, so the color is the only
|
||||
// thing that differs between authors in the commits panel.
|
||||
func findAuthor(lightness extreme, saturation extreme, hue float64) (string, float64) {
|
||||
for n := 1; ; n++ {
|
||||
name := fmt.Sprintf("L%s S%s %d", lightness, saturation, n)
|
||||
h, s, l := authors.ColorPosition(name)
|
||||
if lightness.matches(l) && saturation.matches(s) && hueDistance(h, hue) <= hueTolerance {
|
||||
return name, h
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hueDistance is the distance between two hues on the color wheel, where each
|
||||
// hue is a fraction of a full turn.
|
||||
func hueDistance(a float64, b float64) float64 {
|
||||
d := math.Abs(a - b)
|
||||
return math.Min(d, 1-d)
|
||||
}
|
||||
|
||||
func runGit(dir string, env []string, args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
// Keep the user's git config out of it, so that hooks, commit signing and
|
||||
// the like don't apply, and every run creates the same commits
|
||||
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_CONFIG_NOSYSTEM=1")
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# Specify a command to be executed
|
||||
# like `/bin/bash -l`, `ls`, or any other commands
|
||||
# the default is bash for Linux
|
||||
# or powershell.exe for Windows
|
||||
command: echo "YOU NEED TO SPECIFY YOUR OWN COMMAND WITH THE -d ARG"
|
||||
|
||||
# Specify the current working directory path
|
||||
# the default is the current working directory path
|
||||
cwd: null
|
||||
|
||||
# Export additional ENV variables
|
||||
env:
|
||||
recording: true
|
||||
|
||||
# Explicitly set the number of columns
|
||||
# or use `auto` to take the current
|
||||
# number of columns of your shell
|
||||
cols: 120 # 100
|
||||
|
||||
# Explicitly set the number of rows
|
||||
# or use `auto` to take the current
|
||||
# number of rows of your shell
|
||||
rows: 35 # 30
|
||||
|
||||
# Amount of times to repeat GIF
|
||||
# If value is -1, play once
|
||||
# If value is 0, loop indefinitely
|
||||
# If value is a positive number, loop n times
|
||||
repeat: 0
|
||||
|
||||
# Quality
|
||||
# 1 - 100
|
||||
# Higher quality seems to make no difference, but running it through
|
||||
# gifsicle ends up with a much better compressed version.
|
||||
quality: 100
|
||||
|
||||
# Delay between frames in ms
|
||||
# If the value is `auto` use the actual recording delays
|
||||
frameDelay: auto
|
||||
|
||||
# Maximum delay between frames in ms
|
||||
# Ignored if the `frameDelay` isn't set to `auto`
|
||||
# Set to `auto` to prevent limiting the max idle time
|
||||
maxIdleTime: 2000
|
||||
|
||||
# The surrounding frame box
|
||||
# The `type` can be null, window, floating, or solid`
|
||||
# To hide the title use the value null
|
||||
# Don't forget to add a backgroundColor style with a null as type
|
||||
frameBox:
|
||||
type: floating
|
||||
title: Lazygit
|
||||
style:
|
||||
border: 0px black solid
|
||||
backgroundColor: "#1d1d1d"
|
||||
margin: -5px
|
||||
|
||||
# Add a watermark image to the rendered gif
|
||||
# You need to specify an absolute path for
|
||||
# the image on your machine or a URL, and you can also
|
||||
# add your own CSS styles
|
||||
watermark:
|
||||
imagePath: null
|
||||
style:
|
||||
position: absolute
|
||||
right: 15px
|
||||
bottom: 15px
|
||||
width: 100px
|
||||
opacity: 0.9
|
||||
|
||||
# Cursor style can be one of
|
||||
# `block`, `underline`, or `bar`
|
||||
cursorStyle: block
|
||||
|
||||
# Font family
|
||||
# You can use any font that is installed on your machine
|
||||
# in CSS-like syntax
|
||||
# Download from:
|
||||
# https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/DejaVuSansMono.zip
|
||||
# Not using the mono font because it makes icons too small.
|
||||
fontFamily: "DejaVuSansM Nerd Font"
|
||||
|
||||
# The size of the font
|
||||
fontSize: 8
|
||||
|
||||
# The height of lines
|
||||
lineHeight: 1
|
||||
|
||||
# The spacing between letters
|
||||
letterSpacing: 0
|
||||
|
||||
# Theme
|
||||
theme:
|
||||
background: "transparent"
|
||||
foreground: "#dddad6"
|
||||
cursor: "#c7c7c7"
|
||||
black: "#7a7a7a"
|
||||
red: "#fc4384"
|
||||
green: "#b3e33b"
|
||||
yellow: "#ffa727"
|
||||
blue: "#102895"
|
||||
magenta: "#c930c7"
|
||||
cyan: "#00c5c7"
|
||||
white: "#c7c7c7"
|
||||
brightBlack: "#676767"
|
||||
brightRed: "#ff7fac"
|
||||
brightGreen: "#c8ed71"
|
||||
brightYellow: "#ebdf86"
|
||||
brightBlue: "#6871ff"
|
||||
brightMagenta: "#ff76ff"
|
||||
brightCyan: "#5ffdff"
|
||||
brightWhite: "#fffefe"
|
||||
+46
-172
@@ -2,72 +2,55 @@
|
||||
|
||||
set -e
|
||||
|
||||
# The repository the demo is uploaded to. GitHub only plays videos that live in
|
||||
# its own attachment store, and an attachment is tied to one repository.
|
||||
REPO=jesseduffield/lazygit
|
||||
|
||||
# The issue that collects the demo recordings. Posting a comment there is what
|
||||
# makes an uploaded video readable by people who are not signed in to GitHub.
|
||||
# It can stay closed; commenting on a closed issue publishes the video just as
|
||||
# well, and does not reopen it.
|
||||
PUBLISH_ISSUE=6051
|
||||
TYPE=$1
|
||||
TEST=$2
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [--no-upload] <test path>"
|
||||
echo "e.g. $0 pkg/integration/tests/demo/nuke_working_tree.go"
|
||||
echo
|
||||
echo "--no-upload leaves the video in demo/output and stops there, for"
|
||||
echo "checking how a change to demo/settings.tape turns out."
|
||||
echo "Usage: $0 [gif|mp4] <test path>"
|
||||
echo "e.g. using full path: $0 gif pkg/integration/tests/demo/nuke_working_tree.go"
|
||||
exit 1
|
||||
}
|
||||
|
||||
UPLOAD=true
|
||||
|
||||
if [ "$1" = "--no-upload" ]
|
||||
then
|
||||
UPLOAD=false
|
||||
shift
|
||||
fi
|
||||
|
||||
TEST=$1
|
||||
|
||||
if [ "$#" -ne 1 ]
|
||||
if [ "$#" -ne 2 ]
|
||||
then
|
||||
usage
|
||||
fi
|
||||
|
||||
TOOLS="vhs ttyd ffmpeg"
|
||||
|
||||
if [ "$UPLOAD" = true ]
|
||||
if [ "$TYPE" != "gif" ] && [ "$TYPE" != "mp4" ]
|
||||
then
|
||||
TOOLS="$TOOLS gh"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for TOOL in $TOOLS
|
||||
do
|
||||
if ! command -v "$TOOL" > /dev/null 2>&1
|
||||
then
|
||||
echo "$TOOL could not be found"
|
||||
echo "Install it with: brew install $TOOL"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$UPLOAD" = true ]
|
||||
if [ -z "$TEST" ]
|
||||
then
|
||||
WORKTREE_PATH=$(git worktree list | grep assets | awk '{print $1}')
|
||||
usage
|
||||
fi
|
||||
|
||||
if [ -z "$WORKTREE_PATH" ]
|
||||
then
|
||||
echo "Could not find assets worktree. You'll need to create a worktree for the assets branch using the following command:"
|
||||
echo "git worktree add .worktrees/assets assets"
|
||||
echo "The assets branch has no shared history with the main branch: it exists to store assets which are too large to store in the main branch."
|
||||
exit 1
|
||||
fi
|
||||
WORKTREE_PATH=$(git worktree list | grep assets | awk '{print $1}')
|
||||
|
||||
OUTPUT_DIR="$WORKTREE_PATH/demo"
|
||||
else
|
||||
OUTPUT_DIR=demo/output
|
||||
if [ -z "$WORKTREE_PATH" ]
|
||||
then
|
||||
echo "Could not find assets worktree. You'll need to create a worktree for the assets branch using the following command:"
|
||||
echo "git worktree add .worktrees/assets assets"
|
||||
echo "The assets branch has no shared history with the main branch: it exists to store assets which are too large to store in the main branch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUTPUT_DIR="$WORKTREE_PATH/demo"
|
||||
|
||||
if ! command -v terminalizer &> /dev/null
|
||||
then
|
||||
echo "terminalizer could not be found"
|
||||
echo "Install it with: npm install -g terminalizer"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v "gifsicle" &> /dev/null
|
||||
then
|
||||
echo "gifsicle could not be found"
|
||||
echo "Install it with: npm install -g gifsicle"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get last part of the test path and set that as the output name
|
||||
@@ -80,128 +63,19 @@ go generate pkg/integration/tests/tests.go
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
SCRATCH=$(mktemp -d)
|
||||
trap 'rm -rf "$SCRATCH"' EXIT
|
||||
# First we record the demo into a yaml representation
|
||||
terminalizer -c demo/config.yml record --skip-sharing -d "go run cmd/integration_test/main.go cli --slow $TEST" "$OUTPUT_DIR/$NAME"
|
||||
# Then we render it into a gif
|
||||
terminalizer render "$OUTPUT_DIR/$NAME" -o "$OUTPUT_DIR/$NAME.gif"
|
||||
|
||||
TAPE="$SCRATCH/$NAME.tape"
|
||||
RECORDING="$SCRATCH/$NAME.mp4"
|
||||
OUTPUT="$OUTPUT_DIR/$NAME.mp4"
|
||||
|
||||
# The two quotes in the marker keep the literal VHSDONE out of the command line
|
||||
# that stays on screen while we wait for the marker to be printed.
|
||||
cat > "$TAPE" <<EOF
|
||||
Output "$RECORDING"
|
||||
|
||||
Source demo/settings.tape
|
||||
|
||||
# The command is typed while the recording is hidden, so there is nothing to
|
||||
# gain from animating it.
|
||||
Set TypingSpeed 0ms
|
||||
|
||||
Hide
|
||||
Type "go run cmd/integration_test/main.go cli --slow $TEST; echo VHS''DONE"
|
||||
Enter
|
||||
Wait+Screen@180s /Local branches/
|
||||
Show
|
||||
Wait+Screen@600s /VHSDONE/
|
||||
Hide
|
||||
EOF
|
||||
|
||||
vhs "$TAPE"
|
||||
|
||||
if [ ! -f "$RECORDING" ]
|
||||
# Then we convert it to either an mp4 or gif based on the command line argument
|
||||
if [ "$TYPE" = "mp4" ]
|
||||
then
|
||||
echo "vhs recorded the demo but wrote no video."
|
||||
echo "vhs 0.12.0 does this; see https://github.com/charmbracelet/vhs/issues/787."
|
||||
echo "Install a working version with: go install github.com/charmbracelet/vhs@v0.11.0"
|
||||
exit 1
|
||||
COMPRESSED_PATH="$OUTPUT_DIR/$NAME.mp4"
|
||||
ffmpeg -y -i "$OUTPUT_DIR/$NAME.gif" -movflags faststart -pix_fmt yuv420p -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" "$COMPRESSED_PATH"
|
||||
else
|
||||
COMPRESSED_PATH="$OUTPUT_DIR/$NAME-compressed.gif"
|
||||
gifsicle --colors 256 --use-col=web -O3 < "$OUTPUT_DIR/$NAME.gif" > "$COMPRESSED_PATH"
|
||||
fi
|
||||
|
||||
# The browser draws its playback controls over the bottom of the video, and
|
||||
# they are tall enough to hide lazygit's caption line. Pad the frame so that
|
||||
# the caption sits above them. Chrome draws the tallest bar of the three, and
|
||||
# at the width a README gives the video its buttons start to overlap the
|
||||
# caption below about 90px, so there is not much room to trim here. Measure it
|
||||
# again if demo/settings.tape changes the size of the recording.
|
||||
CAPTION_CLEARANCE=90
|
||||
|
||||
BACKGROUND=$(sed -n 's/.*"background": *"\(#[0-9a-fA-F]*\)".*/\1/p' demo/settings.tape)
|
||||
|
||||
if [ -z "$BACKGROUND" ]
|
||||
then
|
||||
echo "Could not read the background colour from demo/settings.tape"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Hold the last frame for a moment so that the end state stays readable, and
|
||||
# move the moov atom to the front so that the video starts playing before it
|
||||
# has fully downloaded.
|
||||
ffmpeg -y -loglevel error -i "$RECORDING" \
|
||||
-vf "tpad=stop_mode=clone:stop_duration=1.2,pad=iw:ih+$CAPTION_CLEARANCE:0:0:color=$BACKGROUND" \
|
||||
-c:v libx264 -crf 23 -preset slow -pix_fmt yuv420p \
|
||||
-movflags +faststart -an "$OUTPUT"
|
||||
|
||||
if [ "$UPLOAD" = false ]
|
||||
then
|
||||
echo "Demo recorded to $OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# GitHub's web editor posts to this endpoint when you drag a file into a
|
||||
# comment box. It is undocumented, but it accepts an ordinary token, so we can
|
||||
# upload from here. You need push access to $REPO for it to work. If the
|
||||
# endpoint ever goes away, drag the video into a comment box on github.com
|
||||
# instead and copy the URL that GitHub inserts.
|
||||
REPOSITORY_ID=$(gh api "repos/$REPO" --jq .id)
|
||||
|
||||
RESPONSE=$(curl --silent --show-error --fail \
|
||||
--request POST \
|
||||
--header "Authorization: Bearer $(gh auth token)" \
|
||||
--header "Accept: application/json" \
|
||||
--header "Content-Type: video/mp4" \
|
||||
--data-binary "@$OUTPUT" \
|
||||
"https://uploads.github.com/user-attachments/assets?name=$NAME.mp4&content_type=video%2Fmp4&repository_id=$REPOSITORY_ID")
|
||||
|
||||
URL=$(echo "$RESPONSE" | sed -e 's/.*"url":"//' -e 's/".*//')
|
||||
|
||||
if [ -z "$URL" ]
|
||||
then
|
||||
echo "Could not read an attachment URL out of GitHub's response:"
|
||||
echo "$RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# An attachment stays private until a posted comment somewhere in the
|
||||
# repository refers to it. Until that happens the video is a 404 for anyone who
|
||||
# is not signed in, and the README shows a broken player. Referring to it once
|
||||
# makes it public for good, even if the comment is deleted afterwards, so we
|
||||
# collect the recordings in one issue and leave the comments in place.
|
||||
gh api "repos/$REPO/issues/$PUBLISH_ISSUE/comments" \
|
||||
--raw-field "body=$NAME
|
||||
|
||||
$URL" > /dev/null
|
||||
|
||||
# Make sure that worked before handing over a URL, because the person recording
|
||||
# the demo is signed in and will not see the failure.
|
||||
ATTEMPT=0
|
||||
while [ "$ATTEMPT" -lt 30 ]
|
||||
do
|
||||
if curl --silent --fail --output /dev/null --max-time 20 --range 0-1 "$URL"
|
||||
then
|
||||
break
|
||||
fi
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$ATTEMPT" -eq 30 ]
|
||||
then
|
||||
echo "$URL is still not readable without signing in to GitHub."
|
||||
echo "Embedding it now would give logged-out readers a broken player."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Demo recorded to $OUTPUT"
|
||||
echo
|
||||
echo "Embed it with:"
|
||||
echo "<video src=\"$URL\" controls></video>"
|
||||
echo "Demo recorded to $COMPRESSED_PATH"
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
# Re-records the demos that a page embeds and points the page at the new
|
||||
# videos. Use it when a change to demo/settings.tape, or to lazygit's
|
||||
# appearance, leaves the existing recordings looking out of date.
|
||||
#
|
||||
# A page names the demo behind each video in a comment above it:
|
||||
#
|
||||
# <!-- demo: commit_and_push -->
|
||||
# <video src="https://github.com/user-attachments/assets/..." controls></video>
|
||||
#
|
||||
# GitHub drops that comment when it renders the page, so it costs the reader
|
||||
# nothing. This script re-records the demo the comment names and rewrites the
|
||||
# URL on the line below it.
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [--no-upload] [page ...]"
|
||||
echo "e.g. $0 README.md"
|
||||
echo
|
||||
echo "Re-records every demo the given pages embed. With no page given,"
|
||||
echo "that means README.md."
|
||||
echo
|
||||
echo "--no-upload leaves the videos in demo/output and the pages untouched,"
|
||||
echo "which is what you want for reviewing a change to demo/settings.tape."
|
||||
exit 1
|
||||
}
|
||||
|
||||
NO_UPLOAD=
|
||||
|
||||
if [ "$1" = "--no-upload" ]
|
||||
then
|
||||
NO_UPLOAD=--no-upload
|
||||
shift
|
||||
fi
|
||||
|
||||
if [ "$1" = "-h" ] || [ "$1" = "--help" ]
|
||||
then
|
||||
usage
|
||||
fi
|
||||
|
||||
if [ ! -x demo/record_demo.sh ]
|
||||
then
|
||||
echo "Run this from the root of the repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$#" -eq 0 ]
|
||||
then
|
||||
set -- README.md
|
||||
fi
|
||||
|
||||
for PAGE in "$@"
|
||||
do
|
||||
if [ ! -f "$PAGE" ]
|
||||
then
|
||||
echo "$PAGE does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NAMES=$(sed -n 's/^<!-- demo: \([A-Za-z0-9_]*\) -->$/\1/p' "$PAGE")
|
||||
|
||||
if [ -z "$NAMES" ]
|
||||
then
|
||||
echo "$PAGE embeds no demos. Each video needs a <!-- demo: <name> -->"
|
||||
echo "comment on the line above it to say which demo it came from."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for NAME in $NAMES
|
||||
do
|
||||
TEST="pkg/integration/tests/demo/$NAME.go"
|
||||
|
||||
if [ ! -f "$TEST" ]
|
||||
then
|
||||
echo "$PAGE asks for a demo called $NAME, but $TEST does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== $NAME ==="
|
||||
|
||||
if [ -n "$NO_UPLOAD" ]
|
||||
then
|
||||
demo/record_demo.sh --no-upload "$TEST"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Keep the recording chatter on screen, since a full run takes a while,
|
||||
# and read the new URL back out of it afterwards.
|
||||
LOG=$(mktemp)
|
||||
demo/record_demo.sh "$TEST" | tee "$LOG"
|
||||
URL=$(sed -n 's/.*<video src="\([^"]*\)".*/\1/p' "$LOG")
|
||||
rm -f "$LOG"
|
||||
|
||||
if [ -z "$URL" ]
|
||||
then
|
||||
echo "Recording $NAME produced no URL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
awk -v marker="<!-- demo: $NAME -->" -v url="$URL" '
|
||||
hit { sub(/src="[^"]*"/, "src=\"" url "\""); hit = 0 }
|
||||
$0 == marker { hit = 1 }
|
||||
{ print }
|
||||
' "$PAGE" > "$PAGE.new"
|
||||
|
||||
mv "$PAGE.new" "$PAGE"
|
||||
|
||||
echo "$PAGE now points at $URL"
|
||||
done
|
||||
done
|
||||
@@ -1,46 +0,0 @@
|
||||
# Terminal settings shared by all demo recordings. The tape that
|
||||
# demo/record_demo.sh generates for a demo sources this file.
|
||||
|
||||
# Use the non-Mono Nerd Font variant; the Mono variant renders the icons too
|
||||
# small.
|
||||
Set FontFamily "SauceCodePro NF"
|
||||
Set FontSize 24
|
||||
Set LineHeight 1.0
|
||||
Set Padding 20
|
||||
|
||||
# vhs sizes the terminal in pixels rather than in cells, so these two numbers
|
||||
# are what produce a 120x35 grid at the font settings above. If you change the
|
||||
# font, the font size or the padding, record a tape that runs `stty size` and
|
||||
# adjust them until the grid is 120x35 again.
|
||||
Set Width 1866
|
||||
Set Height 1140
|
||||
|
||||
# There is no frame rate setting here because vhs ignores `Set Framerate` when
|
||||
# it writes a video; it captures at 25 fps either way.
|
||||
|
||||
# The frame around the focused view is bold green, and xterm.js draws bold text
|
||||
# in the bright variant of a colour, so brightGreen is the one those frames end
|
||||
# up using.
|
||||
|
||||
Set Theme {
|
||||
"background": "#1d1d1d",
|
||||
"foreground": "#dddad6",
|
||||
"cursor": "#c7c7c7",
|
||||
"selection": "#44475a",
|
||||
"black": "#7a7a7a",
|
||||
"red": "#fc4384",
|
||||
"green": "#3fb950",
|
||||
"yellow": "#ffa727",
|
||||
"blue": "#102895",
|
||||
"magenta": "#c930c7",
|
||||
"cyan": "#00c5c7",
|
||||
"white": "#c7c7c7",
|
||||
"brightBlack": "#676767",
|
||||
"brightRed": "#ff7fac",
|
||||
"brightGreen": "#56d364",
|
||||
"brightYellow": "#ebdf86",
|
||||
"brightBlue": "#6871ff",
|
||||
"brightMagenta": "#ff76ff",
|
||||
"brightCyan": "#5ffdff",
|
||||
"brightWhite": "#fffefe"
|
||||
}
|
||||
+90
-23
@@ -37,12 +37,6 @@ This is only meant as a reference for what config options exist, and what their
|
||||
```yaml
|
||||
# Config relating to the Lazygit UI
|
||||
gui:
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
|
||||
authorColors: {}
|
||||
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
|
||||
branchColorPatterns: {}
|
||||
|
||||
# Custom icons for filenames and file extensions
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color
|
||||
customIcons:
|
||||
@@ -169,6 +163,14 @@ gui:
|
||||
# Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format
|
||||
shortTimeFormat: 3:04PM
|
||||
|
||||
# Whether the terminal has a dark or a light background. This decides whether
|
||||
# 'darkTheme' or 'lightTheme' applies, and the colors of authors are picked to
|
||||
# stand out against it.
|
||||
# One of: 'auto' (default) | 'dark' | 'light'
|
||||
# With 'auto', lazygit asks the terminal, and assumes a dark background if the
|
||||
# terminal doesn't tell.
|
||||
colorScheme: auto
|
||||
|
||||
# Config relating to colors and styles.
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes
|
||||
theme:
|
||||
@@ -179,7 +181,7 @@ gui:
|
||||
|
||||
# Border color of non-focused windows
|
||||
inactiveBorderColor:
|
||||
- default
|
||||
- dim
|
||||
|
||||
# Border color of focused window when searching in that window
|
||||
searchingActiveBorderColor:
|
||||
@@ -190,14 +192,22 @@ gui:
|
||||
optionsTextColor:
|
||||
- blue
|
||||
|
||||
# Color and attributes of the text of the selected line. The attributes are
|
||||
# added to those of the text, and a color replaces the colors of the text.
|
||||
# Set it to 'default' to leave the text as it is, e.g. if you don't want the
|
||||
# selected line in bold.
|
||||
selectedLineFgColor:
|
||||
- bold
|
||||
|
||||
# Background color of selected line.
|
||||
# Default: 'blue' if the terminal has a dark background, or a suitable RGB blue
|
||||
# computed from the background color if it is light.
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line
|
||||
selectedLineBgColor:
|
||||
- blue
|
||||
selectedLineBgColor: []
|
||||
|
||||
# Background color of selected line when view doesn't have focus.
|
||||
inactiveViewSelectedLineBgColor:
|
||||
- bold
|
||||
# Default: a suitable RGB grey computed from the terminal's background color.
|
||||
inactiveViewSelectedLineBgColor: []
|
||||
|
||||
# Foreground color of copied commit
|
||||
cherryPickedCommitFgColor:
|
||||
@@ -223,6 +233,22 @@ gui:
|
||||
defaultFgColor:
|
||||
- default
|
||||
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
|
||||
authorColors: {}
|
||||
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
|
||||
branchColorPatterns: {}
|
||||
|
||||
# Colors and styles that override those in 'theme' when the terminal has a dark
|
||||
# background. It has the same fields as 'theme'.
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
|
||||
darkTheme: {}
|
||||
|
||||
# Colors and styles that override those in 'theme' when the terminal has a light
|
||||
# background. It has the same fields as 'theme'.
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
|
||||
lightTheme: {}
|
||||
|
||||
# Config relating to the commit length indicator
|
||||
commitLength:
|
||||
# If true, show an indicator of commit message length
|
||||
@@ -443,7 +469,9 @@ git:
|
||||
# If not "none", lazygit will automatically fast-forward local branches to match
|
||||
# their upstream after fetching. Applies to branches that are not the currently
|
||||
# checked out branch, and only to those that are strictly behind their upstream
|
||||
# (as opposed to diverged).
|
||||
# (as opposed to diverged). A branch that is checked out in another worktree is
|
||||
# fast-forwarded there, unless that worktree has changes to tracked files or is
|
||||
# in the middle of a rebase or bisect.
|
||||
# Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
|
||||
autoForwardBranches: onlyMainBranches
|
||||
|
||||
@@ -817,6 +845,8 @@ keybinding:
|
||||
main:
|
||||
prevHunk: [<left>, h]
|
||||
nextHunk: [<right>, l]
|
||||
prevFile: "N"
|
||||
nextFile: "n"
|
||||
toggleSelectHunk: a
|
||||
pickBothHunks: b
|
||||
editSelectHunk: E
|
||||
@@ -999,6 +1029,7 @@ The available attributes are:
|
||||
|
||||
- bold
|
||||
- default
|
||||
- dim # faint text; not supported by every terminal
|
||||
- reverse # useful for high-contrast
|
||||
- underline
|
||||
- strikethrough
|
||||
@@ -1023,28 +1054,61 @@ gui:
|
||||
- reverse
|
||||
```
|
||||
|
||||
The text of the selected line is bold by default. If you don't want that, set `selectedLineFgColor` to `default`:
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
theme:
|
||||
selectedLineFgColor:
|
||||
- default
|
||||
```
|
||||
|
||||
## Themes for dark and light backgrounds
|
||||
|
||||
The colors in `gui.theme` apply whether your terminal has a dark or a light background. If you want different colors for the two, set them in `gui.darkTheme` or `gui.lightTheme`. These have the same fields as `gui.theme`, and a field that you set in them overrides the one in `gui.theme`:
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
theme:
|
||||
activeBorderColor:
|
||||
- green
|
||||
- bold
|
||||
lightTheme:
|
||||
activeBorderColor:
|
||||
- blue
|
||||
- bold
|
||||
```
|
||||
|
||||
For `authorColors` and `branchColorPatterns`, each entry overrides the one with the same key in `gui.theme`, and the other entries of `gui.theme` still apply. Branch color patterns of `gui.darkTheme` or `gui.lightTheme` come before those of `gui.theme`.
|
||||
|
||||
Lazygit asks the terminal whether its background is dark or light. If your terminal doesn't tell, lazygit assumes a dark background; set `gui.colorScheme` to `light` if yours is light.
|
||||
|
||||
## Custom Author Color
|
||||
|
||||
Lazygit will assign a random color for every commit author in the commits pane by default.
|
||||
|
||||
These colors are picked to be readable against the background of your terminal, and lazygit asks the terminal whether its background is dark or light. If your terminal doesn't tell, lazygit assumes a dark background; set `gui.colorScheme` to `light` if yours is light.
|
||||
|
||||
You can customize the color in case you're not happy with the randomly assigned one:
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
authorColors:
|
||||
'John Smith': 'red' # use red for John Smith
|
||||
'Alan Smithee': '#00ff00' # use green for Alan Smithee
|
||||
theme:
|
||||
authorColors:
|
||||
'John Smith': 'red' # use red for John Smith
|
||||
'Alan Smithee': '#00ff00' # use green for Alan Smithee
|
||||
```
|
||||
|
||||
You can use wildcard to set a unified color in case your are lazy to customize the color for every author or you just want a single color for all/other authors:
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
authorColors:
|
||||
# use red for John Smith
|
||||
'John Smith': 'red'
|
||||
# use blue for other authors
|
||||
'*': '#0000ff'
|
||||
theme:
|
||||
authorColors:
|
||||
# use red for John Smith
|
||||
'John Smith': 'red'
|
||||
# use blue for other authors
|
||||
'*': '#0000ff'
|
||||
```
|
||||
|
||||
## Custom Branch Color
|
||||
@@ -1053,13 +1117,16 @@ You can customize the color of branches based on branch patterns (regular expres
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
branchColorPatterns:
|
||||
'^docs/': '#11aaff' # use a light blue for branches beginning with 'docs/'
|
||||
'ISSUE-\d+': '#ff5733' # use a bright orange for branches containing 'ISSUE-<some-number>'
|
||||
theme:
|
||||
branchColorPatterns:
|
||||
'^docs/': '#11aaff' # use a light blue for branches beginning with 'docs/'
|
||||
'ISSUE-\d+': '#ff5733' # use a bright orange for branches containing 'ISSUE-<some-number>'
|
||||
```
|
||||
|
||||
Note that the regular expressions are not implicitly anchored to the beginning/end of the branch name. If you want to do that, add leading `^` and/or trailing `$` as needed.
|
||||
|
||||
If several patterns match a branch, the first one wins.
|
||||
|
||||
## Custom Files Icon & Color
|
||||
|
||||
You can customize the icon and color of files based on filenames or extensions:
|
||||
|
||||
@@ -23,22 +23,29 @@ Fields only for `extDiff`:
|
||||
|
||||
- **command** The command line to use for the `diff.external` git config. If left empty, it uses the global value of git's `diff.external` config; this can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
|
||||
|
||||
You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool.
|
||||
|
||||
Fields only for `rawGit`:
|
||||
|
||||
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings.
|
||||
|
||||
The `command` of a `stdinFilter` or `extDiff` renderer is a [Go template](https://pkg.go.dev/text/template) with these variables:
|
||||
|
||||
- `{{width}}`: the width of the view that the diff is rendered into.
|
||||
- `{{colorScheme}}`: `dark` or `light`, depending on whether the terminal has a dark or a light background. Lazygit asks the terminal about this; if yours doesn't tell, set `gui.colorScheme`.
|
||||
- `{{columnWidth}}` (only for `stdinFilter`): the width of one side of a side-by-side rendering, e.g. for `ydiff -p cat -s -w {{columnWidth}}`.
|
||||
- `{{diffContext}}` (only for `extDiff`): lazygit's current diff context size, the value controlled by the `{`/`}` keybindings.
|
||||
|
||||
A variable can also be written with a leading dot, as in `{{.width}}`. The command can use template expressions too; for example, `delta --paging=never {{if gt .width 160}}--side-by-side{{end}}` shows the diff side by side only when there is room for it, or `delta --syntax-theme={{if eq .colorScheme "light"}}Github{{else}}Dracula{{end}}` picks a different syntax theme based on the background.
|
||||
|
||||
Here's an example for a multi-renderer setup:
|
||||
|
||||
```yaml
|
||||
git:
|
||||
diffRenderers:
|
||||
- command: delta --dark --paging=never
|
||||
- command: delta --{{colorScheme}} --paging=never
|
||||
- command: ydiff -p cat
|
||||
colorArg: never
|
||||
- type: extDiff
|
||||
command: difft --color=always --context={{diffContext}}
|
||||
command: difft --color=always --background={{colorScheme}} --context={{diffContext}}
|
||||
- type: rawGit
|
||||
args: [--color-words]
|
||||
name: color-words
|
||||
@@ -51,12 +58,14 @@ git:
|
||||
```yaml
|
||||
git:
|
||||
diffRenderers:
|
||||
- command: delta --dark --paging=never
|
||||
- command: delta --{{colorScheme}} --paging=never
|
||||
```
|
||||
|
||||

|
||||
|
||||
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `command:` field to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
|
||||
`--{{colorScheme}}` passes `--dark` or `--light` to delta, so that it matches the background of your terminal.
|
||||
|
||||
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `command:` field to `delta --{{colorScheme}} --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
|
||||
|
||||
Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons.
|
||||
|
||||
|
||||
@@ -16,3 +16,36 @@ branches properly stacked onto it.
|
||||
Lazygit visualizes the individual branch heads in the stack by marking them with a
|
||||
cyan asterisk (or a cyan branch symbol if you are using [nerd
|
||||
fonts](Config.md#display-nerd-fonts-icons)).
|
||||
|
||||
When you push the topmost branch of the stack with `P`, and the branches below
|
||||
it have commits that haven't been pushed yet, lazygit offers to push them along
|
||||
with it. After rebasing the stack this saves you from checking out and
|
||||
force-pushing every branch one by one; you are asked to confirm the force push
|
||||
once for all of them. Only branches that already have an upstream are included.
|
||||
Each of them is pushed to where `git push` would push it if it were checked out,
|
||||
so your push configuration applies to them as usual.
|
||||
|
||||
When somebody else rebases the stack and force-pushes it, all your branches
|
||||
show up as diverged, for example `↓5↑3`, even though the commits they are ahead
|
||||
by are only the old versions of the ones that are now on the remote. Lazygit
|
||||
tells this apart from a branch that carries work of your own, and shows the
|
||||
divergence dimmed for such a branch. Pressing `f` on it resets it to its
|
||||
upstream instead of refusing, so you don't have to check the branch out and pull
|
||||
it. Lazygit only does this when every commit of the branch was on its remote
|
||||
branch at some point. It finds that out from the reflog of the remote-tracking
|
||||
branch. Reflogs are enabled by default, except in a bare repository; if you work
|
||||
in one with linked worktrees, set `core.logAllRefUpdates` to true there to make
|
||||
this work.
|
||||
|
||||
`f` works on a [range selection](Range_Select.md) too, so you can select the
|
||||
whole stack and bring all of it back in sync at once. If any of the selected
|
||||
branches can't be updated, none of them is, so that you don't end up with half
|
||||
of the stack updated.
|
||||
|
||||
Alternatively, check out the topmost branch of the stack and pull it with `p`.
|
||||
If branches below it can be updated this way, or are simply behind their
|
||||
upstream, lazygit offers to update them along with it. Lazygit decides this
|
||||
from the last fetch, so a branch whose changes on the remote haven't been
|
||||
fetched yet isn't offered; with auto-fetch turned off, pull a second time after
|
||||
the first pull has fetched them. Branches that are checked out in another
|
||||
worktree are left alone. The topmost branch itself is pulled as usual.
|
||||
|
||||
@@ -8,18 +8,17 @@ You'll want to familiarise yourself with how integration tests are written: see
|
||||
|
||||
Ideally we'd run this whole thing through docker but we haven't got that working. So you will need:
|
||||
```
|
||||
# for recording; vhs drives ttyd and ffmpeg under the hood
|
||||
brew install ttyd ffmpeg
|
||||
|
||||
# vhs 0.12.0 runs the tape, reports success and writes no video at all
|
||||
# (https://github.com/charmbracelet/vhs/issues/787), so pin the release
|
||||
# before it
|
||||
go install github.com/charmbracelet/vhs@v0.11.0
|
||||
# for recording
|
||||
npm i -g terminalizer
|
||||
# for gif compression
|
||||
npm i -g gifsicle
|
||||
# for mp4 conversion
|
||||
brew install ffmpeg
|
||||
|
||||
# font with icons
|
||||
wget https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/SourceCodePro.tar.xz && \
|
||||
tar -xf SourceCodePro.tar.xz -C ~/Library/Fonts && \
|
||||
rm SourceCodePro.tar.xz
|
||||
wget https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/DejaVuSansMono.tar.xz && \
|
||||
tar -xf DejaVuSansMono.tar.xz -C /usr/local/share/fonts && \
|
||||
rm DejaVuSansMono.tar.xz
|
||||
```
|
||||
|
||||
## Creating a demo
|
||||
@@ -50,82 +49,34 @@ The scripts and demo definitions live in the code branches but the output lives
|
||||
git worktree add .worktrees/assets assets
|
||||
```
|
||||
|
||||
The mp4 of the recording will be stored in `.worktrees/assets/demo/`.
|
||||
Outputs will be stored in `.worktrees/assets/demos/`. We'll store three separate things:
|
||||
* the yaml of the recording
|
||||
* the original gif
|
||||
* either the compressed gif or the mp4 depending on the output you chose (see below)
|
||||
|
||||
### Recording the demo
|
||||
|
||||
Once you're happy with your demo you can record it using:
|
||||
```sh
|
||||
scripts/record_demo.sh <path>
|
||||
scripts/record_demo.sh [gif|mp4] <path>
|
||||
# e.g.
|
||||
scripts/record_demo.sh pkg/integration/tests/demo/interactive_rebase.go
|
||||
scripts/record_demo.sh gif pkg/integration/tests/demo/interactive_rebase.go
|
||||
```
|
||||
|
||||
The terminal size, font and colours live in `demo/settings.tape`, which the
|
||||
script sources into the tape it generates for the demo.
|
||||
~~The gif format is for use in the first video of the readme (it has a larger size but has auto-play and looping)~~
|
||||
~~The mp4 format is for everything else (no looping, requires clicking, but smaller size).~~
|
||||
|
||||
While you are still working on how a demo looks, pass `--no-upload`. That
|
||||
leaves the video in `demo/output` (which is git-ignored) and stops there, so
|
||||
you can watch it without uploading anything or touching the assets worktree:
|
||||
|
||||
```sh
|
||||
scripts/record_demo.sh --no-upload pkg/integration/tests/demo/interactive_rebase.go
|
||||
```
|
||||
Turns out that you can't store mp4s in a repo and link them from a README so we're gonna just use gifs across the board for now.
|
||||
|
||||
### Including demos in README/docs
|
||||
|
||||
Recording a demo does three things with the mp4: it writes it to your assets
|
||||
worktree, it uploads a copy to GitHub's attachment store, and it posts that
|
||||
copy as a comment on the issue named by `PUBLISH_ISSUE` in the script. Then it
|
||||
prints the tag to embed:
|
||||
If you've followed the above steps you'll end up with your output in your assets worktree.
|
||||
|
||||
```html
|
||||
<video src="https://github.com/user-attachments/assets/<uuid>" controls></video>
|
||||
Within that worktree, stage all three output files and raise a PR against the assets branch.
|
||||
|
||||
Then back in the code branch, in the doc, you can embed the recording like so:
|
||||
```md
|
||||

|
||||
```
|
||||
|
||||
GitHub plays a video in a README only when it is served from its own attachment
|
||||
store. If you commit a video to the assets branch and link it the way we link
|
||||
the images, GitHub drops the whole `<video>` element when it renders the page.
|
||||
So the README reads the uploaded copy rather than the one in the assets
|
||||
worktree. Keep that one anyway, so that we still have the file if we ever need
|
||||
to upload it again. Stage it and raise a PR against the assets branch as you
|
||||
would for any other asset.
|
||||
|
||||
Attachment URLs are opaque and have no path we can predict, so a new recording
|
||||
of an existing demo means a new URL and an edit to the page that embeds it.
|
||||
|
||||
That comment on `PUBLISH_ISSUE` is not bookkeeping; it is what makes the video
|
||||
watchable. An uploaded attachment is readable only by people signed in to
|
||||
GitHub until some posted comment in the repository refers to it, and a README
|
||||
on a branch does not count. Skip that step and the video plays for you and
|
||||
404s for everyone else, which is easy to miss because you are signed in. The
|
||||
script waits until the video can be fetched without a token before it prints
|
||||
the tag. Referring to an attachment once is enough and cannot be undone, so
|
||||
the comments could be deleted later, but leaving them gives us a dated list of
|
||||
every recording.
|
||||
|
||||
Uploading needs push access to the lazygit repository. If you don't have it,
|
||||
record the demo, then ask a maintainer to upload the mp4 for you.
|
||||
|
||||
### Re-recording every demo on a page
|
||||
|
||||
Write the name of the demo above each video, so that we can find our way from
|
||||
a page back to the demo that produced it:
|
||||
|
||||
```html
|
||||
<!-- demo: commit_and_push -->
|
||||
<video src="https://github.com/user-attachments/assets/<uuid>" controls></video>
|
||||
```
|
||||
|
||||
GitHub drops the comment when it renders the page. With it in place, a change
|
||||
to `demo/settings.tape` or to lazygit's own appearance can be rolled out across
|
||||
every recording at once:
|
||||
|
||||
```sh
|
||||
scripts/rerecord_demos.sh
|
||||
# or, to look before you upload anything:
|
||||
scripts/rerecord_demos.sh --no-upload
|
||||
```
|
||||
|
||||
That re-records every demo the README embeds and rewrites each URL in place.
|
||||
Name other pages as arguments to do the same for them.
|
||||
This means we can update assets without needing to update the docs that embed them.
|
||||
|
||||
@@ -10,8 +10,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Scroll up main window | |
|
||||
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Scroll down main window | |
|
||||
| `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. |
|
||||
| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
|
||||
| `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
|
||||
| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. |
|
||||
| `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. |
|
||||
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
@@ -189,7 +189,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` d `` | Delete | View delete options for local/remote branch. |
|
||||
| `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. |
|
||||
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
|
||||
| `` T `` | New tag | |
|
||||
| `` s `` | Sort order | |
|
||||
| `` g `` | Reset | |
|
||||
@@ -223,6 +223,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | Scroll down | |
|
||||
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
|
||||
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` e `` | Edit file | Open file in external editor. |
|
||||
| `` <left>, h `` | Go to previous hunk | |
|
||||
| `` <right>, l `` | Go to next hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Search the current view by text | |
|
||||
|
||||
@@ -328,6 +335,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` e `` | Edit file | Open file in external editor. |
|
||||
| `` <left>, h `` | Go to previous hunk | |
|
||||
| `` <right>, l `` | Go to next hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Search the current view by text | |
|
||||
|
||||
|
||||
@@ -192,6 +192,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
|
||||
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 範囲選択を切り替え | |
|
||||
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
|
||||
| `` <left>, h `` | 前のハンクに移動 | |
|
||||
| `` <right>, l `` | 次のハンクに移動 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
@@ -305,6 +312,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | 下にスクロール | |
|
||||
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
|
||||
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
|
||||
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 範囲選択を切り替え | |
|
||||
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
|
||||
| `` <left>, h `` | 前のハンクに移動 | |
|
||||
| `` <right>, l `` | 次のハンクに移動 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 메인 패널을 위로 스크롤 | |
|
||||
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 메인 패널을 아래로로 스크롤 | |
|
||||
| `` @ `` | 명령어 로그 메뉴 열기 | View options for the command log e.g. show/hide the command log and focus the command log. |
|
||||
| `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
|
||||
| `` p `` | 업데이트 | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
|
||||
| `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. |
|
||||
| `` p `` | 업데이트 | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. |
|
||||
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` } `` | Diff 보기의 변경 사항 주위에 표시되는 컨텍스트의 크기를 늘리기 | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
@@ -84,6 +84,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 드래그 선택 전환 | |
|
||||
| `` e `` | 파일 편집 | Open file in external editor. |
|
||||
| `` <left>, h `` | 이전 hunk를 선택 | |
|
||||
| `` <right>, l `` | 다음 hunk를 선택 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
@@ -162,6 +169,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | 아래로 스크롤 | |
|
||||
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
|
||||
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 드래그 선택 전환 | |
|
||||
| `` e `` | 파일 편집 | Open file in external editor. |
|
||||
| `` <left>, h `` | 이전 hunk를 선택 | |
|
||||
| `` <right>, l `` | 다음 hunk를 선택 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
@@ -223,7 +237,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` d `` | 삭제 | View delete options for local/remote branch. |
|
||||
| `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. |
|
||||
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
|
||||
| `` T `` | 태그를 생성 | |
|
||||
| `` s `` | Sort order | |
|
||||
| `` g `` | View reset options | |
|
||||
|
||||
@@ -113,7 +113,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` d `` | Verwijderen | View delete options for local/remote branch. |
|
||||
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
|
||||
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. |
|
||||
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
|
||||
| `` T `` | Creëer tag | |
|
||||
| `` s `` | Sort order | |
|
||||
| `` g `` | Bekijk reset opties | |
|
||||
@@ -231,6 +231,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | Scroll omlaag | |
|
||||
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
|
||||
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` e `` | Verander bestand | Open bestand in externe editor. |
|
||||
| `` <left>, h `` | Selecteer de vorige hunk | |
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Start met zoeken | |
|
||||
|
||||
@@ -306,6 +313,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` e `` | Verander bestand | Open bestand in externe editor. |
|
||||
| `` <left>, h `` | Selecteer de vorige hunk | |
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Start met zoeken | |
|
||||
|
||||
|
||||
@@ -99,6 +99,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Przełącz zaznaczenie zakresu | |
|
||||
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
|
||||
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
|
||||
| `` <right>, l `` | Idź do następnego fragmentu | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Szukaj w bieżącym widoku po tekście | |
|
||||
|
||||
@@ -201,6 +208,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | Przewiń w dół | |
|
||||
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
|
||||
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Przełącz zaznaczenie zakresu | |
|
||||
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
|
||||
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
|
||||
| `` <right>, l `` | Idź do następnego fragmentu | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Szukaj w bieżącym widoku po tekście | |
|
||||
|
||||
|
||||
@@ -235,6 +235,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | Rolar para baixo | |
|
||||
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
|
||||
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
|
||||
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
|
||||
| `` <left>, h `` | Ir para o local anterior | |
|
||||
| `` <right>, l `` | Ir para o próximo trecho | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Pesquisar na visualização atual por texto | |
|
||||
|
||||
@@ -337,6 +344,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
|
||||
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
|
||||
| `` <left>, h `` | Ir para o local anterior | |
|
||||
| `` <right>, l `` | Ir para o próximo trecho | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Pesquisar na visualização atual por texto | |
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Прокрутить вверх главную панель | |
|
||||
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Прокрутить вниз главную панель | |
|
||||
| `` @ `` | Открыть меню журнала команд | View options for the command log e.g. show/hide the command log and focus the command log. |
|
||||
| `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
|
||||
| `` p `` | Получить и слить изменения | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
|
||||
| `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. |
|
||||
| `` p `` | Получить и слить изменения | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. |
|
||||
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` } `` | Увеличить размер контекста, отображаемого вокруг изменений в просмотрщике сравнении | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
@@ -74,6 +74,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Переключить выборку перетаскивания | |
|
||||
| `` e `` | Редактировать файл | Open file in external editor. |
|
||||
| `` <left>, h `` | Выбрать предыдущую часть | |
|
||||
| `` <right>, l `` | Выбрать следующую часть | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
@@ -106,6 +113,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | Прокрутить вниз | |
|
||||
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
|
||||
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Переключить выборку перетаскивания | |
|
||||
| `` e `` | Редактировать файл | Open file in external editor. |
|
||||
| `` <left>, h `` | Выбрать предыдущую часть | |
|
||||
| `` <right>, l `` | Выбрать следующую часть | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
@@ -223,7 +237,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` d `` | Delete | View delete options for local/remote branch. |
|
||||
| `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. |
|
||||
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
|
||||
| `` T `` | Создать тег | |
|
||||
| `` s `` | Порядок сортировки | |
|
||||
| `` g `` | Просмотреть параметры сброса | |
|
||||
|
||||
@@ -286,6 +286,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
|
||||
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
|
||||
| `` v `` | 切换拖动选择 | |
|
||||
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
|
||||
| `` <left>, h `` | 选择上一个区块 | |
|
||||
| `` <right>, l `` | 选择下一个区块 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
@@ -334,6 +341,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | 向下滚动 | |
|
||||
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
|
||||
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
|
||||
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
|
||||
| `` v `` | 切换拖动选择 | |
|
||||
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
|
||||
| `` <left>, h `` | 选择上一个区块 | |
|
||||
| `` <right>, l `` | 选择下一个区块 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
|
||||
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
@@ -356,6 +363,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
||||
@@ -74,9 +74,5 @@ bump-gocui:
|
||||
demo *args:
|
||||
demo/record_demo.sh {{ args }}
|
||||
|
||||
# Re-record every demo the README embeds
|
||||
rerecord-demos *args:
|
||||
demo/rerecord_demos.sh {{ args }}
|
||||
|
||||
vendor:
|
||||
go mod tidy && go mod vendor
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ func NewGitCommandAux(
|
||||
gitHubCommands := git_commands.NewGitHubCommands(gitCommon)
|
||||
hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon)
|
||||
|
||||
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands)
|
||||
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, branchCommands.HasLocalOnlyCommits, configCommands)
|
||||
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
|
||||
commitLoader := git_commands.NewCommitLoader(cmn, cmd, statusCommands.WorkingTreeState, gitCommon)
|
||||
reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd)
|
||||
|
||||
@@ -285,11 +285,26 @@ func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// Returns whether refName can be fast-forward merged into the current branch
|
||||
func (self *BranchCommands) CanDoFastForwardMerge(refName string) bool {
|
||||
// Fast-forwards the branch that is checked out in the given worktree to the
|
||||
// given ref. Fails if that can't be done without a merge commit. Pass empty
|
||||
// strings for the worktree to use the current one.
|
||||
func (self *BranchCommands) FastForwardMerge(refName string, worktreeGitDir string, worktreePath string) error {
|
||||
cmdArgs := NewGitCmd("merge").
|
||||
Arg("--ff-only").
|
||||
Arg(refName).
|
||||
GitDirIf(worktreeGitDir != "", worktreeGitDir).
|
||||
WorktreePathIf(worktreePath != "", worktreePath).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// Returns whether the first ref is an ancestor of the second one, which also
|
||||
// means that the second one can be fast-forward merged into the first one
|
||||
func (self *BranchCommands) IsAncestor(ancestorRefName string, refName string) bool {
|
||||
cmdArgs := NewGitCmd("merge-base").
|
||||
Arg("--is-ancestor").
|
||||
Arg("HEAD", refName).
|
||||
Arg(ancestorRefName, refName).
|
||||
ToArgv()
|
||||
err := self.cmd.New(cmdArgs).DontLog().Run()
|
||||
return err == nil
|
||||
@@ -353,9 +368,85 @@ func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches *
|
||||
return stdout == "", nil
|
||||
}
|
||||
|
||||
func (self *BranchCommands) UpdateBranchRefs(updateCommands string) error {
|
||||
// Returns whether the given branch has commits of its own, meaning commits
|
||||
// that its remote branch never contained. Those are the commits that would be
|
||||
// lost if we reset the branch to its upstream.
|
||||
//
|
||||
// A branch that has diverged from its upstream doesn't necessarily have any
|
||||
// commits of its own. If somebody else rewrote the remote branch and
|
||||
// force-pushed it, our branch is still at the commits it had before, and all
|
||||
// of those were on the remote branch at some point. The reflog of the
|
||||
// remote-tracking branch records the values it had before it was rewritten, so
|
||||
// a commit that was ever on the remote branch is contained in one of them.
|
||||
func (self *BranchCommands) HasLocalOnlyCommits(branch *models.Branch) (bool, error) {
|
||||
upstreamValues := append(
|
||||
[]string{branch.FullUpstreamRefName()},
|
||||
self.previousUpstreamValues(branch.FullUpstreamRefName())...,
|
||||
)
|
||||
|
||||
cmdArgs := NewGitCmd("rev-list").
|
||||
Arg("--max-count=1").
|
||||
// A value that the remote-tracking branch had long ago might not be
|
||||
// available any more, e.g. in a partial clone. Skip it rather than
|
||||
// failing; it only means we exclude fewer commits.
|
||||
Arg("--ignore-missing").
|
||||
Arg(branch.FullRefName()).
|
||||
Arg(lo.Map(upstreamValues, func(value string, _ int) string {
|
||||
return "^" + value
|
||||
})...).
|
||||
Arg("--").
|
||||
ToArgv()
|
||||
|
||||
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return stdout != "", nil
|
||||
}
|
||||
|
||||
// Returns the values that the given remote-tracking branch had before its
|
||||
// current one, as far back as its reflog goes. Returns nothing if the reflog
|
||||
// is unavailable, for example because core.logAllRefUpdates is false; a branch
|
||||
// that is strictly behind its upstream is recognized without it.
|
||||
func (self *BranchCommands) previousUpstreamValues(upstreamRef string) []string {
|
||||
cmdArgs := NewGitCmd("reflog").
|
||||
Arg("show").
|
||||
Arg("--format=%H").
|
||||
Arg(upstreamRef).
|
||||
ToArgv()
|
||||
|
||||
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Each entry holds the value that the ref was updated to.
|
||||
values := utils.SplitLines(stdout)
|
||||
|
||||
// The value it had before the oldest entry is that entry's old value, and
|
||||
// the only way to name it is <ref>@{<number of entries>}. It doesn't exist
|
||||
// if the oldest entry is the one that created the ref, and asking for it
|
||||
// then is an error rather than an empty result.
|
||||
cmdArgs = NewGitCmd("rev-parse").
|
||||
Arg("-q", "--verify").
|
||||
Arg(fmt.Sprintf("%s@{%d}", upstreamRef, len(values))).
|
||||
ToArgv()
|
||||
|
||||
if stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs(); err == nil {
|
||||
values = append(values, strings.TrimSpace(stdout))
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// Moves branches by writing refs directly. The reflog message is what
|
||||
// `git reflog <branch>` shows for the update; it is the only hint about who
|
||||
// moved the branch, as no git command shows up in the reflog for this.
|
||||
func (self *BranchCommands) UpdateBranchRefs(updateCommands string, reflogMessage string) error {
|
||||
cmdArgs := NewGitCmd("update-ref").
|
||||
Arg("--stdin").
|
||||
Arg("-m", reflogMessage).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).SetStdin(updateCommands).Run()
|
||||
|
||||
@@ -44,6 +44,7 @@ type BranchLoader struct {
|
||||
*GitCommon
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
getCurrentBranchInfo func() (BranchInfo, error)
|
||||
hasLocalOnlyCommits func(*models.Branch) (bool, error)
|
||||
config BranchLoaderConfigCommands
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ func NewBranchLoader(
|
||||
gitCommon *GitCommon,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
getCurrentBranchInfo func() (BranchInfo, error),
|
||||
hasLocalOnlyCommits func(*models.Branch) (bool, error),
|
||||
config BranchLoaderConfigCommands,
|
||||
) *BranchLoader {
|
||||
return &BranchLoader{
|
||||
@@ -59,6 +61,7 @@ func NewBranchLoader(
|
||||
GitCommon: gitCommon,
|
||||
cmd: cmd,
|
||||
getCurrentBranchInfo: getCurrentBranchInfo,
|
||||
hasLocalOnlyCommits: hasLocalOnlyCommits,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
@@ -67,7 +70,7 @@ func NewBranchLoader(
|
||||
func (self *BranchLoader) Load(reflogCommits []*models.Commit,
|
||||
mainBranches *MainBranches,
|
||||
oldBranches []*models.Branch,
|
||||
loadBehindCounts bool,
|
||||
loadExtraInfo bool,
|
||||
onWorker func(func() error),
|
||||
renderFunc func(),
|
||||
) ([]*models.Branch, error) {
|
||||
@@ -135,24 +138,63 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit,
|
||||
branch.UpstreamBranch = match.Merge
|
||||
}
|
||||
|
||||
// If the branch already existed, take over its BehindBaseBranch value
|
||||
// to reduce flicker
|
||||
// If the branch already existed, take over the values that are
|
||||
// determined in the background, to reduce flicker
|
||||
if oldBranch, found := lo.Find(oldBranches, func(b *models.Branch) bool {
|
||||
return b.Name == branch.Name
|
||||
}); found {
|
||||
branch.BehindBaseBranch.Store(oldBranch.BehindBaseBranch.Load())
|
||||
branch.UpstreamRewritten.Store(oldBranch.UpstreamRewritten.Load())
|
||||
}
|
||||
}
|
||||
|
||||
if loadBehindCounts && self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
|
||||
if loadExtraInfo {
|
||||
if self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
|
||||
onWorker(func() error {
|
||||
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
|
||||
})
|
||||
}
|
||||
|
||||
onWorker(func() error {
|
||||
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
|
||||
return self.checkForRewrittenUpstreams(branches, renderFunc)
|
||||
})
|
||||
}
|
||||
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
// For each branch that has diverged from its upstream, determines whether the
|
||||
// divergence comes from the upstream branch having been rewritten, and stores
|
||||
// the answer in the branch. A branch that we can't determine it for keeps the
|
||||
// answer "no", so that we don't offer anything we aren't sure about.
|
||||
func (self *BranchLoader) checkForRewrittenUpstreams(branches []*models.Branch, renderFunc func()) error {
|
||||
t := time.Now()
|
||||
errg := errgroup.Group{}
|
||||
|
||||
for _, branch := range branches {
|
||||
if !branch.IsAheadForPull() || !branch.IsBehindForPull() {
|
||||
branch.UpstreamRewritten.Store(false)
|
||||
continue
|
||||
}
|
||||
|
||||
errg.Go(func() error {
|
||||
hasLocalOnlyCommits, err := self.hasLocalOnlyCommits(branch)
|
||||
if err != nil {
|
||||
// Not worth bothering the user about; it only means that we
|
||||
// don't show this branch differently.
|
||||
self.Log.Errorf("Failed to check whether branch %s has commits of its own: %v", branch.Name, err)
|
||||
}
|
||||
branch.UpstreamRewritten.Store(err == nil && !hasLocalOnlyCommits)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err := errg.Wait()
|
||||
self.Log.Debugf("time to check for rewritten upstreams for all branches: %s", time.Since(t))
|
||||
renderFunc()
|
||||
return err
|
||||
}
|
||||
|
||||
func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches(
|
||||
branches []*models.Branch,
|
||||
mainBranches *MainBranches,
|
||||
@@ -349,6 +391,7 @@ var branchFields = []string{
|
||||
"upstream:short",
|
||||
"upstream:track",
|
||||
"push:track",
|
||||
"push",
|
||||
"subject",
|
||||
"objectname",
|
||||
"committerdate:unix",
|
||||
@@ -361,13 +404,15 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch
|
||||
upstreamName := split[2]
|
||||
track := split[3]
|
||||
pushTrack := split[4]
|
||||
subject := split[5]
|
||||
commitHash := split[6]
|
||||
commitDate := split[7]
|
||||
pushRef := split[5]
|
||||
subject := split[6]
|
||||
commitHash := split[7]
|
||||
commitDate := split[8]
|
||||
|
||||
name := strings.TrimPrefix(fullName, "heads/")
|
||||
aheadForPull, behindForPull, gone := parseUpstreamInfo(upstreamName, track)
|
||||
aheadForPush, behindForPush, _ := parseUpstreamInfo(upstreamName, pushTrack)
|
||||
pushRemote, pushBranch := parsePushDestination(pushRef)
|
||||
|
||||
recency := ""
|
||||
if storeCommitDateAsRecency {
|
||||
@@ -383,6 +428,8 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch
|
||||
BehindForPull: behindForPull,
|
||||
AheadForPush: aheadForPush,
|
||||
BehindForPush: behindForPush,
|
||||
PushRemote: pushRemote,
|
||||
PushBranch: pushBranch,
|
||||
UpstreamGone: gone,
|
||||
Head: headMarker == "*",
|
||||
Subject: subject,
|
||||
@@ -410,6 +457,25 @@ func parseUpstreamInfo(upstreamName string, track string) (string, string, bool)
|
||||
return ahead, behind, false
|
||||
}
|
||||
|
||||
// Splits the remote-tracking ref that the %(push) field names, e.g.
|
||||
// refs/remotes/origin/main, into the remote and the remote branch. Returns
|
||||
// empty strings if the field is empty because git has no push destination for
|
||||
// the branch, or if the ref isn't under refs/remotes/.
|
||||
func parsePushDestination(pushRef string) (string, string) {
|
||||
remoteAndBranch, ok := strings.CutPrefix(pushRef, "refs/remotes/")
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Remote names can't contain slashes, so the first one ends the remote name
|
||||
remote, branch, ok := strings.Cut(remoteAndBranch, "/")
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return remote, branch
|
||||
}
|
||||
|
||||
func parseDifference(track string, regexStr string) string {
|
||||
re := regexp.MustCompile(regexStr)
|
||||
match := re.FindStringSubmatch(track)
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/sasha-s/go-deadlock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -26,7 +28,7 @@ func TestObtainBranch(t *testing.T) {
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "TrimHeads",
|
||||
input: []string{"", "heads/a_branch", "", "", "", "subject", "123", timeStamp},
|
||||
input: []string{"", "heads/a_branch", "", "", "", "", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
@@ -41,7 +43,7 @@ func TestObtainBranch(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "NoUpstream",
|
||||
input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp},
|
||||
input: []string{"", "a_branch", "", "", "", "", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
@@ -56,7 +58,7 @@ func TestObtainBranch(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "IsHead",
|
||||
input: []string{"*", "a_branch", "", "", "", "subject", "123", timeStamp},
|
||||
input: []string{"*", "a_branch", "", "", "", "", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
@@ -71,7 +73,7 @@ func TestObtainBranch(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "IsBehindAndAhead",
|
||||
input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "subject", "123", timeStamp},
|
||||
input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
@@ -79,6 +81,40 @@ func TestObtainBranch(t *testing.T) {
|
||||
BehindForPull: "2",
|
||||
AheadForPush: "3",
|
||||
BehindForPush: "2",
|
||||
PushRemote: "a_remote",
|
||||
PushBranch: "a_branch",
|
||||
Head: false,
|
||||
Subject: "subject",
|
||||
CommitHash: "123",
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "PushDestinationDiffersFromUpstream",
|
||||
input: []string{"", "a_branch", "a_remote/a_branch", "[ahead 3]", "[ahead 5]", "refs/remotes/my_fork/feature/a_branch", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
AheadForPull: "3",
|
||||
BehindForPull: "0",
|
||||
AheadForPush: "5",
|
||||
BehindForPush: "0",
|
||||
PushRemote: "my_fork",
|
||||
PushBranch: "feature/a_branch",
|
||||
Head: false,
|
||||
Subject: "subject",
|
||||
CommitHash: "123",
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "PushDestinationNotARemoteTrackingRef",
|
||||
input: []string{"", "a_branch", "a_remote/a_branch", "", "", "refs/published/a_branch", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
AheadForPull: "0",
|
||||
BehindForPull: "0",
|
||||
AheadForPush: "0",
|
||||
BehindForPush: "0",
|
||||
Head: false,
|
||||
Subject: "subject",
|
||||
CommitHash: "123",
|
||||
@@ -86,7 +122,7 @@ func TestObtainBranch(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "RemoteBranchIsGone",
|
||||
input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "subject", "123", timeStamp},
|
||||
input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
@@ -95,6 +131,8 @@ func TestObtainBranch(t *testing.T) {
|
||||
BehindForPull: "?",
|
||||
AheadForPush: "?",
|
||||
BehindForPush: "?",
|
||||
PushRemote: "a_remote",
|
||||
PushBranch: "a_branch",
|
||||
Head: false,
|
||||
Subject: "subject",
|
||||
CommitHash: "123",
|
||||
@@ -102,7 +140,7 @@ func TestObtainBranch(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "WithCommitDateAsRecency",
|
||||
input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp},
|
||||
input: []string{"", "a_branch", "", "", "", "", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: true,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
@@ -255,3 +293,62 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) {
|
||||
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestCheckForRewrittenUpstreams(t *testing.T) {
|
||||
branch := func(name string, ahead string, behind string) *models.Branch {
|
||||
return &models.Branch{
|
||||
Name: name,
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: name,
|
||||
AheadForPull: ahead,
|
||||
BehindForPull: behind,
|
||||
}
|
||||
}
|
||||
|
||||
notDiverged := branch("not-diverged", "0", "2")
|
||||
rewritten := branch("rewritten", "3", "5")
|
||||
ownCommits := branch("own-commits", "3", "5")
|
||||
failing := branch("failing", "1", "1")
|
||||
|
||||
// A branch that is no longer diverged must lose the value it had before
|
||||
notDiverged.UpstreamRewritten.Store(true)
|
||||
|
||||
branches := []*models.Branch{notDiverged, rewritten, ownCommits, failing}
|
||||
|
||||
var mutex deadlock.Mutex
|
||||
queried := []string{}
|
||||
hasLocalOnlyCommits := func(branch *models.Branch) (bool, error) {
|
||||
mutex.Lock()
|
||||
queried = append(queried, branch.Name)
|
||||
mutex.Unlock()
|
||||
|
||||
switch branch.Name {
|
||||
case "own-commits":
|
||||
return true, nil
|
||||
case "failing":
|
||||
return false, errors.New("error")
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
gitCommon := buildGitCommon(commonDeps{})
|
||||
loader := &BranchLoader{
|
||||
Common: gitCommon.Common,
|
||||
GitCommon: gitCommon,
|
||||
cmd: gitCommon.cmd,
|
||||
hasLocalOnlyCommits: hasLocalOnlyCommits,
|
||||
}
|
||||
|
||||
rendered := false
|
||||
err := loader.checkForRewrittenUpstreams(branches, func() { rendered = true })
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, rendered, "renderFunc should have been called")
|
||||
|
||||
assert.ElementsMatch(t, []string{"rewritten", "own-commits", "failing"}, queried,
|
||||
"only diverged branches should be looked at")
|
||||
assert.False(t, notDiverged.UpstreamRewritten.Load())
|
||||
assert.True(t, rewritten.UpstreamRewritten.Load())
|
||||
assert.False(t, ownCommits.UpstreamRewritten.Load())
|
||||
assert.False(t, failing.UpstreamRewritten.Load(), "a failed check should not claim anything")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -317,3 +318,111 @@ func TestBranchCurrentBranchInfo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchHasLocalOnlyCommits(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
test func(bool, error)
|
||||
}
|
||||
|
||||
branch := &models.Branch{
|
||||
Name: "branch",
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: "branch",
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
"branch is strictly behind its upstream, and there are no reflogs",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, "", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{0}"}, "", errors.New("error")).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch", "--",
|
||||
}, "", nil),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hasLocalOnlyCommits)
|
||||
},
|
||||
},
|
||||
{
|
||||
"the upstream branch was rewritten, so all our commits were on it before",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
|
||||
"1111111111111111111111111111111111111111\n2222222222222222222222222222222222222222\n", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{2}"},
|
||||
"3333333333333333333333333333333333333333\n", nil).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch",
|
||||
"^1111111111111111111111111111111111111111",
|
||||
"^2222222222222222222222222222222222222222",
|
||||
"^3333333333333333333333333333333333333333",
|
||||
"--",
|
||||
}, "", nil),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hasLocalOnlyCommits)
|
||||
},
|
||||
},
|
||||
{
|
||||
"the oldest reflog entry is the one that created the ref",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
|
||||
"1111111111111111111111111111111111111111\n", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{1}"}, "", errors.New("error")).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch",
|
||||
"^1111111111111111111111111111111111111111",
|
||||
"--",
|
||||
}, "", nil),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hasLocalOnlyCommits)
|
||||
},
|
||||
},
|
||||
{
|
||||
"the branch has a commit that was never on the upstream branch",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
|
||||
"1111111111111111111111111111111111111111\n", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{1}"},
|
||||
"2222222222222222222222222222222222222222\n", nil).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch",
|
||||
"^1111111111111111111111111111111111111111",
|
||||
"^2222222222222222222222222222222222222222",
|
||||
"--",
|
||||
}, "4444444444444444444444444444444444444444\n", nil),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, hasLocalOnlyCommits)
|
||||
},
|
||||
},
|
||||
{
|
||||
"bubbles up an error from rev-list",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, "", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{0}"}, "", errors.New("error")).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch", "--",
|
||||
}, "", errors.New("error")),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.Error(t, err)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
instance := buildBranchCommands(commonDeps{runner: s.runner})
|
||||
s.test(instance.HasLocalOnlyCommits(branch))
|
||||
s.runner.CheckForMissingCalls()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ func TestCommitShowCmdObj(t *testing.T) {
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff using git's external diff config",
|
||||
|
||||
@@ -125,11 +125,9 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand
|
||||
|
||||
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder {
|
||||
contextSize := userConfig.Git.DiffContextSize
|
||||
extDiffCmd := diffRendererConfigManager.GetExternalDiffCommand(contextSize)
|
||||
useExtDiff := forUI && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
|
||||
|
||||
return self.
|
||||
ConfigIf(forUI && extDiffCmd != "", "diff.external="+extDiffCmd).
|
||||
ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff").
|
||||
Arg(fmt.Sprintf("--unified=%d", contextSize)).
|
||||
ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
|
||||
|
||||
@@ -59,9 +59,10 @@ func (self *RemoteCommands) DeleteRemoteBranch(task gocui.Task, remoteName strin
|
||||
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
|
||||
}
|
||||
|
||||
func (self *RemoteCommands) DeleteRemoteTag(task gocui.Task, remoteName string, tagName string) error {
|
||||
func (self *RemoteCommands) DeleteRemoteTag(task gocui.Task, remoteName string, tagNames []string) error {
|
||||
cmdArgs := NewGitCmd("push").
|
||||
Arg(remoteName, "--delete", "refs/tags/"+tagName).
|
||||
Arg(remoteName, "--delete").
|
||||
Arg(lo.Map(tagNames, func(t string, _ int) string { return "refs/tags/" + t })...).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
|
||||
|
||||
@@ -139,7 +139,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff using git's external diff config",
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type SyncCommands struct {
|
||||
@@ -18,18 +19,21 @@ func NewSyncCommands(gitCommon *GitCommon) *SyncCommands {
|
||||
}
|
||||
}
|
||||
|
||||
// Push pushes to a branch
|
||||
type PushOpts struct {
|
||||
Force bool
|
||||
ForceWithLease bool
|
||||
CurrentBranch string
|
||||
UpstreamRemote string
|
||||
UpstreamBranch string
|
||||
SetUpstream bool
|
||||
// The remote to push to. If empty, git picks it from its configuration,
|
||||
// and Refspecs must be empty too.
|
||||
Remote string
|
||||
// What to push, each in the form "refs/heads/<local branch>:<remote ref>".
|
||||
// If empty, git decides what to push based on push.default and
|
||||
// remote.<name>.push.
|
||||
Refspecs []string
|
||||
}
|
||||
|
||||
func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (*oscommands.CmdObj, error) {
|
||||
if opts.UpstreamBranch != "" && opts.UpstreamRemote == "" {
|
||||
if len(opts.Refspecs) > 0 && opts.Remote == "" {
|
||||
return nil, errors.New(self.Tr.MustSpecifyOriginError)
|
||||
}
|
||||
|
||||
@@ -37,8 +41,8 @@ func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (*oscommand
|
||||
ArgIf(opts.Force, "--force").
|
||||
ArgIf(opts.ForceWithLease, "--force-with-lease").
|
||||
ArgIf(opts.SetUpstream, "--set-upstream").
|
||||
ArgIf(opts.UpstreamRemote != "", opts.UpstreamRemote).
|
||||
ArgIf(opts.UpstreamBranch != "", fmt.Sprintf("refs/heads/%s:%s", opts.CurrentBranch, opts.UpstreamBranch)).
|
||||
ArgIf(opts.Remote != "", opts.Remote).
|
||||
Arg(opts.Refspecs...).
|
||||
ToArgv()
|
||||
|
||||
cmdObj := self.cmd.New(cmdArgs).PromptOnCredentialRequest(task)
|
||||
@@ -110,15 +114,25 @@ func (self *SyncCommands) Pull(task gocui.Task, opts PullOptions) error {
|
||||
return self.cmd.New(cmdArgs).AddEnvVars("GIT_SEQUENCE_EDITOR=:").PromptOnCredentialRequest(task).Run()
|
||||
}
|
||||
|
||||
func (self *SyncCommands) FastForward(
|
||||
// Fetches the given branches of the given remote, updating their
|
||||
// remote-tracking branches. Local branches are left alone, including the ones
|
||||
// that track them.
|
||||
func (self *SyncCommands) FetchRemoteBranches(
|
||||
task gocui.Task,
|
||||
branchName string,
|
||||
remoteName string,
|
||||
remoteBranchName string,
|
||||
remoteBranchNames []string,
|
||||
) error {
|
||||
// The explicit destinations and the leading + make sure that the
|
||||
// remote-tracking branches are updated even when the remote branches were
|
||||
// rewritten, whatever the remote's fetch refspec says
|
||||
refspecs := lo.Map(remoteBranchNames, func(remoteBranchName string, _ int) string {
|
||||
return fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s",
|
||||
remoteBranchName, remoteName, remoteBranchName)
|
||||
})
|
||||
|
||||
cmdArgs := self.fetchCommandBuilder(false).
|
||||
Arg(remoteName).
|
||||
Arg("refs/heads/" + remoteBranchName + ":" + branchName).
|
||||
Arg(refspecs...).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
|
||||
|
||||
@@ -41,12 +41,11 @@ func TestSyncPush(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Push with force disabled, upstream supplied",
|
||||
testName: "Push with force disabled, refspec supplied",
|
||||
opts: PushOpts{
|
||||
ForceWithLease: false,
|
||||
CurrentBranch: "master",
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: "master",
|
||||
Remote: "origin",
|
||||
Refspecs: []string{"refs/heads/master:master"},
|
||||
},
|
||||
test: func(cmdObj *oscommands.CmdObj, err error) {
|
||||
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "origin", "refs/heads/master:master"})
|
||||
@@ -57,9 +56,8 @@ func TestSyncPush(t *testing.T) {
|
||||
testName: "Push with force disabled, setting upstream",
|
||||
opts: PushOpts{
|
||||
ForceWithLease: false,
|
||||
CurrentBranch: "master-local",
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: "master",
|
||||
Remote: "origin",
|
||||
Refspecs: []string{"refs/heads/master-local:master"},
|
||||
SetUpstream: true,
|
||||
},
|
||||
test: func(cmdObj *oscommands.CmdObj, err error) {
|
||||
@@ -71,9 +69,8 @@ func TestSyncPush(t *testing.T) {
|
||||
testName: "Push with force-with-lease enabled, setting upstream",
|
||||
opts: PushOpts{
|
||||
ForceWithLease: true,
|
||||
CurrentBranch: "master",
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: "master",
|
||||
Remote: "origin",
|
||||
Refspecs: []string{"refs/heads/master:master"},
|
||||
SetUpstream: true,
|
||||
},
|
||||
test: func(cmdObj *oscommands.CmdObj, err error) {
|
||||
@@ -82,11 +79,23 @@ func TestSyncPush(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Push with remote branch but no origin",
|
||||
testName: "Push several refspecs",
|
||||
opts: PushOpts{
|
||||
ForceWithLease: true,
|
||||
UpstreamRemote: "",
|
||||
UpstreamBranch: "master",
|
||||
Remote: "origin",
|
||||
Refspecs: []string{"refs/heads/a:refs/heads/a", "refs/heads/b:refs/heads/b"},
|
||||
},
|
||||
test: func(cmdObj *oscommands.CmdObj, err error) {
|
||||
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--force-with-lease", "origin", "refs/heads/a:refs/heads/a", "refs/heads/b:refs/heads/b"})
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Push with refspec but no remote",
|
||||
opts: PushOpts{
|
||||
ForceWithLease: true,
|
||||
Remote: "",
|
||||
Refspecs: []string{"refs/heads/master:master"},
|
||||
SetUpstream: true,
|
||||
},
|
||||
test: func(cmdObj *oscommands.CmdObj, err error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type TagCommands struct {
|
||||
@@ -46,15 +47,16 @@ func (self *TagCommands) HasTag(tagName string) bool {
|
||||
return self.cmd.New(cmdArgs).DontLog().Run() == nil
|
||||
}
|
||||
|
||||
func (self *TagCommands) LocalDelete(tagName string) error {
|
||||
cmdArgs := NewGitCmd("tag").Arg("-d", tagName).
|
||||
func (self *TagCommands) LocalDelete(tagNames []string) error {
|
||||
cmdArgs := NewGitCmd("tag").Arg("-d").Arg(tagNames...).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
func (self *TagCommands) Push(task gocui.Task, remoteName string, tagName string) error {
|
||||
cmdArgs := NewGitCmd("push").Arg(remoteName, "tag", tagName).
|
||||
func (self *TagCommands) Push(task gocui.Task, remoteName string, tagNames []string) error {
|
||||
cmdArgs := NewGitCmd("push").Arg(remoteName).
|
||||
Arg(lo.FlatMap(tagNames, func(t string, _ int) []string { return []string{"tag", t} })...).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
|
||||
|
||||
@@ -524,6 +524,42 @@ func (self *WorkingTreeCommands) ResetSoft(ref string) error {
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// ResetKeep runs `git reset --keep` in the given worktree, which moves the
|
||||
// checked out branch to the given ref while keeping local modifications. It
|
||||
// fails rather than overwriting a file that differs between the two commits.
|
||||
// Pass empty strings for the worktree to use the current one.
|
||||
func (self *WorkingTreeCommands) ResetKeep(ref string, worktreeGitDir string, worktreePath string) error {
|
||||
cmdArgs := NewGitCmd("reset").Arg("--keep", ref).
|
||||
GitDirIf(worktreeGitDir != "", worktreeGitDir).
|
||||
WorktreePathIf(worktreePath != "", worktreePath).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// Returns whether the given worktree has changes to tracked files, either in
|
||||
// its working tree or in its index. Untracked files don't count, and neither do
|
||||
// submodules. A submodule that is checked out at a different commit than the
|
||||
// one recorded, or that has changes of its own, doesn't get in the way of
|
||||
// moving the branch, because moving it leaves the submodules alone. Pass empty
|
||||
// strings for the worktree to use the current one.
|
||||
func (self *WorkingTreeCommands) HasChangesToTrackedFiles(worktreeGitDir string, worktreePath string) (bool, error) {
|
||||
cmdArgs := NewGitCmd("status").
|
||||
Arg("--porcelain").
|
||||
Arg("--untracked-files=no").
|
||||
Arg("--ignore-submodules").
|
||||
GitDirIf(worktreeGitDir != "", worktreeGitDir).
|
||||
WorktreePathIf(worktreePath != "", worktreePath).
|
||||
ToArgv()
|
||||
|
||||
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return stdout != "", nil
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) ResetMixed(ref string) error {
|
||||
cmdArgs := NewGitCmd("reset").Arg("--mixed", ref).
|
||||
ToArgv()
|
||||
|
||||
@@ -140,12 +140,14 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
|
||||
rebasedBranch, ok := self.rebasedBranch(worktree)
|
||||
if ok {
|
||||
worktree.Branch = rebasedBranch
|
||||
worktree.IsRebasingOrBisecting = true
|
||||
continue
|
||||
}
|
||||
|
||||
bisectedBranch, ok := self.bisectedBranch(worktree)
|
||||
if ok {
|
||||
worktree.Branch = bisectedBranch
|
||||
worktree.IsRebasingOrBisecting = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ type Branch struct {
|
||||
// 'git@github.com:tiwood/lazygit.git'
|
||||
UpstreamRemote string
|
||||
UpstreamBranch string
|
||||
// The remote and the remote branch that `git push` would push this branch
|
||||
// to, as git determines them from push.default, remote.pushDefault and
|
||||
// branch.<name>.pushRemote. In a triangular workflow these differ from the
|
||||
// upstream. Both are empty if git has no push destination for the branch,
|
||||
// e.g. because push.default is "upstream" and the branch has no upstream.
|
||||
PushRemote string
|
||||
PushBranch string
|
||||
// subject line in commit message
|
||||
Subject string
|
||||
// commit hash
|
||||
@@ -40,6 +47,13 @@ type Branch struct {
|
||||
// determined yet, or up to date with base branch. (We don't need to
|
||||
// distinguish the two, as we don't draw anything in both cases.)
|
||||
BehindBaseBranch atomic.Int32
|
||||
|
||||
// Whether the branch has diverged from its upstream because the upstream
|
||||
// branch was rewritten, and not because the branch has commits of its own.
|
||||
// Such a branch can be reset to its upstream without losing anything.
|
||||
// False for branches that haven't diverged, and for those we haven't
|
||||
// determined it for yet.
|
||||
UpstreamRewritten atomic.Bool
|
||||
}
|
||||
|
||||
func (b *Branch) FullRefName() string {
|
||||
@@ -119,6 +133,13 @@ func (b *Branch) IsBehindForPush() bool {
|
||||
return b.RemoteBranchStoredLocally() && b.BehindForPush != "0"
|
||||
}
|
||||
|
||||
// Whether the branch has commits that its push destination doesn't have. False
|
||||
// if the remote branch it would be pushed to isn't stored locally, in which
|
||||
// case the count is "?".
|
||||
func (b *Branch) IsAheadForPush() bool {
|
||||
return b.RemoteBranchStoredLocally() && b.AheadForPush != "0" && b.AheadForPush != "?"
|
||||
}
|
||||
|
||||
// for when we're in a detached head state
|
||||
func (b *Branch) IsRealBranch() bool {
|
||||
return b.AheadForPull != "" && b.BehindForPull != ""
|
||||
|
||||
@@ -19,6 +19,9 @@ type Worktree struct {
|
||||
// * the worktree is mid-rebase on the branch
|
||||
// * the worktree is mid-bisect on the branch
|
||||
Branch string
|
||||
// If true, the worktree is mid-rebase or mid-bisect on Branch, so its HEAD
|
||||
// is detached rather than pointing at the branch
|
||||
IsRebasingOrBisecting bool
|
||||
// The HEAD sha of the worktree. Always populated (even when Branch is set).
|
||||
// Used for display when Branch is empty (detached HEAD state).
|
||||
Head string
|
||||
|
||||
@@ -108,6 +108,14 @@ func (self *CmdObj) GetEnvVars() []string {
|
||||
return self.cmd.Env
|
||||
}
|
||||
|
||||
// SetEnviron replaces the command's whole environment, for a command that has
|
||||
// to run in the same one as another command rather than in this process's.
|
||||
func (self *CmdObj) SetEnviron(env []string) *CmdObj {
|
||||
self.cmd.Env = env
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// sets the working directory
|
||||
func (self *CmdObj) SetWd(wd string) *CmdObj {
|
||||
self.cmd.Dir = wd
|
||||
|
||||
@@ -33,6 +33,16 @@ func (self *CmdObjBuilder) New(args []string) *CmdObj {
|
||||
return cmdObj
|
||||
}
|
||||
|
||||
// NewFromCmd wraps a command that has already been built, for a caller that
|
||||
// holds an *exec.Cmd and needs it as a CmdObj. The command itself is shared,
|
||||
// not copied, so whatever was set on it still applies.
|
||||
func (self *CmdObjBuilder) NewFromCmd(cmd *exec.Cmd) *CmdObj {
|
||||
return &CmdObj{
|
||||
cmd: cmd,
|
||||
runner: self.runner,
|
||||
}
|
||||
}
|
||||
|
||||
// A command with explicit environment from env
|
||||
func (self *CmdObjBuilder) NewWithEnviron(args []string, env []string) *CmdObj {
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
|
||||
@@ -4,12 +4,10 @@ import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
@@ -203,61 +201,25 @@ func (c *OSCommand) FileExists(path string) (bool, error) {
|
||||
|
||||
// PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C
|
||||
func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
|
||||
cmds := lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) *exec.Cmd {
|
||||
return cmdObj.GetCmd()
|
||||
})
|
||||
c.logPipeline(cmdObjs)
|
||||
|
||||
logCmdStr := strings.Join(
|
||||
lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) string {
|
||||
return cmdObj.ToString()
|
||||
}),
|
||||
" | ",
|
||||
)
|
||||
|
||||
c.LogCommand(logCmdStr, true)
|
||||
|
||||
for i := range len(cmds) - 1 {
|
||||
stdout, err := cmds[i].StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmds[i+1].Stdin = stdout
|
||||
cmds, parentEnds, err := wirePipeline(cmdObjs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// keeping this here in case I adapt this code for some other purpose in the future
|
||||
// cmds[len(cmds)-1].Stdout = os.Stdout
|
||||
|
||||
stderrs := make([]bytes.Buffer, len(cmds))
|
||||
for i := range cmds {
|
||||
cmds[i].Stderr = &stderrs[i]
|
||||
}
|
||||
|
||||
// Start every command before waiting for any of them: waiting for a command
|
||||
// closes our end of the pipe that feeds the next one, and a command that
|
||||
// hasn't been started by then would inherit a closed stdin.
|
||||
started := 0
|
||||
var startErr error
|
||||
for _, cmd := range cmds {
|
||||
if err := cmd.Start(); err != nil {
|
||||
startErr = err
|
||||
break
|
||||
}
|
||||
|
||||
started++
|
||||
}
|
||||
started, startErr := startPipeline(cmds, parentEnds)
|
||||
|
||||
finalErrors := []string{}
|
||||
|
||||
if startErr != nil {
|
||||
c.Log.Error(startErr)
|
||||
finalErrors = append(finalErrors, startErr.Error())
|
||||
|
||||
// Without the rest of the pipeline to drain them, the commands we did
|
||||
// start could block forever writing to a full pipe.
|
||||
for _, cmd := range cmds[:started] {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
for i, cmd := range cmds[:started] {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package oscommands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// Pipeline is a chain of running commands, each one's output feeding the next
|
||||
// one's input, with the last one's output going somewhere the caller reads. Its
|
||||
// method set is the one a render task expects of a command (see tasks.Cmd), so
|
||||
// a pipeline can render a view just as a single command can.
|
||||
type Pipeline struct {
|
||||
cmds []*exec.Cmd
|
||||
cmdStr string
|
||||
}
|
||||
|
||||
// StartPipeline starts the given commands wired A | B | C and returns the
|
||||
// pipeline together with the reader for its output.
|
||||
//
|
||||
// Every command's stderr goes to that same output, so whatever a command
|
||||
// complains about is part of what the caller reads. A diff renderer's error
|
||||
// message belongs on screen with the diff it failed to render.
|
||||
//
|
||||
// Closing the reader is how a pipeline is brought down. The last command's next
|
||||
// write fails, so it exits, and the failure travels back up the chain as each
|
||||
// command in turn writes into a pipe whose reader is gone.
|
||||
func (c *OSCommand) StartPipeline(cmdObjs ...*CmdObj) (*Pipeline, io.ReadCloser, error) {
|
||||
c.logPipeline(cmdObjs)
|
||||
|
||||
cmds, parentEnds, err := wirePipeline(cmdObjs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
closeAll(parentEnds)
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, cmd := range cmds {
|
||||
cmd.Stderr = writer
|
||||
}
|
||||
cmds[len(cmds)-1].Stdout = writer
|
||||
parentEnds = append(parentEnds, writer)
|
||||
|
||||
started, err := startPipeline(cmds, parentEnds)
|
||||
if err != nil {
|
||||
for _, cmd := range cmds[:started] {
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
_ = reader.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &Pipeline{cmds: cmds, cmdStr: pipelineString(cmdObjs)}, reader, nil
|
||||
}
|
||||
|
||||
func (self *Pipeline) String() string {
|
||||
return self.cmdStr
|
||||
}
|
||||
|
||||
// Wait waits for every command to exit and reports the failure nearest the end
|
||||
// of the pipeline. A command that fails leaves the ones before it writing into
|
||||
// a pipe nobody reads, so their own broken-pipe failures are consequences of it
|
||||
// rather than the cause worth reporting, while a command that fails early
|
||||
// leaves the ones after it with nothing to read and no reason to fail at all.
|
||||
func (self *Pipeline) Wait() error {
|
||||
var lastErr error
|
||||
for _, cmd := range self.cmds {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
lastErr = fmt.Errorf("%s: %w", cmd.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// Terminate asks every command to stop, without waiting for any of them. On
|
||||
// platforms where that does nothing, the pipeline comes down when its output
|
||||
// reader is closed; see StartPipeline.
|
||||
func (self *Pipeline) Terminate() error {
|
||||
var firstErr error
|
||||
for _, cmd := range self.cmds {
|
||||
if err := TerminateProcessGracefully(cmd.Process); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// logPipeline enters a chain of commands into the command log, unless the first
|
||||
// command was marked not to be logged; it speaks for the pipeline. A render runs
|
||||
// its pipeline again on every selection change, so a caller has to be able to
|
||||
// keep it out of the log.
|
||||
func (c *OSCommand) logPipeline(cmdObjs []*CmdObj) {
|
||||
if cmdObjs[0].ShouldLog() {
|
||||
c.LogCommand(pipelineString(cmdObjs), true)
|
||||
}
|
||||
}
|
||||
|
||||
// pipelineString names a chain of commands the way a shell would write it.
|
||||
func pipelineString(cmdObjs []*CmdObj) string {
|
||||
return strings.Join(
|
||||
lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) string {
|
||||
return cmdObj.ToString()
|
||||
}),
|
||||
" | ",
|
||||
)
|
||||
}
|
||||
|
||||
// wirePipeline connects each command's output to the next one's input, like
|
||||
// A | B | C, and returns the commands along with the parent's ends of those
|
||||
// pipes. The last command's output is left for the caller to direct.
|
||||
//
|
||||
// The parent's ends have to be closed once the commands are running.
|
||||
// startPipeline does that; see there for why it matters.
|
||||
func wirePipeline(cmdObjs []*CmdObj) ([]*exec.Cmd, []io.Closer, error) {
|
||||
cmds := lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) *exec.Cmd {
|
||||
return cmdObj.GetCmd()
|
||||
})
|
||||
|
||||
parentEnds := []io.Closer{}
|
||||
for i := range len(cmds) - 1 {
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
closeAll(parentEnds)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
cmds[i].Stdout = writer
|
||||
cmds[i+1].Stdin = reader
|
||||
parentEnds = append(parentEnds, reader, writer)
|
||||
}
|
||||
|
||||
return cmds, parentEnds, nil
|
||||
}
|
||||
|
||||
// startPipeline starts every command and reports how many it got going. Every
|
||||
// one is started before any of them is waited for: waiting closes our end of
|
||||
// the pipe that feeds the next command, and one that hasn't been started by
|
||||
// then would inherit a closed stdin.
|
||||
//
|
||||
// Once they are all running, each of them holds its own ends of the pipes it
|
||||
// reads and writes, and the parent lets go of its copies. Both directions
|
||||
// matter. While the parent holds the read end of a link, a command writing
|
||||
// into it never learns that the command meant to read it is gone, and keeps
|
||||
// running after the pipeline has been brought down. While the parent holds the
|
||||
// write end, the command reading it never reaches the end of its input.
|
||||
//
|
||||
// When a command fails to start, the ones already running are killed, since
|
||||
// without the rest of the pipeline to drain them they could block forever
|
||||
// writing to a full pipe. They still have to be reaped, so the count covers
|
||||
// them too.
|
||||
func startPipeline(cmds []*exec.Cmd, parentEnds []io.Closer) (int, error) {
|
||||
defer closeAll(parentEnds)
|
||||
|
||||
for i, cmd := range cmds {
|
||||
if err := cmd.Start(); err != nil {
|
||||
for _, started := range cmds[:i] {
|
||||
_ = started.Process.Kill()
|
||||
}
|
||||
|
||||
return i, err
|
||||
}
|
||||
}
|
||||
|
||||
return len(cmds), nil
|
||||
}
|
||||
|
||||
func closeAll(closers []io.Closer) {
|
||||
for _, closer := range closers {
|
||||
_ = closer.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package oscommands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// The pipeline tests need programs to run, and the test binary is the one
|
||||
// program every platform we test on is sure to have. pipelineMember builds a
|
||||
// command that re-runs this binary in the role a member of the pipeline is to
|
||||
// play; the roles are in TestPipelineMember.
|
||||
const pipelineRoleEnvVar = "LAZYGIT_TEST_PIPELINE_ROLE"
|
||||
|
||||
func pipelineMember(role string) *CmdObj {
|
||||
return NewDummyOSCommand().Cmd.
|
||||
New([]string{os.Args[0], "-test.run=^TestPipelineMember$"}).
|
||||
AddEnvVars(pipelineRoleEnvVar + "=" + role)
|
||||
}
|
||||
|
||||
// TestPipelineMember is the program the pipeline tests run, not a test of its
|
||||
// own. It exits before the testing package reports anything, so that its output
|
||||
// is what the role wrote and nothing else.
|
||||
//
|
||||
// For the same reason it exits with syscall.Exit, which skips the exit hooks
|
||||
// that os.Exit runs. In a binary built with -cover, one of these hooks writes
|
||||
// coverage data to $GOCOVERDIR and prints an error to stderr if that fails. On
|
||||
// Windows it fails now and then if two members of a pipeline exit at the same
|
||||
// time, because both of them replace the same file in that directory.
|
||||
func TestPipelineMember(t *testing.T) {
|
||||
switch os.Getenv(pipelineRoleEnvVar) {
|
||||
case "":
|
||||
t.Skip("not a test; the pipeline tests run this binary in a role")
|
||||
case "count":
|
||||
for i := 1; i <= 3; i++ {
|
||||
fmt.Printf("line %d\n", i)
|
||||
}
|
||||
case "upcase":
|
||||
input, _ := io.ReadAll(os.Stdin)
|
||||
fmt.Print(strings.ToUpper(string(input)))
|
||||
case "copy":
|
||||
_, _ = io.Copy(os.Stdout, os.Stdin)
|
||||
case "complain":
|
||||
fmt.Fprintln(os.Stderr, "something went wrong")
|
||||
syscall.Exit(3)
|
||||
case "flood":
|
||||
// A failed write means the reader is gone, and there is no point
|
||||
// writing to nobody. On platforms that raise a signal for it instead,
|
||||
// this process is already dead by the time the write returns.
|
||||
for i := 1; ; i++ {
|
||||
if _, err := fmt.Printf("line %d\n", i); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syscall.Exit(0)
|
||||
}
|
||||
|
||||
func TestStartPipelineStreamsTheOutputOfTheLastCommand(t *testing.T) {
|
||||
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
|
||||
pipelineMember("count"),
|
||||
pipelineMember("upcase"),
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "LINE 1\nLINE 2\nLINE 3\n", string(output))
|
||||
|
||||
assert.NoError(t, pipeline.Wait())
|
||||
assert.NoError(t, reader.Close())
|
||||
}
|
||||
|
||||
func TestStartPipelineReadsWhatTheCommandsComplainAbout(t *testing.T) {
|
||||
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
|
||||
pipelineMember("count"),
|
||||
pipelineMember("complain"),
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "something went wrong\n", string(output))
|
||||
|
||||
// The failure of the command nearest the output is the one reported, even
|
||||
// though the one feeding it was left writing into a pipe nobody reads.
|
||||
assert.ErrorContains(t, pipeline.Wait(), "exit status 3")
|
||||
|
||||
assert.NoError(t, reader.Close())
|
||||
}
|
||||
|
||||
func TestClosingAPipelinesOutputBringsItDown(t *testing.T) {
|
||||
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
|
||||
pipelineMember("flood"),
|
||||
pipelineMember("copy"),
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Read some output first, so that both commands are past their startup and
|
||||
// really running when the reader goes.
|
||||
buf := make([]byte, len("line 1\n"))
|
||||
_, err = io.ReadFull(reader, buf)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "line 1\n", string(buf))
|
||||
|
||||
assert.NoError(t, reader.Close())
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- pipeline.Wait() }()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the pipeline was still running long after its output was closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPipelineReportsACommandItCannotStart(t *testing.T) {
|
||||
osCommand := NewDummyOSCommand()
|
||||
|
||||
_, _, err := osCommand.StartPipeline(
|
||||
pipelineMember("count"),
|
||||
osCommand.Cmd.New([]string{"lazygit-no-such-command"}),
|
||||
)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPipeCommandsReturnsWhenALaterCommandDiesEarly(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- NewDummyOSCommand().PipeCommands(
|
||||
pipelineMember("flood"),
|
||||
pipelineMember("complain"),
|
||||
)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.ErrorContains(t, err, "something went wrong")
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("PipeCommands was still waiting for a command whose output nothing reads")
|
||||
}
|
||||
}
|
||||
@@ -144,9 +144,7 @@ func TerminateLivePtys() {
|
||||
// graceful signal worth waiting on — git and the common diff tools leave it
|
||||
// to the default handler, which calls ExitProcess at whatever instruction
|
||||
// the process happens to execute — so clients that got the event are
|
||||
// already dying. Killing at an arbitrary point cannot leak a stale
|
||||
// index.lock, because pty-rendered commands don't take that lock (see
|
||||
// withPtyGitConfig in pkg/gui/pty.go).
|
||||
// already dying.
|
||||
//
|
||||
// The pseudoconsole close gets its own goroutine because the kill must not
|
||||
// wait for it: on builds where ClosePseudoConsole blocks until the console
|
||||
|
||||
@@ -16,6 +16,11 @@ type Hunk struct {
|
||||
newStart int
|
||||
// the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example)
|
||||
headerContext string
|
||||
// the lengths declared in the header line ('2' and '3' in the above example),
|
||||
// kept so that we can check the parsed body against them (see
|
||||
// Patch.IsWellFormed). Only set by Parse.
|
||||
declaredOldLength int
|
||||
declaredNewLength int
|
||||
// the body of the hunk, excluding the header line
|
||||
bodyLines []*PatchLine
|
||||
}
|
||||
|
||||
+26
-11
@@ -7,7 +7,9 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
|
||||
// Captures, in order: the old start, the old length (omitted by git when it is
|
||||
// 1), the new start, the new length (likewise), and the trailing context.
|
||||
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`)
|
||||
|
||||
func Parse(patchStr string) *Patch {
|
||||
// ignore trailing newline.
|
||||
@@ -19,13 +21,15 @@ func Parse(patchStr string) *Patch {
|
||||
var currentHunk *Hunk
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
oldStart, newStart, headerContext := headerInfo(line)
|
||||
oldStart, oldLength, newStart, newLength, headerContext := headerInfo(line)
|
||||
|
||||
currentHunk = &Hunk{
|
||||
oldStart: oldStart,
|
||||
newStart: newStart,
|
||||
headerContext: headerContext,
|
||||
bodyLines: []*PatchLine{},
|
||||
oldStart: oldStart,
|
||||
newStart: newStart,
|
||||
declaredOldLength: oldLength,
|
||||
declaredNewLength: newLength,
|
||||
headerContext: headerContext,
|
||||
bodyLines: []*PatchLine{},
|
||||
}
|
||||
hunks = append(hunks, currentHunk)
|
||||
} else if currentHunk != nil {
|
||||
@@ -41,14 +45,25 @@ func Parse(patchStr string) *Patch {
|
||||
}
|
||||
}
|
||||
|
||||
func headerInfo(header string) (int, int, string) {
|
||||
func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLength int, headerContext string) {
|
||||
match := hunkHeaderRegexp.FindStringSubmatch(header)
|
||||
|
||||
oldStart := utils.MustConvertToInt(match[1])
|
||||
newStart := utils.MustConvertToInt(match[2])
|
||||
headerContext := match[3]
|
||||
oldStart = utils.MustConvertToInt(match[1])
|
||||
oldLength = declaredLength(match[2])
|
||||
newStart = utils.MustConvertToInt(match[3])
|
||||
newLength = declaredLength(match[4])
|
||||
headerContext = match[5]
|
||||
|
||||
return oldStart, newStart, headerContext
|
||||
return oldStart, oldLength, newStart, newLength, headerContext
|
||||
}
|
||||
|
||||
// declaredLength parses a length capture of a hunk header, which git omits when
|
||||
// it is 1 (e.g. "@@ -0,0 +1 @@").
|
||||
func declaredLength(match string) int {
|
||||
if match == "" {
|
||||
return 1
|
||||
}
|
||||
return utils.MustConvertToInt(match)
|
||||
}
|
||||
|
||||
func newHunkLine(line string) *PatchLine {
|
||||
|
||||
@@ -79,6 +79,44 @@ func (self *Patch) HunkEndIdx(hunkIndex int) int {
|
||||
return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1
|
||||
}
|
||||
|
||||
// IsWellFormed reports whether every hunk's body matches the lengths declared in
|
||||
// its header. A faithful unified diff always satisfies this; a rendering that
|
||||
// restructured the diff body does not — a diff renderer that puts line numbers in
|
||||
// a gutter, say, shifts the +/- marker off the start of each line, so every body
|
||||
// line reads as context and the computed lengths no longer match the header. That
|
||||
// makes this the test for whether a rendered diff can be parsed as a unified diff
|
||||
// at all, rather than trusting a mis-parse. Only meaningful for patches produced
|
||||
// by Parse, which is where the declared lengths come from.
|
||||
func (self *Patch) IsWellFormed() bool {
|
||||
return self.isWellFormed(false)
|
||||
}
|
||||
|
||||
// IsWellFormedSoFar is IsWellFormed for a patch parsed from a diff we have only the
|
||||
// beginning of. Its last hunk holds the first lines of a body that hasn't all arrived,
|
||||
// so every hunk but the last has to match its header exactly, as before, while the last
|
||||
// one only has to fit within what its header declares.
|
||||
//
|
||||
// The check exists to tell a faithful rendering from a restructured one, and it still
|
||||
// does that. A rendering that moves the +/- marker off the start of the line makes us
|
||||
// read a change as context, and a context line counts towards both lengths, so such a
|
||||
// hunk comes out longer than its header declares rather than shorter.
|
||||
func (self *Patch) IsWellFormedSoFar() bool {
|
||||
return self.isWellFormed(true)
|
||||
}
|
||||
|
||||
func (self *Patch) isWellFormed(lastHunkMayBeIncomplete bool) bool {
|
||||
for i, hunk := range self.hunks {
|
||||
if lastHunkMayBeIncomplete && i == len(self.hunks)-1 {
|
||||
return hunk.oldLength() <= hunk.declaredOldLength &&
|
||||
hunk.newLength() <= hunk.declaredNewLength
|
||||
}
|
||||
if hunk.oldLength() != hunk.declaredOldLength || hunk.newLength() != hunk.declaredNewLength {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (self *Patch) ContainsChanges() bool {
|
||||
return lo.SomeBy(self.hunks, func(hunk *Hunk) bool {
|
||||
return hunk.containsChanges()
|
||||
@@ -114,6 +152,37 @@ func (self *Patch) LineNumberOfLine(idx int) int {
|
||||
return hunk.newStart + offset
|
||||
}
|
||||
|
||||
// Takes a line index in the patch and returns the line number in the old file.
|
||||
// This is the old-file counterpart of LineNumberOfLine; for a deletion it gives
|
||||
// the line's position in the old file (additions get the position they sit at).
|
||||
// If the line is a header line, returns 1.
|
||||
// If the line is a hunk header line, returns the first old-file line number in that hunk.
|
||||
// If the line is out of range below, returns the last old-file line number in the last hunk.
|
||||
func (self *Patch) OldLineNumberOfLine(idx int) int {
|
||||
if idx < len(self.header) || len(self.hunks) == 0 {
|
||||
return 1
|
||||
}
|
||||
|
||||
hunkIdx := self.HunkContainingLine(idx)
|
||||
// cursor out of range, return last file line number
|
||||
if hunkIdx == -1 {
|
||||
lastHunk := self.hunks[len(self.hunks)-1]
|
||||
return lastHunk.oldStart + lastHunk.oldLength() - 1
|
||||
}
|
||||
|
||||
hunk := self.hunks[hunkIdx]
|
||||
hunkStartIdx := self.HunkStartIdx(hunkIdx)
|
||||
idxInHunk := idx - hunkStartIdx
|
||||
|
||||
if idxInHunk == 0 {
|
||||
return hunk.oldStart
|
||||
}
|
||||
|
||||
lines := hunk.bodyLines[:idxInHunk-1]
|
||||
offset := nLinesWithKind(lines, []PatchLineKind{DELETION, CONTEXT})
|
||||
return hunk.oldStart + offset
|
||||
}
|
||||
|
||||
// Returns hunk index containing the line at the given patch line index
|
||||
func (self *Patch) HunkContainingLine(idx int) int {
|
||||
for hunkIdx, hunk := range self.hunks {
|
||||
|
||||
@@ -120,6 +120,20 @@ index 9320895..6d79956 100644
|
||||
lemon
|
||||
`
|
||||
|
||||
// Two deletions with no line between them: they share a new-file line number
|
||||
// (both sit at the same new-file position), so only their old-file line numbers
|
||||
// tell them apart.
|
||||
const consecutiveDeletions = `diff --git a/filename b/filename
|
||||
index 9320895..6d79956 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
-grape
|
||||
-pear
|
||||
lemon
|
||||
`
|
||||
|
||||
const newFile = `diff --git a/newfile b/newfile
|
||||
new file mode 100644
|
||||
index 0000000..4e680cc
|
||||
@@ -682,6 +696,148 @@ func TestLineNumberOfLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWellFormed(t *testing.T) {
|
||||
// The body of a diff as rendered with the +/- markers moved out of the text
|
||||
// and into a gutter: every body line now reads as context, so the lengths no
|
||||
// longer match the header.
|
||||
const gutterMangled = `diff --git a/filename b/filename
|
||||
index 9320895..6d79956 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
grape
|
||||
pear
|
||||
lemon
|
||||
`
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
patchStr string
|
||||
expected bool
|
||||
}{
|
||||
{"simpleDiff", simpleDiff, true},
|
||||
{"renameWithModificationDiff", renameWithModificationDiff, true},
|
||||
{"addNewlineToEndOfFile", addNewlineToEndOfFile, true},
|
||||
{"twoHunks", twoHunks, true},
|
||||
{"consecutiveDeletions", consecutiveDeletions, true},
|
||||
{"newFile", newFile, true},
|
||||
{"deletedFile", deletedFile, true},
|
||||
{"addNewlineToPreviouslyEmptyFile", addNewlineToPreviouslyEmptyFile, true},
|
||||
{"exampleHunk", exampleHunk, true},
|
||||
{"gutterMangled", gutterMangled, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormed())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWellFormedSoFar(t *testing.T) {
|
||||
// A diff read only as far as the middle of its second hunk.
|
||||
const cutShort = `diff --git a/filename b/filename
|
||||
index e48a11c..b2ab81b 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,5 +1,5 @@
|
||||
apple
|
||||
-grape
|
||||
+orange
|
||||
...
|
||||
...
|
||||
...
|
||||
@@ -8,6 +8,8 @@ grape
|
||||
...
|
||||
...
|
||||
`
|
||||
|
||||
// The same diff cut short in its first hunk, so that the second is missing
|
||||
// entirely rather than short.
|
||||
const cutShortInTheFirstHunk = `diff --git a/filename b/filename
|
||||
index e48a11c..b2ab81b 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,5 +1,5 @@
|
||||
apple
|
||||
-grape
|
||||
`
|
||||
|
||||
// A rendering with the +/- markers moved into a gutter, cut short: reading the
|
||||
// changes as context makes the hunk longer than its header declares, not shorter,
|
||||
// so it doesn't pass for a diff we only have the beginning of.
|
||||
const gutterMangledAndCutShort = `diff --git a/filename b/filename
|
||||
index 9320895..6d79956 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
grape
|
||||
pear
|
||||
lemon
|
||||
melon
|
||||
`
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
patchStr string
|
||||
expected bool
|
||||
}{
|
||||
{"simpleDiff", simpleDiff, true},
|
||||
{"twoHunks", twoHunks, true},
|
||||
{"cutShort", cutShort, true},
|
||||
{"cutShortInTheFirstHunk", cutShortInTheFirstHunk, true},
|
||||
{"gutterMangledAndCutShort", gutterMangledAndCutShort, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormedSoFar())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldLineNumberOfLine(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
patchStr string
|
||||
indexes []int
|
||||
expecteds []int
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "twoChangesInOneHunk",
|
||||
patchStr: twoChangesInOneHunk,
|
||||
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1000},
|
||||
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5, 5},
|
||||
},
|
||||
{
|
||||
testName: "consecutiveDeletions",
|
||||
patchStr: consecutiveDeletions,
|
||||
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 1000},
|
||||
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 4, 4},
|
||||
},
|
||||
{
|
||||
testName: "renameWithModificationDiff",
|
||||
patchStr: renameWithModificationDiff,
|
||||
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 1000},
|
||||
expecteds: []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
for i, idx := range s.indexes {
|
||||
patch := Parse(s.patchStr)
|
||||
result := patch.OldLineNumberOfLine(idx)
|
||||
assert.Equal(t, s.expecteds[i], result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNextStageableLineIndex(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -309,6 +310,13 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
|
||||
}
|
||||
}
|
||||
|
||||
// This creates gui.branchColorPatterns, so it must run before the move of
|
||||
// that key into gui.theme below.
|
||||
err = migrateBranchColors(&rootNode, changes)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err)
|
||||
}
|
||||
|
||||
pathsToMove := []struct {
|
||||
oldPath []string
|
||||
newPath []string
|
||||
@@ -317,6 +325,14 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
|
||||
[]string{"keybinding", "worktrees", "viewWorktreeOptions"},
|
||||
[]string{"keybinding", "universal", "newWorktree"},
|
||||
},
|
||||
{
|
||||
[]string{"gui", "authorColors"},
|
||||
[]string{"gui", "theme", "authorColors"},
|
||||
},
|
||||
{
|
||||
[]string{"gui", "branchColorPatterns"},
|
||||
[]string{"gui", "theme", "branchColorPatterns"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, pathToMove := range pathsToMove {
|
||||
@@ -630,6 +646,43 @@ func migratePagersToDiffRenderers(rootNode *yaml.Node, changes *ChangesSet) erro
|
||||
})
|
||||
}
|
||||
|
||||
// The deprecated gui.branchColors matched its keys against the part of a branch
|
||||
// name before the first slash. Turn each key into a pattern that matches the
|
||||
// same branches. If the file has a non-empty gui.branchColorPatterns,
|
||||
// gui.branchColors was ignored, so remove it.
|
||||
func migrateBranchColors(rootNode *yaml.Node, changes *ChangesSet) error {
|
||||
return yaml_utils.TransformNode(rootNode, []string{"gui"}, func(guiNode *yaml.Node) error {
|
||||
branchColorsKeyNode, branchColorsValueNode := yaml_utils.LookupKey(guiNode, "branchColors")
|
||||
if branchColorsKeyNode == nil || branchColorsValueNode.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
|
||||
patternsKeyNode, patternsValueNode := yaml_utils.LookupKey(guiNode, "branchColorPatterns")
|
||||
if patternsKeyNode != nil {
|
||||
switch {
|
||||
case patternsValueNode.Kind == yaml.MappingNode && len(patternsValueNode.Content) > 0:
|
||||
yaml_utils.RemoveKey(guiNode, "branchColors")
|
||||
changes.Add("Removed 'gui.branchColors'; it had no effect because 'gui.branchColorPatterns' is set")
|
||||
return nil
|
||||
case patternsValueNode.Kind == yaml.MappingNode || patternsValueNode.Tag == "!!null":
|
||||
yaml_utils.RemoveKey(guiNode, "branchColorPatterns")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
branchColorsKeyNode.Value = "branchColorPatterns"
|
||||
for i := 0; i < len(branchColorsValueNode.Content)-1; i += 2 {
|
||||
keyNode := branchColorsValueNode.Content[i]
|
||||
keyNode.Value = "^" + regexp.QuoteMeta(keyNode.Value) + "(/|$)"
|
||||
keyNode.Tag = "!!str"
|
||||
}
|
||||
changes.Add("Converted 'gui.branchColors' to 'gui.branchColorPatterns'")
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func hasNonNullScalarValue(node *yaml.Node) bool {
|
||||
return node != nil && node.Kind == yaml.ScalarNode && node.Tag != "!!null" && node.Value != ""
|
||||
}
|
||||
|
||||
@@ -175,6 +175,32 @@ func TestMigrationOfMovedKeys(t *testing.T) {
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Moved 'keybinding.worktrees.viewWorktreeOptions' to 'keybinding.universal.newWorktree'"},
|
||||
},
|
||||
{
|
||||
name: "Move author and branch colors into the theme",
|
||||
input: `gui:
|
||||
authorColors:
|
||||
John Smith: red
|
||||
theme:
|
||||
activeBorderColor:
|
||||
- green
|
||||
branchColorPatterns:
|
||||
^docs/: blue
|
||||
`,
|
||||
expected: `gui:
|
||||
theme:
|
||||
activeBorderColor:
|
||||
- green
|
||||
authorColors:
|
||||
John Smith: red
|
||||
branchColorPatterns:
|
||||
^docs/: blue
|
||||
`,
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{
|
||||
"Moved 'gui.authorColors' to 'gui.theme.authorColors'",
|
||||
"Moved 'gui.branchColorPatterns' to 'gui.theme.branchColorPatterns'",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
@@ -821,3 +847,119 @@ func TestPagerMigration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchColorsMigration(t *testing.T) {
|
||||
moved := "Moved 'gui.branchColorPatterns' to 'gui.theme.branchColorPatterns'"
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
expectedDidChange bool
|
||||
expectedChanges []string
|
||||
}{
|
||||
{
|
||||
name: "No branchColors",
|
||||
input: "gui:\n" +
|
||||
" theme:\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" '^docs/': blue\n",
|
||||
expectedDidChange: false,
|
||||
expectedChanges: []string{},
|
||||
},
|
||||
{
|
||||
name: "branchColors is not an object",
|
||||
input: "gui:\n" +
|
||||
" branchColors: 5\n",
|
||||
expectedDidChange: false,
|
||||
expectedChanges: []string{},
|
||||
},
|
||||
{
|
||||
name: "branchColors is converted to patterns",
|
||||
input: "gui:\n" +
|
||||
" scrollHeight: 2\n" +
|
||||
" branchColors:\n" +
|
||||
" feature: green\n" +
|
||||
" v1.x: '#ff0000'\n" +
|
||||
" 123: red\n" +
|
||||
" mouseEvents: false\n",
|
||||
expected: "gui:\n" +
|
||||
" scrollHeight: 2\n" +
|
||||
" mouseEvents: false\n" +
|
||||
" theme:\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" ^feature(/|$): green\n" +
|
||||
" ^v1\\.x(/|$): '#ff0000'\n" +
|
||||
" ^123(/|$): red\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Converted 'gui.branchColors' to 'gui.branchColorPatterns'", moved},
|
||||
},
|
||||
{
|
||||
name: "branchColors is removed if branchColorPatterns is set",
|
||||
input: "gui:\n" +
|
||||
" branchColors:\n" +
|
||||
" feature: green\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" '^docs/': blue\n",
|
||||
expected: "gui:\n" +
|
||||
" theme:\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" '^docs/': blue\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Removed 'gui.branchColors'; it had no effect because 'gui.branchColorPatterns' is set", moved},
|
||||
},
|
||||
{
|
||||
name: "branchColors replaces an empty branchColorPatterns",
|
||||
input: "gui:\n" +
|
||||
" branchColorPatterns: {}\n" +
|
||||
" branchColors:\n" +
|
||||
" feature: green\n",
|
||||
expected: "gui:\n" +
|
||||
" theme:\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" ^feature(/|$): green\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Converted 'gui.branchColors' to 'gui.branchColorPatterns'", moved},
|
||||
},
|
||||
{
|
||||
name: "branchColors replaces a null branchColorPatterns",
|
||||
input: "gui:\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" branchColors:\n" +
|
||||
" feature: green\n",
|
||||
expected: "gui:\n" +
|
||||
" theme:\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" ^feature(/|$): green\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Converted 'gui.branchColors' to 'gui.branchColorPatterns'", moved},
|
||||
},
|
||||
{
|
||||
name: "branchColors is kept if branchColorPatterns is not an object",
|
||||
input: "gui:\n" +
|
||||
" branchColorPatterns: 5\n" +
|
||||
" branchColors:\n" +
|
||||
" feature: green\n",
|
||||
expected: "gui:\n" +
|
||||
" branchColors:\n" +
|
||||
" feature: green\n" +
|
||||
" theme:\n" +
|
||||
" branchColorPatterns: 5\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{moved},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
changes := NewChangesSet()
|
||||
actual, didChange, err := computeMigratedConfig("path doesn't matter", []byte(s.input), changes)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, s.expectedDidChange, didChange)
|
||||
if didChange {
|
||||
assert.Equal(t, s.expected, string(actual))
|
||||
}
|
||||
assert.Equal(t, s.expectedChanges, changes.ToSliceFromOldest())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/karimkhaleel/jsonschema"
|
||||
"github.com/samber/lo"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ColorPatterns assigns colors to the names that match regular expressions.
|
||||
// It's written in YAML as a mapping from pattern to color, and keeps the
|
||||
// patterns in the order in which they are written, so that the first pattern
|
||||
// that matches a name can decide its color.
|
||||
type ColorPatterns []ColorPattern
|
||||
|
||||
type ColorPattern struct {
|
||||
Pattern string
|
||||
Color string
|
||||
}
|
||||
|
||||
// UnmarshalYAML puts the patterns it reads in front of the ones that are there
|
||||
// already, which come from config files that were loaded earlier.
|
||||
func (p *ColorPatterns) UnmarshalYAML(node *yaml.Node) error {
|
||||
// Decoding into a map reports malformed input the same way as for the
|
||||
// other maps in the config.
|
||||
var colors map[string]string
|
||||
if err := node.Decode(&colors); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
patterns := make(ColorPatterns, 0, len(colors))
|
||||
for i := 0; i < len(node.Content)-1; i += 2 {
|
||||
var pattern string
|
||||
if err := node.Content[i].Decode(&pattern); err != nil {
|
||||
return err
|
||||
}
|
||||
patterns = append(patterns, ColorPattern{Pattern: pattern, Color: colors[pattern]})
|
||||
}
|
||||
|
||||
*p = patterns.over(*p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p ColorPatterns) MarshalYAML() (any, error) {
|
||||
node := &yaml.Node{Kind: yaml.MappingNode}
|
||||
for _, pattern := range p {
|
||||
node.Content = append(node.Content,
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: pattern.Pattern},
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: pattern.Color},
|
||||
)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// JSONSchema describes the patterns as the mapping they are written as.
|
||||
func (ColorPatterns) JSONSchema() *jsonschema.Schema {
|
||||
return &jsonschema.Schema{
|
||||
Type: "object",
|
||||
AdditionalProperties: &jsonschema.Schema{Type: "string"},
|
||||
}
|
||||
}
|
||||
|
||||
// over returns p followed by the patterns of lower that p doesn't have.
|
||||
func (p ColorPatterns) over(lower ColorPatterns) ColorPatterns {
|
||||
return slices.Concat(p, lo.Reject(lower, func(l ColorPattern, _ int) bool {
|
||||
return slices.ContainsFunc(p, func(c ColorPattern) bool { return c.Pattern == l.Pattern })
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type colorPatternsConfig struct {
|
||||
Patterns ColorPatterns `yaml:"patterns"`
|
||||
}
|
||||
|
||||
func TestColorPatternsKeepTheirOrder(t *testing.T) {
|
||||
var config colorPatternsConfig
|
||||
err := yaml.Unmarshal([]byte("patterns:\n"+
|
||||
" '^b': red\n"+
|
||||
" '^a': '#00ff00'\n"+
|
||||
" '^c': blue\n"), &config)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, ColorPatterns{
|
||||
{Pattern: "^b", Color: "red"},
|
||||
{Pattern: "^a", Color: "#00ff00"},
|
||||
{Pattern: "^c", Color: "blue"},
|
||||
}, config.Patterns)
|
||||
}
|
||||
|
||||
func TestColorPatternsOfALaterFileComeFirst(t *testing.T) {
|
||||
var config colorPatternsConfig
|
||||
err := yaml.Unmarshal([]byte("patterns:\n"+
|
||||
" '^a': red\n"+
|
||||
" '^b': green\n"), &config)
|
||||
assert.NoError(t, err)
|
||||
err = yaml.Unmarshal([]byte("patterns:\n"+
|
||||
" '^c': blue\n"+
|
||||
" '^b': yellow\n"), &config)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, ColorPatterns{
|
||||
{Pattern: "^c", Color: "blue"},
|
||||
{Pattern: "^b", Color: "yellow"},
|
||||
{Pattern: "^a", Color: "red"},
|
||||
}, config.Patterns)
|
||||
}
|
||||
|
||||
func TestColorPatternsMustBeAMapping(t *testing.T) {
|
||||
var config colorPatternsConfig
|
||||
err := yaml.Unmarshal([]byte("patterns: 5\n"), &config)
|
||||
assert.ErrorContains(t, err, "cannot unmarshal !!int `5` into map[string]string")
|
||||
}
|
||||
|
||||
func TestColorPatternsSurviveMarshalling(t *testing.T) {
|
||||
config := colorPatternsConfig{Patterns: ColorPatterns{
|
||||
{Pattern: "^b", Color: "red"},
|
||||
{Pattern: "^a", Color: "#00ff00"},
|
||||
{Pattern: "true", Color: "blue"},
|
||||
}}
|
||||
content, err := yaml.Marshal(config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var roundTripped colorPatternsConfig
|
||||
err = yaml.Unmarshal(content, &roundTripped)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, config, roundTripped)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@@ -60,18 +61,23 @@ func (self *DiffRendererConfigManager) GetDiffRendererType() DiffRendererType {
|
||||
return currentDiffRendererConfig.getType()
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetStdinFilterCommand(width int) string {
|
||||
// DiffRendererValues are what the command of a diff renderer can refer to.
|
||||
type DiffRendererValues struct {
|
||||
// The width of the view that the diff is rendered into
|
||||
Width int
|
||||
// The number of lines of context around each hunk
|
||||
DiffContext uint64
|
||||
// Whether the terminal has a light background
|
||||
LightBackground bool
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetStdinFilterCommand(values DiffRendererValues) (string, error) {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_StdinFilter {
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
|
||||
templateValues := map[string]string{
|
||||
"columnWidth": strconv.Itoa(width/2 - 6),
|
||||
}
|
||||
|
||||
commandTemplate := string(currentDiffRendererConfig.Command)
|
||||
return utils.ResolvePlaceholderString(commandTemplate, templateValues)
|
||||
return currentDiffRendererConfig.resolveCommand(values)
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetColorArg() string {
|
||||
@@ -87,17 +93,46 @@ func (self *DiffRendererConfigManager) GetColorArg() string {
|
||||
return colorArg
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetExternalDiffCommand(diffContext uint64) string {
|
||||
func (self *DiffRendererConfigManager) GetExternalDiffCommand(values DiffRendererValues) (string, error) {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff {
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
|
||||
templateValues := map[string]string{
|
||||
"diffContext": strconv.Itoa(int(diffContext)),
|
||||
return currentDiffRendererConfig.resolveCommand(values)
|
||||
}
|
||||
|
||||
// resolveCommand resolves the renderer's command, which is a Go template with
|
||||
// the values it can refer to as its variables. A variable can be written with
|
||||
// or without the leading dot, as in {{.width}} or {{width}}.
|
||||
func (self *DiffRendererConfig) resolveCommand(values DiffRendererValues) (string, error) {
|
||||
colorScheme := "dark"
|
||||
if values.LightBackground {
|
||||
colorScheme = "light"
|
||||
}
|
||||
variables := map[string]any{
|
||||
"width": values.Width,
|
||||
"colorScheme": colorScheme,
|
||||
}
|
||||
switch self.getType() {
|
||||
case DiffRendererType_StdinFilter:
|
||||
variables["columnWidth"] = values.Width/2 - 6
|
||||
case DiffRendererType_ExtDiff:
|
||||
variables["diffContext"] = values.DiffContext
|
||||
case DiffRendererType_RawGit:
|
||||
// has no command
|
||||
}
|
||||
|
||||
return utils.ResolvePlaceholderString(string(currentDiffRendererConfig.Command), templateValues)
|
||||
funcs := template.FuncMap{}
|
||||
for name, value := range variables {
|
||||
funcs[name] = func() any { return value }
|
||||
}
|
||||
|
||||
command, err := utils.ResolveTemplate(string(self.Command), variables, funcs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git.diffRenderers: can't use the command '%s': %w", self.Command, err)
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetRawGitArgs() []string {
|
||||
|
||||
@@ -63,6 +63,158 @@ func TestCurrentDiffRendererName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStdinFilterCommand(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
diffRendererConfig DiffRendererConfig
|
||||
width int
|
||||
lightBackground bool
|
||||
expected string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "a command without template variables is passed through",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --paging=never"},
|
||||
width: 120,
|
||||
expected: "delta --paging=never",
|
||||
},
|
||||
{
|
||||
name: "the width the diff is rendered at",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{width}}"},
|
||||
width: 120,
|
||||
expected: "delta --width=120",
|
||||
},
|
||||
{
|
||||
name: "the width of one side of a side-by-side rendering",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "ydiff -p cat -w {{columnWidth}}"},
|
||||
width: 120,
|
||||
expected: "ydiff -p cat -w 54",
|
||||
},
|
||||
{
|
||||
name: "a template variable can also be written with a leading dot",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{.width}}"},
|
||||
width: 120,
|
||||
expected: "delta --width=120",
|
||||
},
|
||||
{
|
||||
name: "the color scheme on a dark background",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --{{colorScheme}}"},
|
||||
width: 120,
|
||||
expected: "delta --dark",
|
||||
},
|
||||
{
|
||||
name: "the color scheme on a light background",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --{{colorScheme}}"},
|
||||
width: 120,
|
||||
lightBackground: true,
|
||||
expected: "delta --light",
|
||||
},
|
||||
{
|
||||
name: "the command can choose between options by the color scheme",
|
||||
diffRendererConfig: DiffRendererConfig{Command: `delta --syntax-theme={{if eq .colorScheme "light"}}GitHub{{else}}Dracula{{end}}`},
|
||||
width: 120,
|
||||
lightBackground: true,
|
||||
expected: "delta --syntax-theme=GitHub",
|
||||
},
|
||||
{
|
||||
name: "the command can use template expressions",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta{{if gt .width 100}} --side-by-side{{end}}"},
|
||||
width: 120,
|
||||
expected: "delta --side-by-side",
|
||||
},
|
||||
{
|
||||
name: "an unknown template variable is an error",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{.widht}}"},
|
||||
width: 120,
|
||||
expectedError: "can't use the command 'delta --width={{.widht}}'",
|
||||
},
|
||||
{
|
||||
name: "an unknown template variable without a leading dot is an error too",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{widht}}"},
|
||||
width: 120,
|
||||
expectedError: "can't use the command 'delta --width={{widht}}'",
|
||||
},
|
||||
{
|
||||
name: "nothing is returned for a renderer of another type",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}}"},
|
||||
width: 120,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
userConfig := &UserConfig{}
|
||||
userConfig.Git.DiffRenderers = []DiffRendererConfig{s.diffRendererConfig}
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
|
||||
|
||||
command, err := config.GetStdinFilterCommand(DiffRendererValues{Width: s.width, LightBackground: s.lightBackground})
|
||||
if s.expectedError != "" {
|
||||
assert.ErrorContains(t, err, s.expectedError)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, s.expected, command)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExternalDiffCommand(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
diffRendererConfig DiffRendererConfig
|
||||
expected string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "a command without template variables is passed through",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
|
||||
expected: "difft --color=always",
|
||||
},
|
||||
{
|
||||
name: "the width the diff is rendered at",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}}"},
|
||||
expected: "difft --width=120",
|
||||
},
|
||||
{
|
||||
name: "the color scheme",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --background={{colorScheme}}"},
|
||||
expected: "difft --background=dark",
|
||||
},
|
||||
{
|
||||
name: "the width alongside the diff context size",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}} --context={{diffContext}}"},
|
||||
expected: "difft --width=120 --context=3",
|
||||
},
|
||||
{
|
||||
name: "a variable of stdin filters is an error",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{columnWidth}}"},
|
||||
expectedError: "can't use the command 'difft --width={{columnWidth}}'",
|
||||
},
|
||||
{
|
||||
name: "nothing is returned for a renderer of another type",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{width}}"},
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
userConfig := &UserConfig{}
|
||||
userConfig.Git.DiffRenderers = []DiffRendererConfig{s.diffRendererConfig}
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
|
||||
|
||||
command, err := config.GetExternalDiffCommand(DiffRendererValues{Width: 120, DiffContext: 3})
|
||||
if s.expectedError != "" {
|
||||
assert.ErrorContains(t, err, s.expectedError)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, s.expected, command)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDiffRendererNameWithoutDiffRenderers(t *testing.T) {
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return &UserConfig{} })
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// ThemeForBackground returns gui.theme with the overrides for a dark or a light
|
||||
// background applied, and with the defaults for that background in the fields
|
||||
// that neither of them sets. backgroundColor is the terminal's background color
|
||||
// as #rrggbb, or empty if we don't know it.
|
||||
func (c *GuiConfig) ThemeForBackground(lightBackground bool, backgroundColor string) ThemeConfig {
|
||||
override := lo.Ternary(lightBackground, c.LightTheme, c.DarkTheme)
|
||||
return mergeThemes(override, c.Theme, themeDefaults(lightBackground, backgroundColor))
|
||||
}
|
||||
|
||||
// themeDefaults returns the defaults of the theme fields whose default depends
|
||||
// on the terminal's background. GetDefaultConfig leaves these fields empty in
|
||||
// gui.theme, so that a value there comes from the user, and wins over these.
|
||||
//
|
||||
// Most of them are derived from the background color, so that they keep the
|
||||
// same distance from it however dark or light it is. If we don't know the
|
||||
// background color, we assume black or white.
|
||||
func themeDefaults(lightBackground bool, backgroundColor string) ThemeConfig {
|
||||
if len(backgroundColor) != 7 || !utils.IsValidHexValue(backgroundColor) {
|
||||
backgroundColor = lo.Ternary(lightBackground, "#ffffff", "#000000")
|
||||
}
|
||||
|
||||
if lightBackground {
|
||||
return ThemeConfig{
|
||||
// The colors of a light palette are dark enough to read as text on
|
||||
// the background, so none of them works as a background for text.
|
||||
// Some blue mixed into the background keeps the colored text on the
|
||||
// selected line as readable as elsewhere.
|
||||
SelectedLineBgColor: []string{mixHexColors(backgroundColor, "#0064ff", 0.25)},
|
||||
InactiveViewSelectedLineBgColor: []string{mixHexColors(backgroundColor, "#000000", 0.15)},
|
||||
}
|
||||
}
|
||||
|
||||
return ThemeConfig{
|
||||
// Dark palettes make their blue dark enough to work as a background for
|
||||
// text. Unlike a color derived from a dark background, it doesn't turn
|
||||
// into black on terminals with only 8 colors.
|
||||
SelectedLineBgColor: []string{"blue"},
|
||||
InactiveViewSelectedLineBgColor: []string{mixHexColors(backgroundColor, "#ffffff", 0.3)},
|
||||
}
|
||||
}
|
||||
|
||||
// mixHexColors mixes two colors given as #rrggbb. amount is how much of b to
|
||||
// take: 0 gives a, 1 gives b.
|
||||
func mixHexColors(a, b string, amount float64) string {
|
||||
channel := func(color string, i int) float64 {
|
||||
value, _ := strconv.ParseUint(color[1+2*i:3+2*i], 16, 8)
|
||||
return float64(value)
|
||||
}
|
||||
|
||||
result := "#"
|
||||
for i := range 3 {
|
||||
mixed := channel(a, i) + (channel(b, i)-channel(a, i))*amount
|
||||
result += fmt.Sprintf("%02x", int(math.Round(mixed)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// mergeThemes takes each field from the first of the themes that sets it. For
|
||||
// maps and color patterns it merges the entries instead, and an entry of an
|
||||
// earlier theme wins over one with the same key in a later theme.
|
||||
func mergeThemes(themes ...ThemeConfig) ThemeConfig {
|
||||
var result ThemeConfig
|
||||
resultValue := reflect.ValueOf(&result).Elem()
|
||||
for i := range resultValue.NumField() {
|
||||
values := lo.Map(themes, func(theme ThemeConfig, _ int) any {
|
||||
return reflect.ValueOf(theme).Field(i).Interface()
|
||||
})
|
||||
resultValue.Field(i).Set(reflect.ValueOf(mergeThemeField(values)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mergeThemeField(values []any) any {
|
||||
switch values[0].(type) {
|
||||
case []string:
|
||||
return lo.FindOrElse(lo.Map(values, func(v any, _ int) []string { return v.([]string) }), nil,
|
||||
func(v []string) bool { return len(v) > 0 })
|
||||
case map[string]string:
|
||||
merged := map[string]string{}
|
||||
for _, v := range slices.Backward(values) {
|
||||
maps.Copy(merged, v.(map[string]string))
|
||||
}
|
||||
return merged
|
||||
case ColorPatterns:
|
||||
return lo.Reduce(values, func(merged ColorPatterns, v any, _ int) ColorPatterns {
|
||||
return merged.over(v.(ColorPatterns))
|
||||
}, nil)
|
||||
default:
|
||||
panic(fmt.Sprintf("don't know how to merge a theme field of type %T", values[0]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestThemeForBackground(t *testing.T) {
|
||||
gui := GuiConfig{
|
||||
Theme: ThemeConfig{
|
||||
ActiveBorderColor: []string{"green"},
|
||||
InactiveBorderColor: []string{"default"},
|
||||
AuthorColors: map[string]string{"Alice": "red", "Bob": "blue"},
|
||||
BranchColorPatterns: ColorPatterns{
|
||||
{Pattern: "^docs/", Color: "cyan"},
|
||||
{Pattern: "^feature/", Color: "green"},
|
||||
},
|
||||
},
|
||||
DarkTheme: ThemeConfig{
|
||||
ActiveBorderColor: []string{"yellow"},
|
||||
},
|
||||
LightTheme: ThemeConfig{
|
||||
InactiveBorderColor: []string{"#777777"},
|
||||
AuthorColors: map[string]string{"Bob": "#000080"},
|
||||
BranchColorPatterns: ColorPatterns{
|
||||
{Pattern: "ISSUE", Color: "red"},
|
||||
{Pattern: "^docs/", Color: "#008080"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dark := gui.ThemeForBackground(false, "")
|
||||
assert.Equal(t, []string{"yellow"}, dark.ActiveBorderColor)
|
||||
assert.Equal(t, []string{"default"}, dark.InactiveBorderColor)
|
||||
assert.Equal(t, map[string]string{"Alice": "red", "Bob": "blue"}, dark.AuthorColors)
|
||||
assert.Equal(t, gui.Theme.BranchColorPatterns, dark.BranchColorPatterns)
|
||||
|
||||
light := gui.ThemeForBackground(true, "")
|
||||
assert.Equal(t, []string{"green"}, light.ActiveBorderColor)
|
||||
assert.Equal(t, []string{"#777777"}, light.InactiveBorderColor)
|
||||
assert.Equal(t, map[string]string{"Alice": "red", "Bob": "#000080"}, light.AuthorColors)
|
||||
assert.Equal(t, ColorPatterns{
|
||||
{Pattern: "ISSUE", Color: "red"},
|
||||
{Pattern: "^docs/", Color: "#008080"},
|
||||
{Pattern: "^feature/", Color: "green"},
|
||||
}, light.BranchColorPatterns)
|
||||
|
||||
assert.Equal(t, map[string]string{"Alice": "red", "Bob": "blue"}, gui.Theme.AuthorColors,
|
||||
"merging must leave the themes it merges alone")
|
||||
}
|
||||
|
||||
func TestEveryThemeFieldCanBeOverridden(t *testing.T) {
|
||||
var override ThemeConfig
|
||||
overrideValue := reflect.ValueOf(&override).Elem()
|
||||
for i := range overrideValue.NumField() {
|
||||
field := overrideValue.Field(i)
|
||||
switch field.Interface().(type) {
|
||||
case []string:
|
||||
field.Set(reflect.ValueOf([]string{"#123456"}))
|
||||
case map[string]string:
|
||||
field.Set(reflect.ValueOf(map[string]string{"key": "#123456"}))
|
||||
case ColorPatterns:
|
||||
field.Set(reflect.ValueOf(ColorPatterns{{Pattern: "key", Color: "#123456"}}))
|
||||
default:
|
||||
t.Fatalf("no test value for theme field %s", overrideValue.Type().Field(i).Name)
|
||||
}
|
||||
}
|
||||
|
||||
gui := GetDefaultConfig().Gui
|
||||
gui.DarkTheme = override
|
||||
assert.Equal(t, override, gui.ThemeForBackground(false, ""))
|
||||
}
|
||||
|
||||
func TestThemeForBackgroundFallsBackToTheDefaultsForTheBackground(t *testing.T) {
|
||||
gui := GetDefaultConfig().Gui
|
||||
assert.Equal(t, []string{"#4d4d4d"}, gui.ThemeForBackground(false, "").InactiveViewSelectedLineBgColor)
|
||||
assert.Equal(t, []string{"#d9d9d9"}, gui.ThemeForBackground(true, "").InactiveViewSelectedLineBgColor)
|
||||
|
||||
gui.Theme.InactiveViewSelectedLineBgColor = []string{"bold"}
|
||||
assert.Equal(t, []string{"bold"}, gui.ThemeForBackground(false, "").InactiveViewSelectedLineBgColor)
|
||||
assert.Equal(t, []string{"bold"}, gui.ThemeForBackground(true, "").InactiveViewSelectedLineBgColor)
|
||||
|
||||
gui.LightTheme.InactiveViewSelectedLineBgColor = []string{"white"}
|
||||
assert.Equal(t, []string{"bold"}, gui.ThemeForBackground(false, "").InactiveViewSelectedLineBgColor)
|
||||
assert.Equal(t, []string{"white"}, gui.ThemeForBackground(true, "").InactiveViewSelectedLineBgColor)
|
||||
}
|
||||
|
||||
func TestBackgroundDefaultsAreDerivedFromTheBackgroundColor(t *testing.T) {
|
||||
gui := GetDefaultConfig().Gui
|
||||
assert.Equal(t, []string{"#626262"}, gui.ThemeForBackground(false, "#1e1e1e").InactiveViewSelectedLineBgColor)
|
||||
assert.Equal(t, []string{"#d7d1c1"}, gui.ThemeForBackground(true, "#fdf6e3").InactiveViewSelectedLineBgColor)
|
||||
|
||||
assert.Equal(t, []string{"blue"}, gui.ThemeForBackground(false, "#1e1e1e").SelectedLineBgColor)
|
||||
assert.Equal(t, []string{"#bed2ea"}, gui.ThemeForBackground(true, "#fdf6e3").SelectedLineBgColor)
|
||||
assert.Equal(t, []string{"#bfd8ff"}, gui.ThemeForBackground(true, "").SelectedLineBgColor)
|
||||
}
|
||||
|
||||
// If gui.theme had a default for a field that also has a default for the
|
||||
// terminal's background, the former would always win.
|
||||
func TestFieldsWithBackgroundDefaultsHaveNoDefaultInGuiTheme(t *testing.T) {
|
||||
genericDefaults := setThemeFields(GetDefaultConfig().Gui.Theme)
|
||||
for _, field := range setThemeFields(themeDefaults(false, "")) {
|
||||
assert.NotContains(t, genericDefaults, field)
|
||||
}
|
||||
for _, field := range setThemeFields(themeDefaults(true, "")) {
|
||||
assert.NotContains(t, genericDefaults, field)
|
||||
}
|
||||
}
|
||||
|
||||
// If only one of the backgrounds had a default for a field, the field would
|
||||
// have no value at all with the other.
|
||||
func TestDarkAndLightDefaultsSetTheSameFields(t *testing.T) {
|
||||
assert.Equal(t, setThemeFields(themeDefaults(false, "")), setThemeFields(themeDefaults(true, "")))
|
||||
}
|
||||
|
||||
func setThemeFields(theme ThemeConfig) []string {
|
||||
var fields []string
|
||||
themeValue := reflect.ValueOf(theme)
|
||||
for i := range themeValue.NumField() {
|
||||
if themeValue.Field(i).Len() > 0 {
|
||||
fields = append(fields, themeValue.Type().Field(i).Name)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
+36
-20
@@ -68,13 +68,6 @@ func (c *RefresherConfig) ExternalChangeCheckIntervalDuration() time.Duration {
|
||||
}
|
||||
|
||||
type GuiConfig struct {
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
|
||||
AuthorColors map[string]string `yaml:"authorColors"`
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
|
||||
// Deprecated: use branchColorPatterns instead
|
||||
BranchColors map[string]string `yaml:"branchColors" jsonschema:"deprecated"`
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
|
||||
BranchColorPatterns map[string]string `yaml:"branchColorPatterns"`
|
||||
// Custom icons for filenames and file extensions
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color
|
||||
CustomIcons CustomIconsConfig `yaml:"customIcons"`
|
||||
@@ -141,9 +134,19 @@ type GuiConfig struct {
|
||||
// Format used when displaying time if the time is less than 24 hours ago.
|
||||
// Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format
|
||||
ShortTimeFormat string `yaml:"shortTimeFormat"`
|
||||
// Whether the terminal has a dark or a light background. This decides whether 'darkTheme' or 'lightTheme' applies, and the colors of authors are picked to stand out against it.
|
||||
// One of: 'auto' (default) | 'dark' | 'light'
|
||||
// With 'auto', lazygit asks the terminal, and assumes a dark background if the terminal doesn't tell.
|
||||
ColorScheme string `yaml:"colorScheme" jsonschema:"enum=auto,enum=dark,enum=light"`
|
||||
// Config relating to colors and styles.
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes
|
||||
Theme ThemeConfig `yaml:"theme"`
|
||||
// Colors and styles that override those in 'theme' when the terminal has a dark background. It has the same fields as 'theme'.
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
|
||||
DarkTheme ThemeConfig `yaml:"darkTheme"`
|
||||
// Colors and styles that override those in 'theme' when the terminal has a light background. It has the same fields as 'theme'.
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
|
||||
LightTheme ThemeConfig `yaml:"lightTheme"`
|
||||
// Config relating to the commit length indicator
|
||||
CommitLength CommitLengthConfig `yaml:"commitLength"`
|
||||
// If true, show the '5 of 20' footer at the bottom of list views
|
||||
@@ -237,10 +240,15 @@ type ThemeConfig struct {
|
||||
SearchingActiveBorderColor []string `yaml:"searchingActiveBorderColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// Color of keybindings help text in the bottom line
|
||||
OptionsTextColor []string `yaml:"optionsTextColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// Color and attributes of the text of the selected line. The attributes are added to those of the text, and a color replaces the colors of the text.
|
||||
// Set it to 'default' to leave the text as it is, e.g. if you don't want the selected line in bold.
|
||||
SelectedLineFgColor []string `yaml:"selectedLineFgColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// Background color of selected line.
|
||||
// Default: 'blue' if the terminal has a dark background, or a suitable RGB blue computed from the background color if it is light.
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line
|
||||
SelectedLineBgColor []string `yaml:"selectedLineBgColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// Background color of selected line when view doesn't have focus.
|
||||
// Default: a suitable RGB grey computed from the terminal's background color.
|
||||
InactiveViewSelectedLineBgColor []string `yaml:"inactiveViewSelectedLineBgColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// Foreground color of copied commit
|
||||
CherryPickedCommitFgColor []string `yaml:"cherryPickedCommitFgColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
@@ -254,6 +262,10 @@ type ThemeConfig struct {
|
||||
UnstagedChangesColor []string `yaml:"unstagedChangesColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// Default text color
|
||||
DefaultFgColor []string `yaml:"defaultFgColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
|
||||
AuthorColors map[string]string `yaml:"authorColors"`
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
|
||||
BranchColorPatterns ColorPatterns `yaml:"branchColorPatterns"`
|
||||
}
|
||||
|
||||
type CommitLengthConfig struct {
|
||||
@@ -316,7 +328,7 @@ type GitConfig struct {
|
||||
AutoRefresh bool `yaml:"autoRefresh"`
|
||||
// If true, poll the repo periodically for external ref changes (commits, branch updates, checkouts made outside lazygit) and refresh when one is detected. Independent of autoRefresh, which only governs the files panel.
|
||||
AutoDetectExternalChanges bool `yaml:"autoDetectExternalChanges"`
|
||||
// If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged).
|
||||
// If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged). A branch that is checked out in another worktree is fast-forwarded there, unless that worktree has changes to tracked files or is in the middle of a rebase or bisect.
|
||||
// Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
|
||||
AutoForwardBranches string `yaml:"autoForwardBranches" jsonschema:"enum=none,enum=onlyMainBranches,enum=allBranches"`
|
||||
// If true, pass the --all arg to git fetch
|
||||
@@ -660,6 +672,8 @@ type KeybindingCommitFilesConfig struct {
|
||||
type KeybindingMainConfig struct {
|
||||
PrevHunk Keybinding `yaml:"prevHunk"`
|
||||
NextHunk Keybinding `yaml:"nextHunk"`
|
||||
PrevFile Keybinding `yaml:"prevFile"`
|
||||
NextFile Keybinding `yaml:"nextFile"`
|
||||
ToggleSelectHunk Keybinding `yaml:"toggleSelectHunk"`
|
||||
PickBothHunks Keybinding `yaml:"pickBothHunks"`
|
||||
EditSelectHunk Keybinding `yaml:"editSelectHunk"`
|
||||
@@ -881,19 +895,19 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
Language: "auto",
|
||||
TimeFormat: "02 Jan 06",
|
||||
ShortTimeFormat: time.Kitchen,
|
||||
ColorScheme: "auto",
|
||||
Theme: ThemeConfig{
|
||||
ActiveBorderColor: []string{"green", "bold"},
|
||||
SearchingActiveBorderColor: []string{"cyan", "bold"},
|
||||
InactiveBorderColor: []string{"default"},
|
||||
OptionsTextColor: []string{"blue"},
|
||||
SelectedLineBgColor: []string{"blue"},
|
||||
InactiveViewSelectedLineBgColor: []string{"bold"},
|
||||
CherryPickedCommitBgColor: []string{"cyan"},
|
||||
CherryPickedCommitFgColor: []string{"blue"},
|
||||
MarkedBaseCommitBgColor: []string{"yellow"},
|
||||
MarkedBaseCommitFgColor: []string{"blue"},
|
||||
UnstagedChangesColor: []string{"red"},
|
||||
DefaultFgColor: []string{"default"},
|
||||
ActiveBorderColor: []string{"green", "bold"},
|
||||
SearchingActiveBorderColor: []string{"cyan", "bold"},
|
||||
InactiveBorderColor: []string{"dim"},
|
||||
OptionsTextColor: []string{"blue"},
|
||||
SelectedLineFgColor: []string{"bold"},
|
||||
CherryPickedCommitBgColor: []string{"cyan"},
|
||||
CherryPickedCommitFgColor: []string{"blue"},
|
||||
MarkedBaseCommitBgColor: []string{"yellow"},
|
||||
MarkedBaseCommitFgColor: []string{"blue"},
|
||||
UnstagedChangesColor: []string{"red"},
|
||||
DefaultFgColor: []string{"default"},
|
||||
},
|
||||
CommitLength: CommitLengthConfig{Show: true},
|
||||
SkipNoStagedFilesWarning: false,
|
||||
@@ -1170,6 +1184,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
Main: KeybindingMainConfig{
|
||||
PrevHunk: Keybinding{"<left>", "h"},
|
||||
NextHunk: Keybinding{"<right>", "l"},
|
||||
PrevFile: Keybinding{"N"},
|
||||
NextFile: Keybinding{"n"},
|
||||
ToggleSelectHunk: Keybinding{"a"},
|
||||
PickBothHunks: Keybinding{"b"},
|
||||
EditSelectHunk: Keybinding{"E"},
|
||||
|
||||
@@ -18,6 +18,10 @@ func (config *UserConfig) Validate() error {
|
||||
[]string{"dashboard", "allBranchesLog"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("gui.colorScheme", config.Gui.ColorScheme,
|
||||
[]string{"auto", "dark", "light"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("gui.showDivergenceFromBaseBranch", config.Gui.ShowDivergenceFromBaseBranch,
|
||||
[]string{"none", "onlyArrow", "arrowAndNumber"}); err != nil {
|
||||
return err
|
||||
@@ -120,10 +124,16 @@ func validateDiffRenderers(diffRenderers []DiffRendererConfig) error {
|
||||
if len(diffRenderer.Args) > 0 {
|
||||
return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'stdinFilter'.")
|
||||
}
|
||||
if err := validateDiffRendererCommand(diffRenderer); err != nil {
|
||||
return err
|
||||
}
|
||||
case "extDiff":
|
||||
if len(diffRenderer.Args) > 0 {
|
||||
return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'extDiff'.")
|
||||
}
|
||||
if err := validateDiffRendererCommand(diffRenderer); err != nil {
|
||||
return err
|
||||
}
|
||||
case "rawGit":
|
||||
if diffRenderer.Command != "" {
|
||||
return errors.New("git.diffRenderers: 'command' cannot be used with diff renderer type 'rawGit'.")
|
||||
@@ -135,6 +145,14 @@ func validateDiffRenderers(diffRenderers []DiffRendererConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDiffRendererCommand resolves the command with made-up values, so that
|
||||
// a mistake in it shows up when the config is loaded rather than when a diff
|
||||
// is rendered.
|
||||
func validateDiffRendererCommand(diffRenderer DiffRendererConfig) error {
|
||||
_, err := diffRenderer.resolveCommand(DiffRendererValues{Width: 80, DiffContext: 3})
|
||||
return err
|
||||
}
|
||||
|
||||
func validateEnum(name string, value string, allowedValues []string) error {
|
||||
if slices.Contains(allowedValues, value) {
|
||||
return nil
|
||||
|
||||
@@ -409,9 +409,14 @@ func TestUserConfigValidate_diffRenderers(t *testing.T) {
|
||||
{name: "stdinFilter with explicit type", diffRenderer: DiffRendererConfig{Type: "stdinFilter"}, valid: false},
|
||||
{name: "stdinFilter with type default without command", diffRenderer: DiffRendererConfig{}, valid: false},
|
||||
{name: "stdinFilter with args", diffRenderer: DiffRendererConfig{Type: "stdinFilter", Command: "delta", Args: []string{"-x"}}, valid: false},
|
||||
{name: "stdinFilter with a template", diffRenderer: DiffRendererConfig{Command: "delta --width={{width}}{{if gt .width 100}} --side-by-side{{end}}"}, valid: true},
|
||||
{name: "stdinFilter with an unknown template variable", diffRenderer: DiffRendererConfig{Command: "delta --width={{.widht}}"}, valid: false},
|
||||
{name: "stdinFilter with a broken template", diffRenderer: DiffRendererConfig{Command: "delta {{if .width}}"}, valid: false},
|
||||
{name: "external diff", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft"}, valid: true},
|
||||
{name: "external diff without command", diffRenderer: DiffRendererConfig{Type: "extDiff"}, valid: true},
|
||||
{name: "external diff with args", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft", Args: []string{"-x"}}, valid: false},
|
||||
{name: "external diff with a template", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft --context={{diffContext}}"}, valid: true},
|
||||
{name: "external diff with a variable of stdin filters", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{columnWidth}}"}, valid: false},
|
||||
{name: "raw git", diffRenderer: DiffRendererConfig{Type: "rawGit"}, valid: true},
|
||||
{name: "raw git with args", diffRenderer: DiffRendererConfig{Type: "rawGit", Args: []string{"-x"}}, valid: true},
|
||||
{name: "raw git with command", diffRenderer: DiffRendererConfig{Type: "rawGit", Command: "delta"}, valid: false},
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
)
|
||||
|
||||
// ColorScheme says whether the terminal shows light text on a dark background,
|
||||
// or dark text on a light one.
|
||||
type ColorScheme int
|
||||
|
||||
const (
|
||||
ColorSchemeUnknown ColorScheme = iota
|
||||
ColorSchemeDark
|
||||
ColorSchemeLight
|
||||
)
|
||||
|
||||
func (self ColorScheme) String() string {
|
||||
switch self {
|
||||
case ColorSchemeDark:
|
||||
return "dark"
|
||||
case ColorSchemeLight:
|
||||
return "light"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DetectedColorScheme is what the terminal has told us about its colors.
|
||||
type DetectedColorScheme struct {
|
||||
ColorScheme ColorScheme
|
||||
// The background color that ColorScheme was derived from, as #rrggbb. It is
|
||||
// empty if the terminal didn't tell us its background color; ColorScheme is
|
||||
// then whatever the terminal said about itself, if anything.
|
||||
Background string
|
||||
}
|
||||
|
||||
func (self DetectedColorScheme) String() string {
|
||||
if self.Background != "" {
|
||||
return fmt.Sprintf("%s (background %s)", self.ColorScheme, self.Background)
|
||||
}
|
||||
if self.ColorScheme != ColorSchemeUnknown {
|
||||
return fmt.Sprintf("%s (reported by the terminal)", self.ColorScheme)
|
||||
}
|
||||
return self.ColorScheme.String()
|
||||
}
|
||||
|
||||
const (
|
||||
// Makes the terminal send a color scheme report whenever its colors change
|
||||
enableColorSchemeReports = "\x1b[?2031h"
|
||||
disableColorSchemeReports = "\x1b[?2031l"
|
||||
// Asks for a color scheme report: CSI ? 997 ; 1 n for dark, 2 for light
|
||||
requestColorScheme = "\x1b[?996n"
|
||||
// Asks for the background color: OSC 11 ; rgb:RRRR/GGGG/BBBB, ended by BEL
|
||||
// or ST. Ending the request with BEL makes more terminals reply.
|
||||
requestBackgroundColor = "\x1b]11;?\a"
|
||||
)
|
||||
|
||||
// colorSchemeTty is a tcell.Tty that finds out the terminal's color scheme.
|
||||
//
|
||||
// Some terminals say whether they are dark or light, and can report it again
|
||||
// whenever that changes. What they base this on varies, though: some go by the
|
||||
// terminal's own colors, others by the dark or light mode of the operating
|
||||
// system, whether or not the terminal follows it. So we also ask for the
|
||||
// background color, and when the terminal tells us that, it decides; a color
|
||||
// scheme report is then only a sign that the background may have changed.
|
||||
//
|
||||
// The queries go out when tcell starts the tty, which is before tcell sends its
|
||||
// own queries during Screen.Init and waits for their answer. Terminals answer in
|
||||
// order, so the answers to ours have arrived by the time Init returns, and we
|
||||
// know the color scheme before drawing anything without waiting for it.
|
||||
//
|
||||
// tcell doesn't understand the answers and drops them, so all we need to do is
|
||||
// watch for them in the input as it goes by.
|
||||
type colorSchemeTty struct {
|
||||
tcell.Tty
|
||||
|
||||
// What we send when the tty starts
|
||||
queries string
|
||||
|
||||
// Guards all of the fields below, and serializes our writes to the terminal
|
||||
// with tcell's
|
||||
mutex sync.Mutex
|
||||
|
||||
started bool
|
||||
|
||||
background colorful.Color
|
||||
// Whether the terminal has told us its background color
|
||||
haveBackground bool
|
||||
// Whether we have asked for the background color and are waiting for the
|
||||
// answer
|
||||
backgroundRequested bool
|
||||
// The color scheme that the terminal last reported for itself
|
||||
reported ColorScheme
|
||||
// Whether we have asked for a color scheme report and are waiting for it
|
||||
colorSchemeRequested bool
|
||||
// Closed once no answer that waitForReplies waits for is outstanding
|
||||
repliesArrived chan struct{}
|
||||
|
||||
notified DetectedColorScheme
|
||||
onChange func(DetectedColorScheme)
|
||||
|
||||
// Only used by Read, which tcell never calls concurrently with itself
|
||||
scanner terminalReplyScanner
|
||||
}
|
||||
|
||||
var _ tcell.Tty = &colorSchemeTty{}
|
||||
|
||||
func newColorSchemeTty(tty tcell.Tty) *colorSchemeTty {
|
||||
return &colorSchemeTty{
|
||||
Tty: tty,
|
||||
queries: colorSchemeQueries(
|
||||
os.Getenv("TERM"),
|
||||
os.Getenv("TERM_PROGRAM"),
|
||||
os.Getenv("TCELL_NEGOTIATE"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// colorSchemeQueries returns what to ask the terminal. It leaves out the
|
||||
// terminals that tcell doesn't send its own queries to (see
|
||||
// applyKnownTerminalProfile and the legacy terminals in tScreen.Init), except
|
||||
// for those that are known to answer a request for the background color.
|
||||
func colorSchemeQueries(term string, termProgram string, tcellNegotiate string) string {
|
||||
if tcellNegotiate == "disable" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if term == "st" || strings.HasPrefix(term, "st-") ||
|
||||
strings.HasPrefix(term, "vt") || strings.Contains(term, "ansi") ||
|
||||
term == "linux" || term == "sun" || term == "sun-color" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if termProgram == "Apple_Terminal" || termProgram == "WezTerm" {
|
||||
return requestBackgroundColor
|
||||
}
|
||||
|
||||
return enableColorSchemeReports + requestColorScheme + requestBackgroundColor
|
||||
}
|
||||
|
||||
// subscribe sets a function to call whenever the detected color scheme changes,
|
||||
// and returns the one detected so far. The function is called on the goroutine
|
||||
// that reads from the tty, so it must not block.
|
||||
func (self *colorSchemeTty) subscribe(onChange func(DetectedColorScheme)) DetectedColorScheme {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
self.onChange = onChange
|
||||
self.notified = self.detected()
|
||||
return self.notified
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) Start() error {
|
||||
if err := self.Tty.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
self.started = true
|
||||
self.writeLocked(self.queries)
|
||||
if strings.Contains(self.queries, requestBackgroundColor) {
|
||||
self.backgroundRequested = true
|
||||
}
|
||||
if strings.Contains(self.queries, requestColorScheme) {
|
||||
self.colorSchemeRequested = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForReplies waits until the terminal has answered what we asked it, but
|
||||
// no longer than the timeout. Call it before handing the terminal to another
|
||||
// program, or the answers would reach that program as if they were typed. It
|
||||
// only waits for the answers that the terminal has given before, so a terminal
|
||||
// that doesn't answer at all costs no time.
|
||||
func (self *colorSchemeTty) waitForReplies(timeout time.Duration) {
|
||||
self.mutex.Lock()
|
||||
if !self.awaitingRepliesLocked() {
|
||||
self.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
if self.repliesArrived == nil {
|
||||
self.repliesArrived = make(chan struct{})
|
||||
}
|
||||
repliesArrived := self.repliesArrived
|
||||
self.mutex.Unlock()
|
||||
|
||||
select {
|
||||
case <-repliesArrived:
|
||||
case <-time.After(timeout):
|
||||
}
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) awaitingRepliesLocked() bool {
|
||||
return (self.backgroundRequested && self.haveBackground) ||
|
||||
(self.colorSchemeRequested && self.reported != ColorSchemeUnknown)
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) Stop() error {
|
||||
self.mutex.Lock()
|
||||
// Otherwise, the program we hand the terminal to would receive the reports
|
||||
// as if they were typed
|
||||
if strings.Contains(self.queries, enableColorSchemeReports) {
|
||||
self.writeLocked(disableColorSchemeReports)
|
||||
}
|
||||
self.started = false
|
||||
self.mutex.Unlock()
|
||||
|
||||
return self.Tty.Stop()
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) Write(p []byte) (int, error) {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
return self.Tty.Write(p)
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) Read(p []byte) (int, error) {
|
||||
n, err := self.Tty.Read(p)
|
||||
|
||||
for _, reply := range self.scanner.scan(p[:n]) {
|
||||
self.handleReply(reply)
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// onFocusGained asks for the background color again, for the terminals that
|
||||
// tell us their background color but not when it changes. When the operating
|
||||
// system switches between dark and light mode, the user is usually busy
|
||||
// elsewhere, so coming back to the terminal is a good time to check.
|
||||
func (self *colorSchemeTty) onFocusGained() {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
self.requestBackgroundColorLocked()
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) handleReply(reply terminalReply) {
|
||||
self.mutex.Lock()
|
||||
|
||||
if reply.background != nil {
|
||||
self.background = *reply.background
|
||||
self.haveBackground = true
|
||||
self.backgroundRequested = false
|
||||
} else {
|
||||
self.reported = reply.colorScheme
|
||||
self.colorSchemeRequested = false
|
||||
self.requestBackgroundColorLocked()
|
||||
}
|
||||
|
||||
if self.repliesArrived != nil && !self.awaitingRepliesLocked() {
|
||||
close(self.repliesArrived)
|
||||
self.repliesArrived = nil
|
||||
}
|
||||
|
||||
detected := self.detected()
|
||||
onChange := self.onChange
|
||||
changed := detected != self.notified
|
||||
if changed {
|
||||
self.notified = detected
|
||||
}
|
||||
|
||||
self.mutex.Unlock()
|
||||
|
||||
if changed && onChange != nil {
|
||||
onChange(detected)
|
||||
}
|
||||
}
|
||||
|
||||
// requestBackgroundColorLocked asks for the background color, if the terminal
|
||||
// has told us its background color before and isn't about to do it anyway.
|
||||
func (self *colorSchemeTty) requestBackgroundColorLocked() {
|
||||
if !self.started || !self.haveBackground || self.backgroundRequested {
|
||||
return
|
||||
}
|
||||
|
||||
self.writeLocked(requestBackgroundColor)
|
||||
self.backgroundRequested = true
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) detected() DetectedColorScheme {
|
||||
if !self.haveBackground {
|
||||
return DetectedColorScheme{ColorScheme: self.reported}
|
||||
}
|
||||
|
||||
return DetectedColorScheme{
|
||||
ColorScheme: colorSchemeOfBackground(self.background),
|
||||
Background: self.background.Hex(),
|
||||
}
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) writeLocked(s string) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// If the terminal is gone, tcell finds out when it next writes or reads
|
||||
_, _ = self.Tty.Write([]byte(s))
|
||||
}
|
||||
|
||||
func colorSchemeOfBackground(background colorful.Color) ColorScheme {
|
||||
lightness, _, _ := background.Lab()
|
||||
if lightness < 0.5 {
|
||||
return ColorSchemeDark
|
||||
}
|
||||
return ColorSchemeLight
|
||||
}
|
||||
|
||||
// terminalReply is one of the answers that colorSchemeTty asks the terminal
|
||||
// for. Exactly one of its fields is set.
|
||||
type terminalReply struct {
|
||||
colorScheme ColorScheme
|
||||
background *colorful.Color
|
||||
}
|
||||
|
||||
type terminalReplyScannerState int
|
||||
|
||||
const (
|
||||
scanningText terminalReplyScannerState = iota
|
||||
scanningEscape
|
||||
scanningCsi
|
||||
scanningOsc
|
||||
scanningOscEscape
|
||||
)
|
||||
|
||||
// No reply we look for is longer than this; we don't look at longer sequences
|
||||
const maxTerminalReplyLength = 64
|
||||
|
||||
// terminalReplyScanner finds the terminal's answers to our color scheme queries
|
||||
// in its input. The input arrives in chunks, which may split an answer anywhere,
|
||||
// so the scanner keeps its state from one chunk to the next.
|
||||
type terminalReplyScanner struct {
|
||||
state terminalReplyScannerState
|
||||
sequence []byte
|
||||
oversized bool
|
||||
}
|
||||
|
||||
func (self *terminalReplyScanner) scan(input []byte) []terminalReply {
|
||||
var replies []terminalReply
|
||||
|
||||
for _, b := range input {
|
||||
switch self.state {
|
||||
case scanningText:
|
||||
if b == '\x1b' {
|
||||
self.state = scanningEscape
|
||||
}
|
||||
|
||||
case scanningEscape:
|
||||
self.scanEscape(b)
|
||||
|
||||
case scanningCsi:
|
||||
switch {
|
||||
case b == '\x1b':
|
||||
self.state = scanningEscape
|
||||
case b >= 0x20 && b <= 0x3f: // parameter and intermediate bytes
|
||||
self.appendToSequence(b)
|
||||
case b >= 0x40 && b <= 0x7e: // final byte
|
||||
self.state = scanningText
|
||||
if b == 'n' && !self.oversized {
|
||||
if colorScheme, ok := parseColorSchemeReport(string(self.sequence)); ok {
|
||||
replies = append(replies, terminalReply{colorScheme: colorScheme})
|
||||
}
|
||||
}
|
||||
default:
|
||||
self.state = scanningText
|
||||
}
|
||||
|
||||
case scanningOsc:
|
||||
switch b {
|
||||
case '\a':
|
||||
self.state = scanningText
|
||||
replies = self.appendOscReply(replies)
|
||||
case '\x1b':
|
||||
self.state = scanningOscEscape
|
||||
default:
|
||||
self.appendToSequence(b)
|
||||
}
|
||||
|
||||
case scanningOscEscape:
|
||||
if b == '\\' {
|
||||
self.state = scanningText
|
||||
replies = self.appendOscReply(replies)
|
||||
} else {
|
||||
// Not a string terminator, so a new escape sequence has begun and
|
||||
// the one before it was cut short
|
||||
self.scanEscape(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return replies
|
||||
}
|
||||
|
||||
// scanEscape handles the byte after an ESC.
|
||||
func (self *terminalReplyScanner) scanEscape(b byte) {
|
||||
switch b {
|
||||
case '[':
|
||||
self.startSequence(scanningCsi)
|
||||
case ']':
|
||||
self.startSequence(scanningOsc)
|
||||
case '\x1b':
|
||||
self.state = scanningEscape
|
||||
default:
|
||||
self.state = scanningText
|
||||
}
|
||||
}
|
||||
|
||||
func (self *terminalReplyScanner) startSequence(state terminalReplyScannerState) {
|
||||
self.state = state
|
||||
self.sequence = self.sequence[:0]
|
||||
self.oversized = false
|
||||
}
|
||||
|
||||
func (self *terminalReplyScanner) appendToSequence(b byte) {
|
||||
if len(self.sequence) >= maxTerminalReplyLength {
|
||||
self.oversized = true
|
||||
return
|
||||
}
|
||||
self.sequence = append(self.sequence, b)
|
||||
}
|
||||
|
||||
func (self *terminalReplyScanner) appendOscReply(replies []terminalReply) []terminalReply {
|
||||
if self.oversized {
|
||||
return replies
|
||||
}
|
||||
|
||||
if background, ok := parseBackgroundColorReport(string(self.sequence)); ok {
|
||||
replies = append(replies, terminalReply{background: &background})
|
||||
}
|
||||
|
||||
return replies
|
||||
}
|
||||
|
||||
// parseColorSchemeReport parses the parameters of CSI ? 997 ; <n> n
|
||||
func parseColorSchemeReport(params string) (ColorScheme, bool) {
|
||||
switch params {
|
||||
case "?997;1":
|
||||
return ColorSchemeDark, true
|
||||
case "?997;2":
|
||||
return ColorSchemeLight, true
|
||||
default:
|
||||
return ColorSchemeUnknown, false
|
||||
}
|
||||
}
|
||||
|
||||
// parseBackgroundColorReport parses the content of OSC 11 ; rgb:R/G/B, where
|
||||
// each of R, G and B has from one to four hex digits. Some terminals send
|
||||
// rgba:R/G/B/A instead.
|
||||
func parseBackgroundColorReport(content string) (colorful.Color, bool) {
|
||||
spec, ok := strings.CutPrefix(content, "11;")
|
||||
if !ok {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
|
||||
var components []string
|
||||
if rgb, ok := strings.CutPrefix(spec, "rgb:"); ok {
|
||||
components = strings.Split(rgb, "/")
|
||||
if len(components) != 3 {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
} else if rgba, ok := strings.CutPrefix(spec, "rgba:"); ok {
|
||||
components = strings.Split(rgba, "/")
|
||||
if len(components) != 4 {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
} else {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
|
||||
values := [3]float64{}
|
||||
for i := range values {
|
||||
component := components[i]
|
||||
if len(component) < 1 || len(component) > 4 {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
value, err := strconv.ParseUint(component, 16, 16)
|
||||
if err != nil {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
maxValue := uint64(1)<<(4*len(component)) - 1
|
||||
values[i] = float64(value) / float64(maxValue)
|
||||
}
|
||||
|
||||
return colorful.Color{R: values[0], G: values[1], B: values[2]}, true
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func describeReplies(replies []terminalReply) []string {
|
||||
result := []string{}
|
||||
for _, reply := range replies {
|
||||
if reply.background != nil {
|
||||
result = append(result, reply.background.Hex())
|
||||
} else {
|
||||
result = append(result, reply.colorScheme.String())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestTerminalReplyScanner(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "dark report",
|
||||
input: "\x1b[?997;1n",
|
||||
expected: []string{"dark"},
|
||||
},
|
||||
{
|
||||
name: "light report",
|
||||
input: "\x1b[?997;2n",
|
||||
expected: []string{"light"},
|
||||
},
|
||||
{
|
||||
name: "background ended by ST",
|
||||
input: "\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\",
|
||||
expected: []string{"#1e1e1e"},
|
||||
},
|
||||
{
|
||||
name: "background ended by BEL",
|
||||
input: "\x1b]11;rgb:ffff/ffff/ffff\a",
|
||||
expected: []string{"#ffffff"},
|
||||
},
|
||||
{
|
||||
name: "background with two hex digits per component",
|
||||
input: "\x1b]11;rgb:fd/f6/e3\a",
|
||||
expected: []string{"#fdf6e3"},
|
||||
},
|
||||
{
|
||||
name: "background with one hex digit per component",
|
||||
input: "\x1b]11;rgb:f/0/f\a",
|
||||
expected: []string{"#ff00ff"},
|
||||
},
|
||||
{
|
||||
name: "background with alpha",
|
||||
input: "\x1b]11;rgba:0000/2b2b/3636/ffff\a",
|
||||
expected: []string{"#002b36"},
|
||||
},
|
||||
{
|
||||
name: "replies among other input",
|
||||
input: "j\x1b[?997;1n\x1b[A\x1b[<0;10;5M\x1b]11;rgb:0000/0000/0000\x1b\\\x1b[?62;22c",
|
||||
expected: []string{"dark", "#000000"},
|
||||
},
|
||||
{
|
||||
name: "other reports",
|
||||
input: "\x1b[?997;3n\x1b[0n\x1b[?996n\x1b]10;rgb:ffff/ffff/ffff\a\x1b]4;1;rgb:ffff/0000/0000\a",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "malformed backgrounds",
|
||||
input: "\x1b]11;rgb:zz/00/00\a\x1b]11;rgb:10/20\a\x1b]11;rgb:12345/0/0\a\x1b]11;rgb://\a\x1b]11;#ffffff\a",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "reply after a sequence that was cut short",
|
||||
input: "\x1b]11;rgb:\x1b[?997;2n\x1b[?99\x1b]11;rgb:00/00/00\a",
|
||||
expected: []string{"light", "#000000"},
|
||||
},
|
||||
{
|
||||
name: "reply after an oversized sequence",
|
||||
input: "\x1b]11;" + strings.Repeat("x", 100) + "\a\x1b[?" + strings.Repeat("9", 100) + "n\x1b[?997;1n",
|
||||
expected: []string{"dark"},
|
||||
},
|
||||
{
|
||||
name: "oversized sequence that starts like a reply",
|
||||
input: "\x1b]11;rgb:00/00/00" + strings.Repeat("x", 100) + "\a",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
scanner := terminalReplyScanner{}
|
||||
assert.Equal(t, s.expected, describeReplies(scanner.scan([]byte(s.input))))
|
||||
})
|
||||
|
||||
t.Run(s.name+", split in two", func(t *testing.T) {
|
||||
for i := range len(s.input) {
|
||||
scanner := terminalReplyScanner{}
|
||||
replies := scanner.scan([]byte(s.input[:i]))
|
||||
replies = append(replies, scanner.scan([]byte(s.input[i:]))...)
|
||||
assert.Equal(t, s.expected, describeReplies(replies), "split at %d", i)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(s.name+", byte by byte", func(t *testing.T) {
|
||||
scanner := terminalReplyScanner{}
|
||||
replies := []terminalReply{}
|
||||
for i := range len(s.input) {
|
||||
replies = append(replies, scanner.scan([]byte{s.input[i]})...)
|
||||
}
|
||||
assert.Equal(t, s.expected, describeReplies(replies))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorSchemeQueries(t *testing.T) {
|
||||
allQueries := enableColorSchemeReports + requestColorScheme + requestBackgroundColor
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
term string
|
||||
termProgram string
|
||||
tcellNegotiate string
|
||||
expected string
|
||||
}{
|
||||
{name: "xterm", term: "xterm-256color", expected: allQueries},
|
||||
{name: "tmux", term: "tmux-256color", termProgram: "tmux", expected: allQueries},
|
||||
{name: "Terminal.app", term: "xterm-256color", termProgram: "Apple_Terminal", expected: requestBackgroundColor},
|
||||
{name: "WezTerm", term: "xterm-256color", termProgram: "WezTerm", expected: requestBackgroundColor},
|
||||
{name: "st", term: "st-256color", expected: ""},
|
||||
{name: "Linux console", term: "linux", expected: ""},
|
||||
{name: "VT100", term: "vt100", expected: ""},
|
||||
{name: "negotiation disabled", term: "xterm-256color", tcellNegotiate: "disable", expected: ""},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, colorSchemeQueries(s.term, s.termProgram, s.tcellNegotiate))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTty struct {
|
||||
input string
|
||||
written strings.Builder
|
||||
}
|
||||
|
||||
var _ tcell.Tty = &fakeTty{}
|
||||
|
||||
func (self *fakeTty) Start() error { return nil }
|
||||
func (self *fakeTty) Stop() error { return nil }
|
||||
func (self *fakeTty) Drain() error { return nil }
|
||||
func (self *fakeTty) NotifyResize(chan<- bool) {}
|
||||
func (self *fakeTty) WindowSize() (tcell.WindowSize, error) { return tcell.WindowSize{}, nil }
|
||||
func (self *fakeTty) Close() error { return nil }
|
||||
func (self *fakeTty) Write(p []byte) (int, error) { return self.written.Write(p) }
|
||||
func (self *fakeTty) Read(p []byte) (int, error) {
|
||||
n := copy(p, self.input)
|
||||
self.input = self.input[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (self *fakeTty) takeWritten() string {
|
||||
written := self.written.String()
|
||||
self.written.Reset()
|
||||
return written
|
||||
}
|
||||
|
||||
type colorSchemeTtyTest struct {
|
||||
fake *fakeTty
|
||||
tty *colorSchemeTty
|
||||
notifications []string
|
||||
}
|
||||
|
||||
func newColorSchemeTtyTest(termProgram string) *colorSchemeTtyTest {
|
||||
test := &colorSchemeTtyTest{fake: &fakeTty{}}
|
||||
test.tty = &colorSchemeTty{
|
||||
Tty: test.fake,
|
||||
queries: colorSchemeQueries("xterm-256color", termProgram, ""),
|
||||
}
|
||||
test.tty.subscribe(func(colorScheme DetectedColorScheme) {
|
||||
test.notifications = append(test.notifications, colorScheme.String())
|
||||
})
|
||||
return test
|
||||
}
|
||||
|
||||
// feed has the terminal send input, and has tcell read it
|
||||
func (self *colorSchemeTtyTest) feed(input string) {
|
||||
self.fake.input = input
|
||||
_, _ = self.tty.Read(make([]byte, 128))
|
||||
}
|
||||
|
||||
func (self *colorSchemeTtyTest) takeNotifications() []string {
|
||||
notifications := self.notifications
|
||||
self.notifications = nil
|
||||
return notifications
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyPrefersTheBackground(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
assert.Equal(t, enableColorSchemeReports+requestColorScheme+requestBackgroundColor, test.fake.takeWritten())
|
||||
|
||||
// The terminal says it's light, but its background is dark
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Equal(t, []string{"light (reported by the terminal)"}, test.takeNotifications())
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
|
||||
|
||||
test.feed("\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\\x1b[?62;22c")
|
||||
assert.Equal(t, []string{"dark (background #1e1e1e)"}, test.takeNotifications())
|
||||
assert.Equal(t, "", test.fake.takeWritten())
|
||||
|
||||
// A later report only makes us ask for the background again
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Nil(t, test.takeNotifications())
|
||||
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
|
||||
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
|
||||
|
||||
test.feed("\x1b]11;rgb:ffff/ffff/ffff\x1b\\")
|
||||
assert.Equal(t, []string{"light (background #ffffff)"}, test.takeNotifications())
|
||||
|
||||
// The same background again is no change
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
|
||||
test.feed("\x1b]11;rgb:ffff/ffff/ffff\x1b\\")
|
||||
assert.Nil(t, test.takeNotifications())
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyWithoutBackground(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.fake.takeWritten()
|
||||
|
||||
test.feed("\x1b[?997;1n\x1b[?62;22c")
|
||||
assert.Equal(t, []string{"dark (reported by the terminal)"}, test.takeNotifications())
|
||||
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Equal(t, []string{"light (reported by the terminal)"}, test.takeNotifications())
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "a terminal that didn't answer before isn't asked again")
|
||||
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, "", test.fake.takeWritten())
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyAsksAgainOnFocus(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("Apple_Terminal")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
|
||||
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
|
||||
|
||||
test.feed("\x1b]11;rgb:0000/0000/0000\a")
|
||||
assert.Equal(t, []string{"dark (background #000000)"}, test.takeNotifications())
|
||||
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
|
||||
|
||||
test.feed("\x1b]11;rgb:ffff/ffff/ffff\a")
|
||||
assert.Equal(t, []string{"light (background #ffffff)"}, test.takeNotifications())
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyStopAndStart(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.fake.takeWritten()
|
||||
test.feed("\x1b[?997;1n\x1b]11;rgb:0000/0000/0000\a")
|
||||
|
||||
assert.NoError(t, test.tty.Stop())
|
||||
assert.Equal(t, disableColorSchemeReports, test.fake.takeWritten())
|
||||
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "nothing is written to a stopped tty")
|
||||
|
||||
// Anything may have changed while we were stopped, so ask again
|
||||
assert.NoError(t, test.tty.Start())
|
||||
assert.Equal(t, enableColorSchemeReports+requestColorScheme+requestBackgroundColor, test.fake.takeWritten())
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyStopWithoutReports(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("WezTerm")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.fake.takeWritten()
|
||||
|
||||
assert.NoError(t, test.tty.Stop())
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "reports were never turned on")
|
||||
}
|
||||
|
||||
func TestColorSchemeOfBackground(t *testing.T) {
|
||||
for background, expected := range map[string]ColorScheme{
|
||||
"rgb:0000/0000/0000": ColorSchemeDark,
|
||||
"rgb:1e/1e/1e": ColorSchemeDark,
|
||||
"rgb:00/2b/36": ColorSchemeDark,
|
||||
"rgb:70/70/70": ColorSchemeDark,
|
||||
"rgb:80/80/80": ColorSchemeLight,
|
||||
"rgb:fd/f6/e3": ColorSchemeLight,
|
||||
"rgb:ff/ff/ff": ColorSchemeLight,
|
||||
} {
|
||||
color, ok := parseBackgroundColorReport("11;" + background)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, expected, colorSchemeOfBackground(color), background)
|
||||
}
|
||||
}
|
||||
|
||||
// startWaiting starts waiting for replies, with a timeout too long to matter
|
||||
func (self *colorSchemeTtyTest) startWaiting() <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
self.tty.waitForReplies(time.Minute)
|
||||
close(done)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func isClosed(ch <-chan struct{}) bool {
|
||||
select {
|
||||
case <-ch:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func assertDoneWaiting(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
assert.Eventually(t, func() bool { return isClosed(done) }, time.Second, time.Millisecond)
|
||||
}
|
||||
|
||||
func assertStillWaiting(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
assert.Never(t, func() bool { return isClosed(done) }, 50*time.Millisecond, time.Millisecond)
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyWaitsForReplies(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.feed("\x1b[?997;1n\x1b]11;rgb:0000/0000/0000\a")
|
||||
assertDoneWaiting(t, test.startWaiting())
|
||||
|
||||
assert.NoError(t, test.tty.Stop())
|
||||
assert.NoError(t, test.tty.Start())
|
||||
|
||||
done := test.startWaiting()
|
||||
assertStillWaiting(t, done)
|
||||
test.feed("\x1b[?997;1n")
|
||||
assertStillWaiting(t, done)
|
||||
test.feed("\x1b]11;rgb:0000/0000/0000\a")
|
||||
assertDoneWaiting(t, done)
|
||||
|
||||
test.tty.onFocusGained()
|
||||
done = test.startWaiting()
|
||||
assertStillWaiting(t, done)
|
||||
test.feed("\x1b]11;rgb:0000/0000/0000\a")
|
||||
assertDoneWaiting(t, done)
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyGivesUpWaiting(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.feed("\x1b[?997;1n\x1b]11;rgb:0000/0000/0000\a")
|
||||
test.tty.onFocusGained()
|
||||
|
||||
start := time.Now()
|
||||
test.tty.waitForReplies(20 * time.Millisecond)
|
||||
assert.GreaterOrEqual(t, time.Since(start), 20*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyDoesntWaitForRepliesThatNeverCame(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
// The terminal answers nothing
|
||||
assert.NoError(t, test.tty.Start())
|
||||
assertDoneWaiting(t, test.startWaiting())
|
||||
|
||||
// The terminal reports its color scheme, but not its background
|
||||
test.feed("\x1b[?997;1n\x1b[?62;22c")
|
||||
assert.NoError(t, test.tty.Stop())
|
||||
assert.NoError(t, test.tty.Start())
|
||||
|
||||
done := test.startWaiting()
|
||||
assertStillWaiting(t, done)
|
||||
test.feed("\x1b[?997;1n")
|
||||
assertDoneWaiting(t, done)
|
||||
}
|
||||
+97
-20
@@ -20,6 +20,25 @@ type escapeInterpreter struct {
|
||||
instruction instruction
|
||||
hyperlink strings.Builder
|
||||
|
||||
// the digits of the OSC number seen so far, while we don't yet know which
|
||||
// OSC this is
|
||||
oscNumber strings.Builder
|
||||
// the payload of an OSC 1717 sequence, in which a diff renderer states
|
||||
// which line of which file it is about to render; accumulated like
|
||||
// hyperlink, and attached to the cells that follow it
|
||||
metadata strings.Builder
|
||||
// whether the payload currently in metadata has reached a cell, so that one
|
||||
// that never does can be recognized and kept as an orphan
|
||||
metadataConsumed bool
|
||||
// OSC 1717 payloads that no cell took, because the next record followed
|
||||
// with nothing rendered in between. A renderer emits records back to back
|
||||
// wherever two diff lines share a rendered line — the deletion and the
|
||||
// addition of a modification collapsed into one column, or a banner
|
||||
// announcing a file and its first hunk at once. The write loop gives these
|
||||
// cells of their own, so that a line keeps every record it was given rather
|
||||
// than only the last.
|
||||
orphanedMetadata []string
|
||||
|
||||
// ConPTY emits cursor-positioning escapes (CUP) to skip over blank
|
||||
// rows rather than emitting LFs for them. To convert those into row
|
||||
// advances the view can act on, we track where in the pseudo-terminal
|
||||
@@ -82,9 +101,9 @@ const (
|
||||
stateParams
|
||||
stateCSIDiscard
|
||||
stateOSC
|
||||
stateOSCWaitForParams
|
||||
stateOSCParams
|
||||
stateOSCHyperlink
|
||||
stateOSCMetadata
|
||||
stateOSCEndEscape
|
||||
stateOSCSkipUnknown
|
||||
|
||||
@@ -427,27 +446,42 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
||||
}
|
||||
return true, nil
|
||||
case stateOSC:
|
||||
if characterEquals(ch, '8') {
|
||||
ei.state = stateOSCWaitForParams
|
||||
ei.hyperlink.Reset()
|
||||
// Accumulate the OSC number until the ';' that terminates it, then
|
||||
// dispatch on the whole number rather than on a single digit.
|
||||
switch {
|
||||
case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9':
|
||||
ei.oscNumber.WriteByte(ch[0])
|
||||
return true, nil
|
||||
case characterEquals(ch, ';'):
|
||||
switch ei.oscNumber.String() {
|
||||
case "8":
|
||||
ei.hyperlink.Reset()
|
||||
ei.state = stateOSCParams
|
||||
case "1717":
|
||||
ei.orphanUnconsumedMetadata()
|
||||
ei.state = stateOSCMetadata
|
||||
default:
|
||||
ei.state = stateOSCSkipUnknown
|
||||
}
|
||||
ei.oscNumber.Reset()
|
||||
return true, nil
|
||||
default:
|
||||
// Not an OSC we understand — it has no number, or a character
|
||||
// follows the number where the ';' should be. Rather than
|
||||
// erroring, which would reset state mid-OSC and leak the rest of
|
||||
// the sequence into the view as literal text, skip to its
|
||||
// terminator, which this character may already be.
|
||||
ei.oscNumber.Reset()
|
||||
switch {
|
||||
case characterEquals(ch, 0x07):
|
||||
ei.state = stateNone
|
||||
case characterEquals(ch, 0x1b):
|
||||
ei.state = stateOSCEndEscape
|
||||
default:
|
||||
ei.state = stateOSCSkipUnknown
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ei.state = stateOSCSkipUnknown
|
||||
return true, nil
|
||||
case stateOSCWaitForParams:
|
||||
if !characterEquals(ch, ';') {
|
||||
// Malformed OSC 8 (expected ';' after '8'). Rather than
|
||||
// erroring — which would reset state mid-OSC and cause the
|
||||
// rest of the sequence to leak as literal text — treat the
|
||||
// whole OSC as one we don't understand and skip to its
|
||||
// terminator.
|
||||
ei.state = stateOSCSkipUnknown
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ei.state = stateOSCParams
|
||||
return true, nil
|
||||
case stateOSCParams:
|
||||
if characterEquals(ch, ';') {
|
||||
ei.state = stateOSCHyperlink
|
||||
@@ -463,6 +497,18 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
||||
ei.hyperlink.Write(ch)
|
||||
}
|
||||
return true, nil
|
||||
case stateOSCMetadata:
|
||||
switch {
|
||||
case characterEquals(ch, 0x07):
|
||||
ei.dropMetadataIfHandshake()
|
||||
ei.state = stateNone
|
||||
case characterEquals(ch, 0x1b):
|
||||
ei.dropMetadataIfHandshake()
|
||||
ei.state = stateOSCEndEscape
|
||||
default:
|
||||
ei.metadata.Write(ch)
|
||||
}
|
||||
return true, nil
|
||||
case stateOSCEndEscape:
|
||||
ei.state = stateNone
|
||||
return true, nil
|
||||
@@ -478,6 +524,37 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// orphanUnconsumedMetadata clears the metadata accumulator for a new OSC 1717
|
||||
// record, keeping the payload it held as an orphan if no cell took it (see
|
||||
// orphanedMetadata).
|
||||
func (ei *escapeInterpreter) orphanUnconsumedMetadata() {
|
||||
if ei.metadata.Len() > 0 && !ei.metadataConsumed {
|
||||
ei.orphanedMetadata = append(ei.orphanedMetadata, ei.metadata.String())
|
||||
}
|
||||
ei.metadata.Reset()
|
||||
ei.metadataConsumed = false
|
||||
}
|
||||
|
||||
// takeOrphanedMetadata hands the accumulated orphaned payloads to the caller and
|
||||
// clears the list.
|
||||
func (ei *escapeInterpreter) takeOrphanedMetadata() []string {
|
||||
result := ei.orphanedMetadata
|
||||
ei.orphanedMetadata = nil
|
||||
return result
|
||||
}
|
||||
|
||||
// dropMetadataIfHandshake discards a just-completed OSC 1717 payload that
|
||||
// carries nothing beyond the version. A diff renderer emits such a record ahead
|
||||
// of everything else to announce that it speaks the protocol, so that a host can
|
||||
// find that out by asking rather than by inspecting a rendering. It says nothing
|
||||
// about any line, so it must not attach to the line that follows it; a per-line
|
||||
// record always has fields, and is kept.
|
||||
func (ei *escapeInterpreter) dropMetadataIfHandshake() {
|
||||
if !strings.Contains(ei.metadata.String(), ";") {
|
||||
ei.metadata.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func (ei *escapeInterpreter) outputCSI() error {
|
||||
n := len(ei.csiParam)
|
||||
for i := 0; i < n; {
|
||||
|
||||
@@ -167,6 +167,7 @@ func TestParseOneIgnoresUnknownSequences(t *testing.T) {
|
||||
"\x1b[0 q", // intermediate byte after a param
|
||||
"\x1b[1;;m", // malformed SGR: empty middle param
|
||||
"\x1b]8bogus\x07", // OSC 8 missing ';'
|
||||
"\x1b]1337;File=inline=1\x07", // OSC with a number we don't implement
|
||||
"\x1b[" + strings.Repeat("0", 300) + "m", // single param overflows length cap
|
||||
"\x1b[" + strings.Repeat("1;", 25) + "1m", // too many params
|
||||
}
|
||||
|
||||
+50
-2
@@ -227,6 +227,12 @@ type Gui struct {
|
||||
// readable from anywhere, so it's atomic.
|
||||
focused atomic.Bool
|
||||
|
||||
// colorSchemeTty is nil when running headless. colorScheme and
|
||||
// colorSchemeHandler are only touched on the UI thread.
|
||||
colorSchemeTty *colorSchemeTty
|
||||
colorScheme DetectedColorScheme
|
||||
colorSchemeHandler func(DetectedColorScheme) error
|
||||
|
||||
// blockInputCount, when greater than zero, withholds keyboard input from
|
||||
// the handlers: key events are buffered into bufferedKeyEvents and replayed
|
||||
// once the count drops back to zero, while mouse clicks and hover are
|
||||
@@ -317,6 +323,18 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
|
||||
// never happened.
|
||||
g.focused.Store(true)
|
||||
|
||||
if g.colorSchemeTty != nil {
|
||||
g.colorScheme = g.colorSchemeTty.subscribe(func(colorScheme DetectedColorScheme) {
|
||||
g.UpdateBackground(func(g *Gui) error {
|
||||
g.colorScheme = colorScheme
|
||||
if g.colorSchemeHandler != nil {
|
||||
return g.colorSchemeHandler(colorScheme)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
@@ -374,6 +392,7 @@ func (g *Gui) WaitUntilIdle() {
|
||||
// initialization and when gocui is not needed anymore.
|
||||
func (g *Gui) Close() {
|
||||
close(g.stop)
|
||||
g.waitForColorSchemeReplies()
|
||||
Screen.Fini()
|
||||
}
|
||||
|
||||
@@ -419,7 +438,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er
|
||||
v.y1 = y1
|
||||
|
||||
if sizeChanged {
|
||||
v.ClearViewLines()
|
||||
v.RewrapContent()
|
||||
|
||||
if v.Editable {
|
||||
cursorX, cursorY := v.TextArea.GetCursorXY()
|
||||
@@ -708,6 +727,29 @@ func (g *Gui) SetFocusHandler(handler func(bool) error) {
|
||||
g.focusHandler = handler
|
||||
}
|
||||
|
||||
// DetectedColorScheme returns what the terminal has told us about its colors.
|
||||
// It is known before the first layout, for the terminals that tell us at all.
|
||||
func (g *Gui) DetectedColorScheme() DetectedColorScheme {
|
||||
return g.colorScheme
|
||||
}
|
||||
|
||||
// SetColorSchemeChangeHandler sets a function to call on the UI thread whenever
|
||||
// the terminal's colors change after startup.
|
||||
func (g *Gui) SetColorSchemeChangeHandler(handler func(DetectedColorScheme) error) {
|
||||
g.colorSchemeHandler = handler
|
||||
}
|
||||
|
||||
// Long enough for the round trip of a slow ssh connection
|
||||
const colorSchemeReplyTimeout = 500 * time.Millisecond
|
||||
|
||||
// waitForColorSchemeReplies is for before we give up the terminal. tcell is
|
||||
// still reading the input at that point, so the answers are consumed as usual.
|
||||
func (g *Gui) waitForColorSchemeReplies() {
|
||||
if g.colorSchemeTty != nil {
|
||||
g.colorSchemeTty.waitForReplies(colorSchemeReplyTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gui) SetOpenHyperlinkFunc(openHyperlinkFunc func(string, string) error) {
|
||||
g.openHyperlink = openHyperlinkFunc
|
||||
}
|
||||
@@ -1534,7 +1576,7 @@ func (g *Gui) flush() error {
|
||||
// if GUI's size has changed, we need to redraw all views
|
||||
if maxX != g.maxX || maxY != g.maxY {
|
||||
for _, v := range g.views {
|
||||
v.ClearViewLines()
|
||||
v.RewrapContent()
|
||||
}
|
||||
}
|
||||
g.maxX, g.maxY = maxX, maxY
|
||||
@@ -2043,6 +2085,10 @@ func (g *Gui) onFocus(ev *GocuiEvent) error {
|
||||
}
|
||||
g.focused.Store(ev.Focused)
|
||||
|
||||
if ev.Focused && g.colorSchemeTty != nil {
|
||||
g.colorSchemeTty.onFocusGained()
|
||||
}
|
||||
|
||||
if g.focusHandler != nil {
|
||||
return g.focusHandler(ev.Focused)
|
||||
}
|
||||
@@ -2059,6 +2105,8 @@ func (g *Gui) onFocus(ev *GocuiEvent) error {
|
||||
// after re-engaging.
|
||||
|
||||
func (g *Gui) Suspend() error {
|
||||
g.waitForColorSchemeReplies()
|
||||
|
||||
g.suspendedMutex.Lock()
|
||||
defer g.suspendedMutex.Unlock()
|
||||
|
||||
|
||||
@@ -56,7 +56,13 @@ var runeReplacements = map[rune]string{
|
||||
func (g *Gui) tcellInit(runeReplacements map[rune]string) error {
|
||||
tcell.SetEncodingFallback(tcell.EncodingFallbackASCII)
|
||||
|
||||
s, e := tcell.NewScreen()
|
||||
tty, e := tcell.NewDevTty()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
colorSchemeTty := newColorSchemeTty(tty)
|
||||
|
||||
s, e := tcell.NewTerminfoScreenFromTty(colorSchemeTty)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
@@ -68,6 +74,7 @@ func (g *Gui) tcellInit(runeReplacements map[rune]string) error {
|
||||
registerRuneFallbacks(s, runeReplacements)
|
||||
|
||||
g.screen = s
|
||||
g.colorSchemeTty = colorSchemeTty
|
||||
Screen = s
|
||||
return nil
|
||||
}
|
||||
|
||||
+441
-29
@@ -5,6 +5,7 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
@@ -138,6 +139,12 @@ type View struct {
|
||||
// focus.
|
||||
InactiveViewSelBgColor Attribute
|
||||
|
||||
// SelTextColor is applied to the text of the selected line when it is
|
||||
// highlighted, whether the view has the focus or not. Its attributes are
|
||||
// added to those of the text, and if it has a color, that replaces the
|
||||
// color of the text.
|
||||
SelTextColor Attribute
|
||||
|
||||
// If Editable is true, keystrokes will be added to the view's internal
|
||||
// buffer at the cursor position.
|
||||
Editable bool
|
||||
@@ -157,6 +164,12 @@ type View struct {
|
||||
// instead of Sel{Bg,Fg}Colors for highlighting selected lines.
|
||||
HighlightInactive bool
|
||||
|
||||
// If SelectedLineColorWidth is greater than zero, a highlighted line is painted
|
||||
// in the selection colors on that many columns at its left edge only, rather
|
||||
// than across its whole width, leaving the line's own colors to show through.
|
||||
// For content that conveys meaning by color of its own.
|
||||
SelectedLineColorWidth int
|
||||
|
||||
// If Frame is true, a border will be drawn around the view.
|
||||
Frame bool
|
||||
|
||||
@@ -245,23 +258,138 @@ type pos struct {
|
||||
x, y int
|
||||
}
|
||||
|
||||
// call this in the event of a view resize, or if you want to render new content
|
||||
// without the chance of old content still appearing, or if you want to remove
|
||||
// a line from the existing content
|
||||
// call this if you want to render new content without the chance of old content
|
||||
// still appearing, or if you want to remove a line from the existing content. For
|
||||
// a view whose size has changed, whose content is the same but has to be wrapped
|
||||
// afresh, call RewrapContent instead.
|
||||
func (v *View) clearViewLines() {
|
||||
v.tainted = true
|
||||
v.viewLines = nil
|
||||
v.clearHover()
|
||||
}
|
||||
|
||||
// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on
|
||||
// the UI thread (the layout pass) that touch a view whose content a task
|
||||
// goroutine may be writing concurrently: viewLines/tainted/hover are all
|
||||
// buffer state that writeMutex protects.
|
||||
func (v *View) ClearViewLines() {
|
||||
// RewrapContent wraps the view's content for the size the view has now, and puts
|
||||
// the positions into that content — the scroll offset, the cursor, a range's
|
||||
// anchor — back on the lines they were on. They are all view lines, which count
|
||||
// the segments each line is wrapped into, so wrapping the content at another
|
||||
// width leaves every one of them pointing at a different line.
|
||||
//
|
||||
// Call it on the UI thread whenever the view's size changes; a task goroutine may
|
||||
// be writing the content concurrently, and all of this is state writeMutex
|
||||
// protects.
|
||||
func (v *View) RewrapContent() {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.refreshViewLinesIfNeeded()
|
||||
origin := v.contentPosOf(v.oy)
|
||||
cursor := v.contentPosOf(v.oy + v.cy)
|
||||
anchor := v.contentPosOf(v.rangeSelectStartY)
|
||||
cursorRow := v.cy
|
||||
|
||||
v.clearViewLines()
|
||||
v.refreshViewLinesIfNeeded()
|
||||
|
||||
if !origin.ok {
|
||||
return
|
||||
}
|
||||
|
||||
cursorLine, cursorOk := v.viewLineOf(cursor)
|
||||
if anchorLine, ok := v.viewLineOf(anchor); ok {
|
||||
v.rangeSelectStartY = anchorLine
|
||||
if cursorOk {
|
||||
// A range covers lines of content, not the wrapped segments those
|
||||
// lines are drawn as, so its ends go back on the outermost segments
|
||||
// of their lines: a line that was covered whole stays covered whole.
|
||||
cursorLine = v.viewLineOfRangeEnd(cursor, anchor)
|
||||
v.rangeSelectStartY = v.viewLineOfRangeEnd(anchor, cursor)
|
||||
}
|
||||
}
|
||||
|
||||
// The line the cursor is on keeps the row it was drawn on, so that it doesn't
|
||||
// move under the user; with no cursor on screen the view keeps its own place
|
||||
// in the content instead.
|
||||
if v.Highlight && cursorOk && cursorRow >= 0 && cursorRow < v.InnerHeight() {
|
||||
v.SetOriginY(cursorLine - cursorRow)
|
||||
} else if originLine, ok := v.viewLineOf(origin); ok {
|
||||
v.SetOriginY(originLine)
|
||||
}
|
||||
if cursorOk {
|
||||
v.cy = cursorLine - v.oy
|
||||
}
|
||||
}
|
||||
|
||||
// contentPos is a position in a view's content in terms that survive the content
|
||||
// being wrapped again: which line of it, and which of that line's segments.
|
||||
type contentPos struct {
|
||||
line, segment int
|
||||
ok bool
|
||||
}
|
||||
|
||||
// contentPosOf returns where the given view line sits in the content. Only call
|
||||
// this with a lock on writeMutex, and with the view lines up to date.
|
||||
func (v *View) contentPosOf(viewLine int) contentPos {
|
||||
if viewLine < 0 || viewLine >= len(v.viewLines) {
|
||||
return contentPos{}
|
||||
}
|
||||
return contentPos{
|
||||
line: v.viewLines[viewLine].linesY,
|
||||
segment: v.viewLines[viewLine].linesX,
|
||||
ok: true,
|
||||
}
|
||||
}
|
||||
|
||||
// viewLineOf returns the view line drawing the given position in the content,
|
||||
// on the nearest segment its line still has. Only call this with a lock on
|
||||
// writeMutex, and with the view lines up to date.
|
||||
func (v *View) viewLineOf(pos contentPos) (int, bool) {
|
||||
first, last, ok := v.segmentSpanOf(pos)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return min(first+pos.segment, last), true
|
||||
}
|
||||
|
||||
// viewLineOfRangeEnd returns the view line for one end of a range selection: the
|
||||
// outermost segment of its line, so that the range covers that line whole. other
|
||||
// is the range's other end, which says which way is outward. Both ends have to be
|
||||
// positions whose lines are drawn, which viewLineOf answers.
|
||||
func (v *View) viewLineOfRangeEnd(pos contentPos, other contentPos) int {
|
||||
first, last, _ := v.segmentSpanOf(pos)
|
||||
if pos.line <= other.line {
|
||||
return first
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// segmentSpanOf returns the first and last view line drawing the given position's
|
||||
// line of the content. ok is false when the position was never taken, or its line
|
||||
// isn't drawn at all.
|
||||
func (v *View) segmentSpanOf(pos contentPos) (int, int, bool) {
|
||||
if !pos.ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return v.viewLineSpanOfBufferLine(pos.line)
|
||||
}
|
||||
|
||||
// viewLineSpanOfBufferLine returns the first and last view line drawing the given
|
||||
// buffer line, i.e. the first and last segment it is wrapped into. Both are the
|
||||
// same view line when the line doesn't wrap. ok is false when the line isn't drawn
|
||||
// at all. Only call this with a lock on writeMutex, and with the view lines up to
|
||||
// date.
|
||||
func (v *View) viewLineSpanOfBufferLine(bufferLine int) (int, int, bool) {
|
||||
first, last := -1, -1
|
||||
for i, vline := range v.viewLines {
|
||||
if vline.linesY == bufferLine {
|
||||
if first == -1 {
|
||||
first = i
|
||||
}
|
||||
last = i
|
||||
} else if first != -1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return first, last, first != -1
|
||||
}
|
||||
|
||||
type searcher struct {
|
||||
@@ -499,10 +627,23 @@ func (v *View) SetRangeSelectStart(rangeSelectStartY int) {
|
||||
v.rangeSelectStartY = rangeSelectStartY
|
||||
}
|
||||
|
||||
// RangeSelectStartY returns the view line the range selection is anchored on,
|
||||
// or -1 when there is no range.
|
||||
func (v *View) RangeSelectStartY() int {
|
||||
return v.rangeSelectStartY
|
||||
}
|
||||
|
||||
func (v *View) CancelRangeSelect() {
|
||||
v.rangeSelectStartY = -1
|
||||
}
|
||||
|
||||
// HasRangeSelect reports whether a range selection is anchored, as opposed to the
|
||||
// view showing a plain cursor. A range whose ends are on the same view line is still
|
||||
// one, which SelectedLineRange alone can't tell you.
|
||||
func (v *View) HasRangeSelect() bool {
|
||||
return v.rangeSelectStartY != -1
|
||||
}
|
||||
|
||||
func calculateNewOrigin(selectedLine int, oldOrigin int, lineCount int, viewHeight int) int {
|
||||
if viewHeight >= lineCount {
|
||||
return 0
|
||||
@@ -572,6 +713,23 @@ type lineType struct {
|
||||
// matches the current width; nil means nothing is cached yet.
|
||||
wrappedCells [][]cell
|
||||
wrappedColumns int
|
||||
|
||||
// asWritten is the text of the line as its writer wrote it, escape sequences
|
||||
// left out, kept from the first character on that the cells spell differently:
|
||||
// a tab, which the cells hold as the spaces it fills, or a carriage return,
|
||||
// which they hold as the overwrite it caused. nil while the cells spell the
|
||||
// line as it was written, as they do for most lines.
|
||||
asWritten []byte
|
||||
}
|
||||
|
||||
// textAsWritten returns the line's text as its writer wrote it, escape sequences
|
||||
// left out. A reader that parses a view's content rather than showing it wants
|
||||
// this form; the cells' text is for showing.
|
||||
func (l *lineType) textAsWritten() string {
|
||||
if l.asWritten != nil {
|
||||
return string(l.asWritten)
|
||||
}
|
||||
return l.cells.String()
|
||||
}
|
||||
|
||||
// trailingFillAttributes describes the fg/bg colors that draw() should
|
||||
@@ -588,6 +746,9 @@ type cell struct {
|
||||
width int // number of terminal cells occupied by chr (always 1 or 2)
|
||||
bgColor, fgColor Attribute
|
||||
hyperlink string
|
||||
// the OSC 1717 payload in effect when the cell was written, i.e. what the
|
||||
// diff renderer said about the diff line this cell is part of
|
||||
metadata string
|
||||
}
|
||||
|
||||
type cells []cell
|
||||
@@ -631,7 +792,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
|
||||
|
||||
v.FgColor, v.BgColor = ColorDefault, ColorDefault
|
||||
v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault
|
||||
v.InactiveViewSelBgColor = ColorDefault
|
||||
v.InactiveViewSelBgColor, v.SelTextColor = ColorDefault, ColorDefault
|
||||
v.TitleColor, v.FrameColor = ColorDefault, ColorDefault
|
||||
v.buf.ei.screenColMax = v.InnerWidth()
|
||||
return v
|
||||
@@ -721,13 +882,9 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
|
||||
rangeSelectEnd = max(relativeRangeSelectStart, v.cy)
|
||||
}
|
||||
|
||||
if y >= rangeSelectStart && y <= rangeSelectEnd {
|
||||
// this ensures we use the bright variant of a colour upon highlight
|
||||
fgColorComponent := fgColor & ^AttrAll
|
||||
if fgColorComponent >= AttrIsValidColor && fgColorComponent < AttrIsValidColor+8 {
|
||||
fgColor += 8
|
||||
}
|
||||
fgColor = fgColor | AttrBold
|
||||
colorWidth := v.SelectedLineColorWidth
|
||||
if y >= rangeSelectStart && y <= rangeSelectEnd && (colorWidth == 0 || x < colorWidth) {
|
||||
fgColor = applySelTextColor(fgColor, v.SelTextColor)
|
||||
if v.HighlightInactive || !isWindowFocused {
|
||||
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
|
||||
} else {
|
||||
@@ -834,8 +991,7 @@ func (v *View) SetWritePos(x, y int) {
|
||||
y = 0
|
||||
}
|
||||
|
||||
v.buf.wx = x
|
||||
v.buf.wy = y
|
||||
v.buf.seekWrite(x, y)
|
||||
|
||||
// Changing the write position makes a pending newline obsolete
|
||||
v.buf.pendingNewline = false
|
||||
@@ -919,6 +1075,35 @@ func (b *viewBuffer) writeCells(cells []cell) {
|
||||
b.wx += len(cells)
|
||||
}
|
||||
|
||||
// seekWrite moves the write cursor to (x, y). Writing there starts the line over,
|
||||
// so whatever it kept of its text as written is dropped; the text a line keeps is
|
||||
// the text written to it from its start. A carriage return continues a line
|
||||
// instead, and moves the cursor without this (see write).
|
||||
func (b *viewBuffer) seekWrite(x, y int) {
|
||||
b.wx = x
|
||||
b.wy = y
|
||||
if y < len(b.lines) {
|
||||
b.lines[y].asWritten = nil
|
||||
}
|
||||
}
|
||||
|
||||
// startAsWritten begins keeping the current line's text as written (see
|
||||
// lineType.asWritten), at the first character the cells won't spell the same way.
|
||||
// Up to here they spell it exactly, so their text is what was written so far.
|
||||
func (b *viewBuffer) startAsWritten() {
|
||||
if line := &b.lines[b.wy]; line.asWritten == nil {
|
||||
line.asWritten = append([]byte{}, line.cells.String()...)
|
||||
}
|
||||
}
|
||||
|
||||
// noteAsWritten records text the writer wrote to the current line, once the line
|
||||
// keeps its text as written at all.
|
||||
func (b *viewBuffer) noteAsWritten(text []byte) {
|
||||
if line := &b.lines[b.wy]; line.asWritten != nil {
|
||||
line.asWritten = append(line.asWritten, text...)
|
||||
}
|
||||
}
|
||||
|
||||
// Write appends a byte slice into the view's internal buffer. Because
|
||||
// View implements the io.Writer interface, it can be passed as parameter
|
||||
// of functions like fmt.Fprintf, fmt.Fprintln, io.Copy, etc. Clear must
|
||||
@@ -962,14 +1147,30 @@ func (b *viewBuffer) write(v *View, p []byte) {
|
||||
|
||||
finishLine := func() {
|
||||
b.autoRenderHyperlinksInCurrentLine(v)
|
||||
// A record that reached the line's end without covering a cell still
|
||||
// belongs to the line: an orphan (see escapeInterpreter.orphanedMetadata),
|
||||
// or the record of a changed line that is empty, which a renderer emits
|
||||
// with nothing but the newline after it. Give each a cell of its own, so
|
||||
// that the line is still recognizable as the diff line it renders rather
|
||||
// than as nothing at all.
|
||||
for _, payload := range b.ei.takeOrphanedMetadata() {
|
||||
b.writeCells([]cell{{metadata: payload}})
|
||||
}
|
||||
if b.ei.metadata.Len() > 0 && !b.ei.metadataConsumed {
|
||||
b.writeCells([]cell{{metadata: b.ei.metadata.String()}})
|
||||
b.ei.metadataConsumed = true
|
||||
}
|
||||
}
|
||||
|
||||
advanceToNextLine := func() {
|
||||
b.wx = 0
|
||||
b.wy++
|
||||
b.seekWrite(0, b.wy+1)
|
||||
if b.wy >= len(b.lines) {
|
||||
b.lines = append(b.lines, lineType{})
|
||||
}
|
||||
// An OSC 1717 record describes the line it precedes and is never
|
||||
// closed, so it stops applying at the line's end; a renderer emits a
|
||||
// fresh one for each line it has something to say about.
|
||||
b.ei.metadata.Reset()
|
||||
}
|
||||
|
||||
if b.pendingNewline {
|
||||
@@ -999,6 +1200,10 @@ func (b *viewBuffer) write(v *View, p []byte) {
|
||||
b.ei.notifyRowAdvance()
|
||||
case characterEquals(chr, '\r'):
|
||||
finishLine()
|
||||
// The cells will hold what follows as an overwrite of what came
|
||||
// before; the text as written keeps the return itself.
|
||||
b.startAsWritten()
|
||||
b.noteAsWritten(chr)
|
||||
b.wx = 0
|
||||
b.ei.notifyColumnReset()
|
||||
default:
|
||||
@@ -1114,8 +1319,18 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
truncateLine := false
|
||||
|
||||
isEscape, err := b.ei.parseOne(ch)
|
||||
|
||||
// A record that the next one superseded before any cell took it still
|
||||
// belongs to this line (see escapeInterpreter.orphanedMetadata); give each
|
||||
// a cell of its own, in the order they were emitted, ahead of whatever this
|
||||
// character produces.
|
||||
for _, payload := range b.ei.takeOrphanedMetadata() {
|
||||
cells = append(cells, cell{metadata: payload})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
for _, chr := range b.ei.characters() {
|
||||
characters := b.ei.characters()
|
||||
for _, chr := range characters {
|
||||
c := cell{
|
||||
fgColor: v.FgColor,
|
||||
bgColor: v.BgColor,
|
||||
@@ -1124,6 +1339,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
}
|
||||
cells = append(cells, c)
|
||||
}
|
||||
b.noteAsWritten([]byte(strings.Join(characters, "")))
|
||||
b.ei.reset()
|
||||
} else {
|
||||
repeatCount := 1
|
||||
@@ -1140,7 +1356,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
fg: b.ei.curFgColor,
|
||||
bg: b.ei.curBgColor,
|
||||
}
|
||||
return truncateLine, []cell{}
|
||||
return truncateLine, cells
|
||||
} else if cf, ok := b.ei.instruction.(cursorForward); ok {
|
||||
// emit `n` space cells under the parser-tracked SGR — used
|
||||
// to materialize ConPTY's compressed runs of spaces (which
|
||||
@@ -1149,10 +1365,19 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
repeatCount = cf.n
|
||||
ch = []byte{' '}
|
||||
width = 1
|
||||
b.noteAsWritten(bytes.Repeat(ch, repeatCount))
|
||||
} else if isEscape {
|
||||
// do not output anything
|
||||
return truncateLine, nil
|
||||
// the escape itself outputs nothing, but any cells carrying an
|
||||
// orphaned record still need writing
|
||||
if len(cells) == 0 {
|
||||
return truncateLine, nil
|
||||
}
|
||||
return truncateLine, cells
|
||||
} else if characterEquals(ch, '\t') {
|
||||
// The cells hold a tab as the spaces it fills; the text as written
|
||||
// keeps the tab itself.
|
||||
b.startAsWritten()
|
||||
b.noteAsWritten(ch)
|
||||
// fill tab-sized space
|
||||
tabWidth := v.TabWidth
|
||||
if tabWidth < 1 {
|
||||
@@ -1161,14 +1386,20 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
ch = []byte{' '}
|
||||
width = 1
|
||||
repeatCount = tabWidth - (x % tabWidth)
|
||||
} else {
|
||||
b.noteAsWritten(ch)
|
||||
}
|
||||
c := cell{
|
||||
fgColor: b.ei.curFgColor,
|
||||
bgColor: b.ei.curBgColor,
|
||||
hyperlink: b.ei.hyperlink.String(),
|
||||
metadata: b.ei.metadata.String(),
|
||||
chr: string(ch),
|
||||
width: width,
|
||||
}
|
||||
if c.metadata != "" {
|
||||
b.ei.metadataConsumed = true
|
||||
}
|
||||
for range repeatCount {
|
||||
cells = append(cells, c)
|
||||
}
|
||||
@@ -1744,6 +1975,139 @@ func (v *View) BufferLines() []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
// DiffLineContent holds what one line of a rendered diff offers to a reader trying
|
||||
// to recover which line of which file it came from: the line's text, which can be
|
||||
// parsed as a unified diff when the rendering preserves one, and the OSC 1717
|
||||
// records a diff renderer attached to it, which state the answer outright.
|
||||
type DiffLineContent struct {
|
||||
// The line's text as its writer wrote it, escape sequences left out, where
|
||||
// BufferLines gives the text as the cells spell it. The two differ where the
|
||||
// cells can't spell what was written: a tab, which they hold as the spaces it
|
||||
// fills, and a carriage return, which they hold as the overwrite it caused.
|
||||
// git terminates a path containing a space with a tab in a diff header, and a
|
||||
// parser of the diff has to see the tab.
|
||||
Text string
|
||||
// The distinct OSC 1717 payloads carried by the line's cells, in
|
||||
// left-to-right order. A single-column rendering tags every cell of a line
|
||||
// with the same payload, so there is one; a side-by-side rendering tags
|
||||
// each side separately, so a line showing a deletion beside the addition
|
||||
// that replaces it carries both.
|
||||
Metadata []string
|
||||
}
|
||||
|
||||
// DiffLineContents returns the per-line material a diff-line reader works from
|
||||
// (see DiffLineContent), indexed by unwrapped buffer line. Text and records are
|
||||
// snapshotted in a single locked pass, so they stay consistent with each other
|
||||
// and with the buffer they came from even while a re-render rebuilds it.
|
||||
func (v *View) DiffLineContents() []DiffLineContent {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
return diffLineContentsFrom(v.buf, 0)
|
||||
}
|
||||
|
||||
// OffscreenDiffLineContents is DiffLineContents for the content of a re-render in
|
||||
// progress (see BeginOffscreenRender). A reader deciding where the new content
|
||||
// should be shown has to work from this: it has to answer before the swap, since
|
||||
// after the swap the content is already on screen. Returns nil when no re-render
|
||||
// is underway.
|
||||
func (v *View) OffscreenDiffLineContents() []DiffLineContent {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if v.offscreen == nil {
|
||||
return nil
|
||||
}
|
||||
return diffLineContentsFrom(v.offscreen, 0)
|
||||
}
|
||||
|
||||
// OffscreenDiffLineContentsFrom is OffscreenDiffLineContents restricted to the lines
|
||||
// from index `from` on (so result[0] is buffer line `from`). It lets a reader that
|
||||
// follows a re-render as it loads look at each line once, rather than snapshotting
|
||||
// the whole buffer again on every line — the difference between an O(n) and an O(n²)
|
||||
// scan of a large diff. Returns nil when no re-render is underway, or when `from` is
|
||||
// past the lines read so far.
|
||||
func (v *View) OffscreenDiffLineContentsFrom(from int) []DiffLineContent {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if v.offscreen == nil || from < 0 || from >= len(v.offscreen.lines) {
|
||||
return nil
|
||||
}
|
||||
return diffLineContentsFrom(v.offscreen, from)
|
||||
}
|
||||
|
||||
// OffscreenLineCount returns the number of unwrapped lines a re-render in progress
|
||||
// has read so far, or 0 when none is underway. It tells a reader waiting for a
|
||||
// particular line, cheaply, when a screenful below it has arrived too — so that the
|
||||
// swap shows that line with content under it rather than at the bottom edge of a
|
||||
// half-filled view.
|
||||
func (v *View) OffscreenLineCount() int {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if v.offscreen == nil {
|
||||
return 0
|
||||
}
|
||||
return len(v.offscreen.lines)
|
||||
}
|
||||
|
||||
func diffLineContentsFrom(buf *viewBuffer, from int) []DiffLineContent {
|
||||
lines := buf.lines[from:]
|
||||
contents := make([]DiffLineContent, len(lines))
|
||||
for i := range lines {
|
||||
line := &lines[i]
|
||||
var metadata []string
|
||||
for _, c := range line.cells {
|
||||
if c.metadata != "" && !slices.Contains(metadata, c.metadata) {
|
||||
metadata = append(metadata, c.metadata)
|
||||
}
|
||||
}
|
||||
contents[i] = DiffLineContent{Text: line.textAsWritten(), Metadata: metadata}
|
||||
}
|
||||
return contents
|
||||
}
|
||||
|
||||
// BufferLineForViewLine maps a view line index (which counts wrapped lines) to
|
||||
// the index of the corresponding line in the unwrapped internal buffer (as
|
||||
// returned by BufferLines). Several view lines map to the same buffer line when
|
||||
// that line wraps. Returns false if the view line is out of range.
|
||||
func (v *View) BufferLineForViewLine(y int) (int, bool) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
return v.bufferLineForViewLine(y)
|
||||
}
|
||||
|
||||
// ViewLineForBufferLine maps an unwrapped buffer line index to the index of the
|
||||
// first view line that renders it — the inverse of BufferLineForViewLine, for
|
||||
// turning a line found by examining the buffer into a line to scroll to or
|
||||
// select. Returns false if the buffer line isn't rendered into any view line.
|
||||
func (v *View) ViewLineForBufferLine(bufferLineIdx int) (int, bool) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.refreshViewLinesIfNeeded()
|
||||
|
||||
first, _, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
|
||||
return first, ok
|
||||
}
|
||||
|
||||
// LastViewLineForBufferLine maps an unwrapped buffer line index to the index of
|
||||
// the last view line that renders it, which for a line that doesn't wrap is the
|
||||
// same as the first. It is where the far end of a range goes: a range is over
|
||||
// buffer lines, so it has to cover the last one of them to its final segment
|
||||
// rather than stopping where that line begins.
|
||||
func (v *View) LastViewLineForBufferLine(bufferLineIdx int) (int, bool) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.refreshViewLinesIfNeeded()
|
||||
|
||||
_, last, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
|
||||
return last, ok
|
||||
}
|
||||
|
||||
// Buffer returns a string with the contents of the view's internal
|
||||
// buffer.
|
||||
func (v *View) Buffer() string {
|
||||
@@ -1770,6 +2134,9 @@ func (v *View) ViewBufferLines() []string {
|
||||
|
||||
// LinesHeight is the count of view lines (i.e. lines excluding wrapping)
|
||||
func (v *View) LinesHeight() int {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
return len(v.buf.lines)
|
||||
}
|
||||
|
||||
@@ -1843,6 +2210,15 @@ func indexFunc(r rune) bool {
|
||||
return r == ' ' || r == 0
|
||||
}
|
||||
|
||||
// applySelTextColor adds the attributes of selTextColor to fgColor, and
|
||||
// replaces the color of fgColor with that of selTextColor if it has one.
|
||||
func applySelTextColor(fgColor, selTextColor Attribute) Attribute {
|
||||
if selTextColor&AttrColorBits != ColorDefault {
|
||||
fgColor = fgColor&AttrStyleBits | selTextColor&AttrColorBits
|
||||
}
|
||||
return fgColor | selTextColor&AttrStyleBits
|
||||
}
|
||||
|
||||
// SetHighlight toggles highlighting of separate lines, for custom lists
|
||||
// or multiple selection in views.
|
||||
func (v *View) SetHighlight(y int, on bool) {
|
||||
@@ -1970,16 +2346,31 @@ func (v *View) SelectedLineIdx() int {
|
||||
return seletedLineIdx
|
||||
}
|
||||
|
||||
// IsLineVisible reports whether the given view line is one of those on screen.
|
||||
func (v *View) IsLineVisible(viewLine int) bool {
|
||||
return viewLine >= v.OriginY() && viewLine < v.OriginY()+v.InnerHeight()
|
||||
}
|
||||
|
||||
// MiddleVisibleLineIdx returns the view line halfway down the visible content. It
|
||||
// stands in for a cursor in a view that has none: of the lines on screen, the one in
|
||||
// the middle is the likeliest to be the one being read.
|
||||
func (v *View) MiddleVisibleLineIdx() int {
|
||||
top := v.OriginY()
|
||||
bottom := min(top+v.InnerHeight(), v.ViewLinesHeight())
|
||||
return (top + bottom) / 2
|
||||
}
|
||||
|
||||
// expected to only be used in tests
|
||||
func (v *View) SelectedLine() string {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if len(v.buf.lines) == 0 {
|
||||
idx, ok := v.bufferLineForViewLine(v.SelectedLineIdx())
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return v.lineContentAtIdx(v.SelectedLineIdx())
|
||||
return v.lineContentAtIdx(idx)
|
||||
}
|
||||
|
||||
// expected to only be used in tests
|
||||
@@ -1994,8 +2385,17 @@ func (v *View) SelectedLines() []string {
|
||||
startIdx, endIdx := v.SelectedLineRange()
|
||||
|
||||
lines := make([]string, 0, endIdx-startIdx+1)
|
||||
previous := -1
|
||||
for i := startIdx; i <= endIdx; i++ {
|
||||
lines = append(lines, v.lineContentAtIdx(i))
|
||||
// The selection is in view lines, which count the segments a wrapped line
|
||||
// is drawn as; a line the selection covers several segments of is still
|
||||
// the one line it is.
|
||||
idx, ok := v.bufferLineForViewLine(i)
|
||||
if !ok || idx == previous {
|
||||
continue
|
||||
}
|
||||
previous = idx
|
||||
lines = append(lines, v.lineContentAtIdx(idx))
|
||||
}
|
||||
|
||||
return lines
|
||||
@@ -2005,6 +2405,19 @@ func (v *View) lineContentAtIdx(idx int) string {
|
||||
return v.buf.lines[idx].cells.String()
|
||||
}
|
||||
|
||||
// bufferLineForViewLine maps a view line index, which counts the wrapped
|
||||
// segments of the lines it draws, to the index of the line of content it is a
|
||||
// segment of. Only call this with a lock on writeMutex.
|
||||
func (v *View) bufferLineForViewLine(y int) (int, bool) {
|
||||
v.refreshViewLinesIfNeeded()
|
||||
|
||||
if y < 0 || y >= len(v.viewLines) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return v.viewLines[y].linesY, true
|
||||
}
|
||||
|
||||
func (v *View) SelectedPoint() (int, int) {
|
||||
cx, cy := v.Cursor()
|
||||
ox, oy := v.Origin()
|
||||
@@ -2075,8 +2488,7 @@ func (v *View) ClearTextArea() {
|
||||
|
||||
func (v *View) overwriteLines(y int, content string) {
|
||||
// break by newline, then for each line, write it, then add that erase command
|
||||
v.buf.wx = 0
|
||||
v.buf.wy = y
|
||||
v.SetWritePos(0, y)
|
||||
v.clearViewLines()
|
||||
|
||||
lines := strings.ReplaceAll(content, "\n", "\x1b[K\n")
|
||||
|
||||
@@ -113,6 +113,99 @@ func TestWriteString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverwriteLinesAfterContentEndingInANewline(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 20, 10, OutputNormal)
|
||||
// The trailing newline is held back until more content arrives, so that the
|
||||
// view doesn't end in an empty line.
|
||||
v.writeString("a\nb\n")
|
||||
|
||||
v.OverwriteLines(0, "x")
|
||||
|
||||
assert.Equal(t, []string{"x", "b"}, v.BufferLines())
|
||||
}
|
||||
|
||||
// diffLineTexts returns the Text of each of the given contents.
|
||||
func diffLineTexts(contents []DiffLineContent) []string {
|
||||
return lo.Map(contents, func(c DiffLineContent, _ int) string { return c.Text })
|
||||
}
|
||||
|
||||
func TestDiffLineContentsTextIsTheTextAsWritten(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stringsToWrite []string
|
||||
expectedShown []string
|
||||
expectedAsWritten []string
|
||||
}{
|
||||
{
|
||||
name: "a line the cells spell as written",
|
||||
stringsToWrite: []string{"abc\n"},
|
||||
expectedShown: []string{"abc"},
|
||||
expectedAsWritten: []string{"abc"},
|
||||
},
|
||||
{
|
||||
name: "a tab is kept rather than the spaces it fills",
|
||||
stringsToWrite: []string{"a\tb\n"},
|
||||
expectedShown: []string{"a b"},
|
||||
expectedAsWritten: []string{"a\tb"},
|
||||
},
|
||||
{
|
||||
name: "a carriage return is kept rather than the overwrite it causes",
|
||||
stringsToWrite: []string{"abc\rde\n"},
|
||||
expectedShown: []string{"dec"},
|
||||
expectedAsWritten: []string{"abc\rde"},
|
||||
},
|
||||
{
|
||||
// git writes a CRLF file's lines as "+foo\r", the color reset, "\n".
|
||||
name: "escape sequences are left out",
|
||||
stringsToWrite: []string{"\x1b[32m+foo\r\x1b[m\n"},
|
||||
expectedShown: []string{"+foo"},
|
||||
expectedAsWritten: []string{"+foo\r"},
|
||||
},
|
||||
{
|
||||
// ConPTY writes a run of spaces as a cursor-forward escape.
|
||||
name: "a cursor-forward escape stands for the spaces it skips",
|
||||
stringsToWrite: []string{"\ta\x1b[2Cb\n"},
|
||||
expectedShown: []string{" a b"},
|
||||
expectedAsWritten: []string{"\ta b"},
|
||||
},
|
||||
{
|
||||
name: "a line written in two parts",
|
||||
stringsToWrite: []string{"a\t", "b\n"},
|
||||
expectedShown: []string{"a b"},
|
||||
expectedAsWritten: []string{"a\tb"},
|
||||
},
|
||||
{
|
||||
name: "only the lines with a tab or a return are kept separately",
|
||||
stringsToWrite: []string{"x\n", "y\tz\n", "w\n"},
|
||||
expectedShown: []string{"x", "y z", "w"},
|
||||
expectedAsWritten: []string{"x", "y\tz", "w"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 20, 10, OutputNormal)
|
||||
for _, s := range test.stringsToWrite {
|
||||
v.writeString(s)
|
||||
}
|
||||
assert.Equal(t, test.expectedShown, v.BufferLines())
|
||||
assert.Equal(t, test.expectedAsWritten, diffLineTexts(v.DiffLineContents()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffLineContentsTextOfAnOverwrittenLine(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 20, 10, OutputNormal)
|
||||
v.writeString("a\tb\nc\td")
|
||||
|
||||
// Overwriting a line starts it over: what it kept of its earlier text goes,
|
||||
// and the line below is left alone.
|
||||
v.OverwriteLines(0, "xy")
|
||||
|
||||
assert.Equal(t, []string{"xy", "c d"}, v.BufferLines())
|
||||
assert.Equal(t, []string{"xy", "c\td"}, diffLineTexts(v.DiffLineContents()))
|
||||
}
|
||||
|
||||
func TestUpdatedCursorAndOrigin(t *testing.T) {
|
||||
tests := []struct {
|
||||
prevOrigin int
|
||||
@@ -158,6 +251,107 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
|
||||
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
|
||||
}
|
||||
|
||||
// osc1717 wraps an OSC 1717 payload in the sequence a diff renderer emits it in:
|
||||
// the ESC ] introducer with the OSC number, and ESC \ as the terminator.
|
||||
func osc1717(payload string) string {
|
||||
return "\x1b]1717;" + payload + "\x1b\\"
|
||||
}
|
||||
|
||||
func TestDiffLineContents(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// A diff renderer prefixes each line it renders with a record naming the
|
||||
// file and the line's position in it: version;type;new-line;old-line;file.
|
||||
v.writeString(strings.Join([]string{
|
||||
osc1717("1;c;1;;foo.txt") + "line1",
|
||||
osc1717("1;d;2;2;foo.txt") + "old2",
|
||||
osc1717("1;a;2;;foo.txt") + "new2",
|
||||
"@@ a hunk header, which carries no record @@",
|
||||
}, "\n"))
|
||||
|
||||
assert.Equal(t, []DiffLineContent{
|
||||
{Text: "line1", Metadata: []string{"1;c;1;;foo.txt"}},
|
||||
{Text: "old2", Metadata: []string{"1;d;2;2;foo.txt"}},
|
||||
{Text: "new2", Metadata: []string{"1;a;2;;foo.txt"}},
|
||||
// The record of the line before doesn't bleed onto this one.
|
||||
{Text: "@@ a hunk header, which carries no record @@"},
|
||||
}, v.DiffLineContents())
|
||||
}
|
||||
|
||||
func TestDiffLineContentsWithSideBySideRecords(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// A side-by-side renderer puts two diff lines on one rendered line, and so
|
||||
// emits a record before each half.
|
||||
v.writeString(strings.Join([]string{
|
||||
osc1717("1;c;1;;foo.txt") + "context " + osc1717("1;c;1;;foo.txt") + "context",
|
||||
osc1717("1;d;2;2;foo.txt") + "old2 " + osc1717("1;a;2;;foo.txt") + "new2",
|
||||
}, "\n"))
|
||||
|
||||
assert.Equal(t, []DiffLineContent{
|
||||
// The two halves of a context line are the same diff line, stated twice.
|
||||
{Text: "context context", Metadata: []string{"1;c;1;;foo.txt"}},
|
||||
{Text: "old2 new2", Metadata: []string{"1;d;2;2;foo.txt", "1;a;2;;foo.txt"}},
|
||||
}, v.DiffLineContents())
|
||||
}
|
||||
|
||||
func TestDiffLineContentsOfWrappedLine(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// A line that gocui wraps is still one buffer line, so its record covers
|
||||
// every view line it is displayed on.
|
||||
v.writeString(osc1717("1;a;1;;foo.txt") + "a line too long to fit")
|
||||
|
||||
assert.Equal(t, []DiffLineContent{
|
||||
{Text: "a line too long to fit", Metadata: []string{"1;a;1;;foo.txt"}},
|
||||
}, v.DiffLineContents())
|
||||
assert.Equal(t, 3, v.ViewLinesHeight())
|
||||
for viewLine := range 3 {
|
||||
bufferLine, ok := v.BufferLineForViewLine(viewLine)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 0, bufferLine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffLineContentsWithRecordsCoveringNoCell(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
v.writeString(strings.Join([]string{
|
||||
// A banner announcing a file and its first hunk at once carries both
|
||||
// records back to back.
|
||||
osc1717("1;f;;;foo.txt") + osc1717("1;h;5;;foo.txt") + "foo.txt --- Go",
|
||||
// So does a modification whose deletion and addition are collapsed into
|
||||
// a single rendered line.
|
||||
osc1717("1;d;5;5;foo.txt") + osc1717("1;a;5;;foo.txt") + "595 new content",
|
||||
// A changed line that is empty is rendered as its record and nothing else.
|
||||
osc1717("1;a;6;;foo.txt"),
|
||||
}, "\n") + "\n")
|
||||
|
||||
assert.Equal(t, []DiffLineContent{
|
||||
{Text: "foo.txt --- Go", Metadata: []string{"1;f;;;foo.txt", "1;h;5;;foo.txt"}},
|
||||
{Text: "595 new content", Metadata: []string{"1;d;5;5;foo.txt", "1;a;5;;foo.txt"}},
|
||||
{Text: "", Metadata: []string{"1;a;6;;foo.txt"}},
|
||||
}, v.DiffLineContents())
|
||||
}
|
||||
|
||||
func TestDiffLineContentsSwallowsHandshake(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// A diff renderer announces itself with a version-only record before the
|
||||
// diff. It must leave no trace: no visible bytes, no line of its own, and
|
||||
// above all no record on the line that follows it.
|
||||
v.writeString(osc1717("1") + strings.Join([]string{
|
||||
"diff --git a/foo.txt b/foo.txt",
|
||||
osc1717("1;a;1;;foo.txt") + "added",
|
||||
}, "\n"))
|
||||
|
||||
assert.Equal(t, []DiffLineContent{
|
||||
{Text: "diff --git a/foo.txt b/foo.txt"},
|
||||
{Text: "added", Metadata: []string{"1;a;1;;foo.txt"}},
|
||||
}, v.DiffLineContents())
|
||||
}
|
||||
|
||||
// An async re-render builds into an off-screen buffer and swaps it in once it
|
||||
// has enough to paint, so readers keep seeing the previous render — coherent and
|
||||
// consistent — until the new content appears in one step. See View.offscreen.
|
||||
@@ -204,6 +398,61 @@ func TestViewLinesTruncatedByShorterRender(t *testing.T) {
|
||||
assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines())
|
||||
}
|
||||
|
||||
func TestBufferLineForViewLine(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// Buffer line 0 is short (view line 0); buffer line 1 wraps into three view
|
||||
// lines (1, 2, 3); buffer line 2 is short again (view line 4).
|
||||
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
|
||||
|
||||
for viewLine, wantBufferLine := range []int{0, 1, 1, 1, 2} {
|
||||
bufferLine, ok := v.BufferLineForViewLine(viewLine)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, wantBufferLine, bufferLine)
|
||||
}
|
||||
|
||||
_, ok := v.BufferLineForViewLine(5)
|
||||
assert.False(t, ok)
|
||||
|
||||
_, ok = v.BufferLineForViewLine(-1)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestViewLineForBufferLine(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// A wrapped buffer line maps to the first of the view lines it spans.
|
||||
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
|
||||
|
||||
for bufferLine, wantViewLine := range []int{0, 1, 4} {
|
||||
viewLine, ok := v.ViewLineForBufferLine(bufferLine)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, wantViewLine, viewLine)
|
||||
}
|
||||
|
||||
_, ok := v.ViewLineForBufferLine(3)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestLastViewLineForBufferLine(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// A wrapped buffer line maps to the last of the view lines it spans.
|
||||
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
|
||||
|
||||
for bufferLine, wantViewLine := range []int{0, 3, 4} {
|
||||
viewLine, ok := v.LastViewLineForBufferLine(bufferLine)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, wantViewLine, viewLine)
|
||||
}
|
||||
|
||||
_, ok := v.LastViewLineForBufferLine(3)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// While an async re-render loads, it swaps in only a partially-filled buffer at
|
||||
// its first paint and keeps appending lines afterwards. The scrollbar must keep
|
||||
// using the pre-load height until the load ends, so the thumb doesn't shrink and
|
||||
@@ -780,3 +1029,93 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
|
||||
"trailing cell at (%d, 2) should have green bg", x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySelTextColor(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
fgColor Attribute
|
||||
selTextColor Attribute
|
||||
expected Attribute
|
||||
}{
|
||||
{
|
||||
name: "adds attributes",
|
||||
fgColor: ColorRed | AttrUnderline,
|
||||
selTextColor: AttrBold,
|
||||
expected: ColorRed | AttrUnderline | AttrBold,
|
||||
},
|
||||
{
|
||||
name: "replaces the color",
|
||||
fgColor: ColorRed | AttrUnderline,
|
||||
selTextColor: ColorWhite | AttrBold,
|
||||
expected: ColorWhite | AttrUnderline | AttrBold,
|
||||
},
|
||||
{
|
||||
name: "replaces an RGB color",
|
||||
fgColor: NewRGBColor(0x12, 0x34, 0x56),
|
||||
selTextColor: ColorBlue,
|
||||
expected: ColorBlue,
|
||||
},
|
||||
{
|
||||
name: "leaves the text alone if default",
|
||||
fgColor: ColorRed | AttrUnderline,
|
||||
selTextColor: ColorDefault,
|
||||
expected: ColorRed | AttrUnderline,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, applySelTextColor(s.fgColor, s.selTextColor))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A view that wraps draws one line of its content as several view lines, and the
|
||||
// cursor and the range anchor count those. What is asked about a selection is
|
||||
// which lines of the content it covers, so those are what it has to be reported
|
||||
// in.
|
||||
func TestSelectedLinesOfWrappedContent(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10
|
||||
v.Wrap = true
|
||||
v.Highlight = true
|
||||
|
||||
// "a line that wraps" takes two view lines, so the four lines of content are
|
||||
// drawn as five: "one", "two", "a line th", "at wraps", "four".
|
||||
v.writeString("one\ntwo\na line that wraps\nfour\n")
|
||||
assert.Equal(t, 5, v.ViewLinesHeight())
|
||||
|
||||
// The cursor on the wrapped line's second half is on that line.
|
||||
v.FocusPoint(0, 3, false)
|
||||
assert.Equal(t, "a line that wraps", v.SelectedLine())
|
||||
|
||||
// A range over both halves of the wrapped line covers one line of content.
|
||||
v.SetRangeSelectStart(2)
|
||||
assert.Equal(t, []string{"a line that wraps"}, v.SelectedLines())
|
||||
}
|
||||
|
||||
// Resizing a view throws away the wrapping of its content and wraps it again for
|
||||
// the new width, which moves every line of it to a different view line. The
|
||||
// positions into the view count view lines, so they all have to come along.
|
||||
func TestResizingAWrappingViewKeepsItsPlaceInTheContent(t *testing.T) {
|
||||
g := &Gui{}
|
||||
v, _ := g.SetView("name", 0, 0, 11, 10, 0) // InnerWidth 10
|
||||
v.Wrap = true
|
||||
v.Highlight = true
|
||||
|
||||
// Two wrapping lines, with a single line between them: eight view lines for
|
||||
// five lines of content.
|
||||
v.writeString("one\na line that wraps\ntwo\nanother wrapping line\nthree\n")
|
||||
assert.Equal(t, 8, v.ViewLinesHeight())
|
||||
|
||||
// A range over the whole of the second wrapping line, which is drawn as view
|
||||
// lines 4 to 6.
|
||||
v.SetRangeSelectStart(4)
|
||||
v.FocusPoint(0, 6, false)
|
||||
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
|
||||
|
||||
// Widen the view so that nothing wraps any more.
|
||||
_, _ = g.SetView("name", 0, 0, 31, 10, 0) // InnerWidth 30
|
||||
assert.Equal(t, 5, v.ViewLinesHeight())
|
||||
|
||||
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
|
||||
}
|
||||
|
||||
+14
-3
@@ -196,12 +196,12 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
|
||||
|
||||
self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == ""
|
||||
|
||||
self.updateSelectionHighlights()
|
||||
self.UpdateSelectionHighlights()
|
||||
|
||||
c.HandleFocus(opts)
|
||||
}
|
||||
|
||||
// updateSelectionHighlights re-derives which views draw a selection, and which of
|
||||
// UpdateSelectionHighlights re-derives which views draw a selection, and which of
|
||||
// them draw theirs as the active one: a view shows a selection while its context is
|
||||
// on the stack and has something to select, and the context the user is in shows the
|
||||
// active selection while the ones behind it show inactive ones.
|
||||
@@ -210,7 +210,7 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
|
||||
// every change to the stack goes through; after a refresh, since that is when the
|
||||
// contents of a list change; and from whoever tells a context that its content has
|
||||
// gained or lost something to select.
|
||||
func (self *ContextMgr) updateSelectionHighlights() {
|
||||
func (self *ContextMgr) UpdateSelectionHighlights() {
|
||||
self.RLock()
|
||||
defer self.RUnlock()
|
||||
|
||||
@@ -399,3 +399,14 @@ func (self *ContextMgr) NextInStack(c types.Context) types.Context {
|
||||
|
||||
panic("context not in stack")
|
||||
}
|
||||
|
||||
// IsInStack reports whether the given context is on the stack at all, for callers
|
||||
// that can't otherwise know and would make NextInStack panic.
|
||||
func (self *ContextMgr) IsInStack(c types.Context) bool {
|
||||
self.RLock()
|
||||
defer self.RUnlock()
|
||||
|
||||
return lo.ContainsBy(self.ContextStack, func(other types.Context) bool {
|
||||
return other.GetKey() == c.GetKey()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,15 +13,14 @@ type BaseContext struct {
|
||||
windowName string
|
||||
onGetOptionsMap func() map[string]string
|
||||
|
||||
keybindingsFns []types.KeybindingsFn
|
||||
mouseKeybindingsFns []types.MouseKeybindingsFn
|
||||
onDoubleClickFn func() error
|
||||
onClickFn func(opts gocui.ViewMouseBindingOpts) error
|
||||
onClickFocusedMainViewFn onClickFocusedMainViewFn
|
||||
onRenderToMainFn func()
|
||||
onFocusFns []onFocusFn
|
||||
onFocusLostFns []onFocusLostFn
|
||||
onQuitFns []func()
|
||||
keybindingsFns []types.KeybindingsFn
|
||||
mouseKeybindingsFns []types.MouseKeybindingsFn
|
||||
onDoubleClickFn func() error
|
||||
onClickFn func(opts gocui.ViewMouseBindingOpts) error
|
||||
onRenderToMainFn func()
|
||||
onFocusFns []onFocusFn
|
||||
onFocusLostFns []onFocusLostFn
|
||||
onQuitFns []func()
|
||||
|
||||
focusable bool
|
||||
transient bool
|
||||
@@ -34,9 +33,8 @@ type BaseContext struct {
|
||||
}
|
||||
|
||||
type (
|
||||
onFocusFn = func(types.OnFocusOpts)
|
||||
onFocusLostFn = func(types.OnFocusLostOpts)
|
||||
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
|
||||
onFocusFn = func(types.OnFocusOpts)
|
||||
onFocusLostFn = func(types.OnFocusLostOpts)
|
||||
)
|
||||
|
||||
var _ types.IBaseContext = &BaseContext{}
|
||||
@@ -122,6 +120,14 @@ func (self *BaseContext) HasSelectableContent() bool {
|
||||
return self.hasSelectableContent
|
||||
}
|
||||
|
||||
// SetHasSelectableContent is for the contexts whose answer isn't fixed and isn't a
|
||||
// list length either: the main panes, which can only tell by reading the diff they
|
||||
// have rendered. Whoever sets it re-derives the highlights that follow from it (see
|
||||
// ContextMgr.UpdateSelectionHighlights).
|
||||
func (self *BaseContext) SetHasSelectableContent(value bool) {
|
||||
self.hasSelectableContent = value
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetKey() types.ContextKey {
|
||||
return self.key
|
||||
}
|
||||
@@ -153,7 +159,6 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
|
||||
self.onQuitFns = nil
|
||||
self.onDoubleClickFn = nil
|
||||
self.onClickFn = nil
|
||||
self.onClickFocusedMainViewFn = nil
|
||||
self.onRenderToMainFn = nil
|
||||
}
|
||||
|
||||
@@ -175,15 +180,6 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e
|
||||
}
|
||||
}
|
||||
|
||||
func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) {
|
||||
if fn != nil {
|
||||
if self.onClickFocusedMainViewFn != nil {
|
||||
panic("only one controller is allowed to set an onClickFocusedMainViewFn")
|
||||
}
|
||||
self.onClickFocusedMainViewFn = fn
|
||||
}
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetOnDoubleClick() func() error {
|
||||
return self.onDoubleClickFn
|
||||
}
|
||||
@@ -192,10 +188,6 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
|
||||
return self.onClickFn
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
|
||||
return self.onClickFocusedMainViewFn
|
||||
}
|
||||
|
||||
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
|
||||
if fn != nil {
|
||||
if self.onRenderToMainFn != nil {
|
||||
|
||||
@@ -19,11 +19,16 @@ type CommitFilesContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
|
||||
)
|
||||
|
||||
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
|
||||
viewModel := filetree.NewCommitFileTreeViewModel(
|
||||
func() []*models.CommitFile { return c.Model().CommitFiles },
|
||||
|
||||
@@ -31,11 +31,16 @@ type commitDropIndicator struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
dropIndicator := &commitDropIndicator{insertionIndex: -1}
|
||||
viewModel := NewLocalCommitsViewModel(
|
||||
|
||||
@@ -8,9 +8,62 @@ import (
|
||||
type MainContext struct {
|
||||
*SimpleContext
|
||||
*SearchTrait
|
||||
|
||||
diffSelect types.DiffSelectState
|
||||
// dragAnchorViewLine is the view line a mouse-down landed on, remembered so that a
|
||||
// drag that follows can anchor its range there. The click may have selected a whole
|
||||
// hunk, whose range anchor is the block's far end, so the clicked line can't be
|
||||
// read back from the view.
|
||||
dragAnchorViewLine int
|
||||
// selectableContentRenderKey names the render whose content HasSelectableContent
|
||||
// was worked out from. What there is to select is a property of the content, so an
|
||||
// answer about the content of another render says nothing about this one.
|
||||
selectableContentRenderKey string
|
||||
}
|
||||
|
||||
var _ types.ISearchableContext = (*MainContext)(nil)
|
||||
var (
|
||||
_ types.ISearchableContext = (*MainContext)(nil)
|
||||
_ types.DiffPaneContext = (*MainContext)(nil)
|
||||
)
|
||||
|
||||
// DiffSelectState returns the focused main view's selection mode state, for the
|
||||
// controllers to read and mutate directly.
|
||||
func (self *MainContext) DiffSelectState() *types.DiffSelectState {
|
||||
return &self.diffSelect
|
||||
}
|
||||
|
||||
// ResetDiffSelectMode returns the pane's selection to the default mode — a single
|
||||
// line, no range — for whenever it is established from scratch rather than moved. The
|
||||
// view's range anchor is cleared too, so the next render highlights the cursor line
|
||||
// only.
|
||||
func (self *MainContext) ResetDiffSelectMode() {
|
||||
self.diffSelect.Mode = types.DiffSelectModeLine
|
||||
self.diffSelect.RangeIsSticky = false
|
||||
self.diffSelect.UserEnabledHunkMode = false
|
||||
self.GetView().CancelRangeSelect()
|
||||
}
|
||||
|
||||
// SetDragAnchorViewLine records the view line a mouse-down landed on, so that a drag
|
||||
// that follows can anchor its range there (see dragAnchorViewLine).
|
||||
func (self *MainContext) SetDragAnchorViewLine(viewLine int) {
|
||||
self.dragAnchorViewLine = viewLine
|
||||
}
|
||||
|
||||
// DragAnchorViewLine returns the view line the last mouse-down landed on.
|
||||
func (self *MainContext) DragAnchorViewLine() int {
|
||||
return self.dragAnchorViewLine
|
||||
}
|
||||
|
||||
// SelectableContentRenderKey returns the render HasSelectableContent describes (see
|
||||
// selectableContentRenderKey).
|
||||
func (self *MainContext) SelectableContentRenderKey() string {
|
||||
return self.selectableContentRenderKey
|
||||
}
|
||||
|
||||
// SetSelectableContentRenderKey records which render HasSelectableContent describes.
|
||||
func (self *MainContext) SetSelectableContentRenderKey(key string) {
|
||||
self.selectableContentRenderKey = key
|
||||
}
|
||||
|
||||
func NewMainContext(
|
||||
view *gocui.View,
|
||||
@@ -21,12 +74,11 @@ func NewMainContext(
|
||||
ctx := &MainContext{
|
||||
SimpleContext: NewSimpleContext(
|
||||
NewBaseContext(NewBaseContextOpts{
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: view,
|
||||
WindowName: windowName,
|
||||
Key: key,
|
||||
Focusable: true,
|
||||
HasSelectableContent: false,
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: view,
|
||||
WindowName: windowName,
|
||||
Key: key,
|
||||
Focusable: true,
|
||||
})),
|
||||
SearchTrait: NewSearchTrait(c),
|
||||
}
|
||||
@@ -38,5 +90,8 @@ func (self *MainContext) ModelSearchResults(searchStr string, caseSensitive bool
|
||||
return nil
|
||||
}
|
||||
|
||||
// When selecting a search result, collapse a range selection (whether sticky or not)
|
||||
// or a hunk selection to just the matching line.
|
||||
func (self *MainContext) OnSearchSelect(int) {
|
||||
self.ResetDiffSelectMode()
|
||||
}
|
||||
|
||||
@@ -14,10 +14,15 @@ type ReflogCommitsContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.IListContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*ReflogCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *ReflogCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
|
||||
viewModel := NewFilteredListViewModel(
|
||||
func() []*models.Commit { return c.Model().FilteredReflogCommits },
|
||||
|
||||
@@ -12,10 +12,15 @@ type StashContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*StashContext)(nil)
|
||||
_ types.DiffableContext = (*StashContext)(nil)
|
||||
_ types.IListContext = (*StashContext)(nil)
|
||||
_ types.DiffableContext = (*StashContext)(nil)
|
||||
_ types.DiffMainViewContext = (*StashContext)(nil)
|
||||
)
|
||||
|
||||
func (self *StashContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewStashContext(
|
||||
c *ContextCommon,
|
||||
) *StashContext {
|
||||
|
||||
@@ -21,11 +21,16 @@ type SubCommitsContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewSubCommitsContext(
|
||||
c *ContextCommon,
|
||||
) *SubCommitsContext {
|
||||
|
||||
@@ -15,10 +15,15 @@ type WorkingTreeContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IListContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
|
||||
_ types.DiffMainViewContext = (*WorkingTreeContext)(nil)
|
||||
)
|
||||
|
||||
func (self *WorkingTreeContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypeStaging
|
||||
}
|
||||
|
||||
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
|
||||
viewModel := filetree.NewFileTreeViewModel(
|
||||
func() []*models.File { return c.Model().Files },
|
||||
|
||||
@@ -67,7 +67,8 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
worktreeHelper,
|
||||
searchHelper,
|
||||
)
|
||||
diffHelper := helpers.NewDiffHelper(helperCommon)
|
||||
diffLineHelper := helpers.NewDiffLineHelper(helperCommon)
|
||||
diffHelper := helpers.NewDiffHelper(helperCommon, diffLineHelper)
|
||||
cherryPickHelper := helpers.NewCherryPickHelper(
|
||||
helperCommon,
|
||||
rebaseHelper,
|
||||
@@ -110,6 +111,7 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
SuspendResume: helpers.NewSuspendResumeHelper(helperCommon),
|
||||
Snake: helpers.NewSnakeHelper(helperCommon),
|
||||
Diff: diffHelper,
|
||||
DiffLine: diffLineHelper,
|
||||
Repos: reposHelper,
|
||||
RecordDirectory: recordDirectoryHelper,
|
||||
Update: helpers.NewUpdateHelper(helperCommon, gui.Updater),
|
||||
@@ -176,7 +178,6 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
contextLinesController := controllers.NewContextLinesController(common)
|
||||
renameSimilarityThresholdController := controllers.NewRenameSimilarityThresholdController(common)
|
||||
verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common)
|
||||
viewSelectionControllerFactory := controllers.NewViewSelectionControllerFactory(common)
|
||||
|
||||
branchesController := controllers.NewBranchesController(common)
|
||||
gitFlowController := controllers.NewGitFlowController(common)
|
||||
@@ -313,13 +314,11 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
controllers.AttachControllers(gui.State.Contexts.Normal,
|
||||
mainViewController,
|
||||
verticalScrollControllerFactory.Create(gui.State.Contexts.Normal),
|
||||
viewSelectionControllerFactory.Create(gui.State.Contexts.Normal),
|
||||
)
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.NormalSecondary,
|
||||
secondaryViewController,
|
||||
verticalScrollControllerFactory.Create(gui.State.Contexts.NormalSecondary),
|
||||
viewSelectionControllerFactory.Create(gui.State.Contexts.NormalSecondary),
|
||||
)
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.Files,
|
||||
|
||||
@@ -8,7 +8,6 @@ func AttachControllers(context types.Context, controllers ...types.IController)
|
||||
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
|
||||
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
|
||||
context.AddOnClickFn(controller.GetOnClick())
|
||||
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
|
||||
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
|
||||
context.AddOnFocusFn(controller.GetOnFocus())
|
||||
context.AddOnFocusLostFn(controller.GetOnFocusLost())
|
||||
|
||||
@@ -19,10 +19,6 @@ func (self *baseController) GetOnDoubleClick() func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
@@ -144,8 +143,8 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Branches.FastForward),
|
||||
Handler: self.withItem(self.fastForward),
|
||||
GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)),
|
||||
Handler: self.withItems(self.fastForward),
|
||||
GetDisabledReason: self.require(self.itemRangeSelected(self.branchesAreReal)),
|
||||
Description: self.c.Tr.FastForward,
|
||||
Tooltip: self.c.Tr.FastForwardTooltip,
|
||||
},
|
||||
@@ -206,13 +205,13 @@ func (self *BranchesController) GetOnRenderToMain() func() {
|
||||
} else {
|
||||
cmdObj := self.c.Git().Branch.GetGraphCmdObj(branch.FullRefName())
|
||||
|
||||
ptyTask := types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
task = ptyTask
|
||||
rendererTask := types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
task = rendererTask
|
||||
|
||||
pr, ok := self.c.Model().PullRequestsMap[branch.Name]
|
||||
if ok && presentation.ShouldShowPrForBranch(pr, branch.Name, self.c.UserConfig()) {
|
||||
ptyTask.Prefix = presentation.FormatPullRequestHeader(pr, self.c.Tr)
|
||||
ptyTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n"
|
||||
rendererTask.Prefix = presentation.FormatPullRequestHeader(pr, self.c.Tr)
|
||||
rendererTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,54 +654,23 @@ func (self *BranchesController) rebase(branch *models.Branch) error {
|
||||
return self.c.Helpers().MergeAndRebase.RebaseOntoRef(branch.Name)
|
||||
}
|
||||
|
||||
func (self *BranchesController) fastForward(branch *models.Branch) error {
|
||||
if !branch.IsTrackingRemote() {
|
||||
func (self *BranchesController) fastForward(branches []*models.Branch) error {
|
||||
if !lo.EveryBy(branches, func(branch *models.Branch) bool { return branch.IsTrackingRemote() }) {
|
||||
return errors.New(self.c.Tr.FwdNoUpstream)
|
||||
}
|
||||
if !branch.RemoteBranchStoredLocally() {
|
||||
if !lo.EveryBy(branches, func(branch *models.Branch) bool { return branch.RemoteBranchStoredLocally() }) {
|
||||
return errors.New(self.c.Tr.FwdNoLocalUpstream)
|
||||
}
|
||||
if branch.IsAheadForPull() {
|
||||
// A branch that is only ahead has nothing to fast-forward to. One that is
|
||||
// both ahead and behind may still be reset to its upstream, so let the
|
||||
// helper look into it.
|
||||
if lo.SomeBy(branches, func(branch *models.Branch) bool {
|
||||
return branch.IsAheadForPull() && !branch.IsBehindForPull()
|
||||
}) {
|
||||
return errors.New(self.c.Tr.FwdCommitsToPush)
|
||||
}
|
||||
|
||||
action := self.c.Tr.Actions.FastForwardBranch
|
||||
worktree, ok := self.worktreeForBranch(branch)
|
||||
|
||||
return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
if ok {
|
||||
self.c.LogAction(action)
|
||||
|
||||
worktreeGitDir := ""
|
||||
worktreePath := ""
|
||||
// if it is the current worktree path, no need to specify the path
|
||||
if !worktree.IsCurrent {
|
||||
worktreeGitDir = worktree.GitDir
|
||||
worktreePath = worktree.Path
|
||||
}
|
||||
|
||||
err := self.c.Git().Sync.Pull(
|
||||
task,
|
||||
git_commands.PullOptions{
|
||||
RemoteName: branch.UpstreamRemote,
|
||||
BranchName: branch.UpstreamBranch,
|
||||
FastForwardOnly: true,
|
||||
WorktreeGitDir: worktreeGitDir,
|
||||
WorktreePath: worktreePath,
|
||||
},
|
||||
)
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.LogAction(action)
|
||||
|
||||
err := self.c.Git().Sync.FastForward(
|
||||
task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch,
|
||||
)
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
|
||||
return err
|
||||
})
|
||||
return self.c.Helpers().BranchesHelper.FastForwardBranches(branches)
|
||||
}
|
||||
|
||||
func (self *BranchesController) createTag(branch *models.Branch) error {
|
||||
|
||||
@@ -177,7 +177,7 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
|
||||
|
||||
paths := self.pathsForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false)
|
||||
task := types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
task := types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
@@ -606,16 +606,6 @@ func (self *CommitFilesController) expandAll() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
return func(mainViewName string, clickedLineIdx int) error {
|
||||
node := self.getSelectedItem()
|
||||
if node != nil && node.File != nil {
|
||||
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
|
||||
return diffPathsForNode(
|
||||
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering())
|
||||
|
||||
@@ -83,6 +83,11 @@ func (self *ContextLinesController) applyChange() error {
|
||||
case context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY:
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STAGING}})
|
||||
default:
|
||||
// The diff is about to be rendered again with more or less context around
|
||||
// each change, which reads as the lines you were looking at moving up or down
|
||||
// the view; keep them where they are instead.
|
||||
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
|
||||
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
|
||||
currentContext.HandleRenderToMain()
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -356,7 +356,7 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
|
||||
prefix += self.c.Tr.MergeConflictCurrentDiff
|
||||
}
|
||||
prefix += "\n\n"
|
||||
self.renderToMainWithTask(types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix))
|
||||
self.renderToMainWithTask(types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
||||
refreshOpts := types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Title: title,
|
||||
},
|
||||
@@ -395,7 +395,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
||||
refreshOpts.Secondary = &types.ViewUpdateOpts{
|
||||
Title: title,
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,16 +408,6 @@ func (self *FilesController) GetOnDoubleClick() func() error {
|
||||
})
|
||||
}
|
||||
|
||||
func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
return func(mainViewName string, clickedLineIdx int) error {
|
||||
node := self.getSelectedItem()
|
||||
if node != nil && node.File != nil {
|
||||
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// if we are dealing with a status for which there is no key in this map,
|
||||
// then we won't optimistically render: we'll just let `git status` tell
|
||||
// us what the new status is.
|
||||
|
||||
@@ -3,7 +3,6 @@ package controllers
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
@@ -185,13 +184,7 @@ func (self *GlobalController) cycleDiffRenderersBackward() error {
|
||||
// onDiffRenderersChanged re-renders the main view so the newly selected diff renderer
|
||||
// takes effect, and shows a toast naming it.
|
||||
func (self *GlobalController) onDiffRenderersChanged() {
|
||||
currentSide := self.c.Context().CurrentSide()
|
||||
currentKey := self.c.Context().Current().GetKey()
|
||||
if currentSide.GetKey() == currentKey ||
|
||||
currentKey == context.NORMAL_MAIN_CONTEXT_KEY ||
|
||||
currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY {
|
||||
currentSide.HandleRenderToMain()
|
||||
}
|
||||
self.c.Helpers().Diff.RenderToMainAgain()
|
||||
|
||||
diffRendererConfigManager := self.c.State().GetDiffRendererConfigManager()
|
||||
current, total := diffRendererConfigManager.CurrentDiffRendererIndex()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// Returns the branches stacked below the given branch: those whose tip is one
|
||||
// of the given commits of the branch that hasn't been merged into a main
|
||||
// branch yet. The branch itself and the main branches are left out. The
|
||||
// result is in the order in which the tips appear in commits, so the branch
|
||||
// closest to the given one comes first.
|
||||
//
|
||||
// Only the commits passed in are looked at, so a branch whose tip is further
|
||||
// down the history than the loaded commits is not found.
|
||||
func BranchesBelowInStack(
|
||||
commits []*models.Commit,
|
||||
branches []*models.Branch,
|
||||
branch *models.Branch,
|
||||
mainBranches []string,
|
||||
) []*models.Branch {
|
||||
branchesByTip := map[string][]*models.Branch{}
|
||||
for _, other := range branches {
|
||||
if other.Name == branch.Name || lo.Contains(mainBranches, other.Name) {
|
||||
continue
|
||||
}
|
||||
branchesByTip[other.CommitHash] = append(branchesByTip[other.CommitHash], other)
|
||||
}
|
||||
|
||||
result := []*models.Branch{}
|
||||
for _, commit := range commits {
|
||||
if commit.IsTODO() || commit.Status == models.StatusMerged {
|
||||
continue
|
||||
}
|
||||
result = append(result, branchesByTip[commit.Hash()]...)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stefanhaller/git-todo-parser/todo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBranchesBelowInStack(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
commit := func(hash string, status models.CommitStatus) *models.Commit {
|
||||
return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Status: status})
|
||||
}
|
||||
todoCommit := func(hash string) *models.Commit {
|
||||
return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Action: todo.Pick, Status: models.StatusRebasing})
|
||||
}
|
||||
branch := func(name string, tip string) *models.Branch {
|
||||
return &models.Branch{Name: name, CommitHash: tip}
|
||||
}
|
||||
|
||||
current := branch("current", "c3")
|
||||
mainBranches := []string{"master", "main"}
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
commits []*models.Commit
|
||||
branches []*models.Branch
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
testName: "stack of branches, closest to the current one first",
|
||||
commits: []*models.Commit{
|
||||
commit("c3", models.StatusUnpushed),
|
||||
commit("c2", models.StatusPushed),
|
||||
commit("c1", models.StatusUnpushed),
|
||||
commit("m1", models.StatusMerged),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("first", "c1"), branch("second", "c2"), branch("master", "m1")},
|
||||
expected: []string{"second", "first"},
|
||||
},
|
||||
{
|
||||
testName: "branch pointing at the same commit as the current one",
|
||||
commits: []*models.Commit{
|
||||
commit("c3", models.StatusUnpushed),
|
||||
commit("m1", models.StatusMerged),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("twin", "c3")},
|
||||
expected: []string{"twin"},
|
||||
},
|
||||
{
|
||||
testName: "several branches pointing at one commit keep their order",
|
||||
commits: []*models.Commit{
|
||||
commit("c3", models.StatusUnpushed),
|
||||
commit("c1", models.StatusUnpushed),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("one", "c1"), branch("two", "c1")},
|
||||
expected: []string{"one", "two"},
|
||||
},
|
||||
{
|
||||
testName: "main branches are left out even if their tip isn't merged",
|
||||
commits: []*models.Commit{
|
||||
commit("c3", models.StatusUnpushed),
|
||||
commit("c1", models.StatusUnpushed),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("main", "c1")},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
testName: "branches whose tip is merged are left out",
|
||||
commits: []*models.Commit{
|
||||
commit("c3", models.StatusUnpushed),
|
||||
commit("m1", models.StatusMerged),
|
||||
commit("m0", models.StatusMerged),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("old", "m0"), branch("master", "m1")},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
testName: "branches whose tip is not among the commits are left out",
|
||||
commits: []*models.Commit{
|
||||
commit("c3", models.StatusUnpushed),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("sibling", "s1")},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
testName: "todo commits of a rebase don't count",
|
||||
commits: []*models.Commit{
|
||||
todoCommit("t1"),
|
||||
commit("c3", models.StatusUnpushed),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("other", "t1")},
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
result := BranchesBelowInStack(s.commits, s.branches, current, mainBranches)
|
||||
names := lo.Map(result, func(b *models.Branch, _ int) string { return b.Name })
|
||||
assert.Equal(t, s.expected, names)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
@@ -420,52 +422,287 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool, fe
|
||||
if self.c.State().GetRepoGeneration() != fetchGeneration {
|
||||
return nil
|
||||
}
|
||||
err := self.AutoForwardBranches(background)
|
||||
if background && err != nil {
|
||||
// The background poller discards this return value, so surface
|
||||
// the error in the log rather than as a popup for background work.
|
||||
self.c.Log.Error(err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
self.AutoForwardBranches(background)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return fetchErr
|
||||
}
|
||||
|
||||
func (self *BranchesHelper) AutoForwardBranches(background bool) error {
|
||||
if self.c.UserConfig().Git.AutoForwardBranches == "none" {
|
||||
// One of the branches that a fast-forward is about to bring to its upstream
|
||||
type branchToForward struct {
|
||||
branch *models.Branch
|
||||
// the worktree that the branch is checked out in, nil if there is none
|
||||
worktree *models.Worktree
|
||||
// whether the branch has to be reset to its upstream because it has
|
||||
// diverged from it, as opposed to being moved forward
|
||||
reset bool
|
||||
}
|
||||
|
||||
// Updates the given branches to their upstream branches, fetching those first.
|
||||
// A branch that is behind its upstream is moved forward to it; one that has
|
||||
// diverged from it is reset to it, as long as it has no commits of its own. If
|
||||
// any of the branches can't be updated, none of them is.
|
||||
func (self *BranchesHelper) FastForwardBranches(branches []*models.Branch) error {
|
||||
fastForward, err := self.PrepareFastForward(branches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return self.WithInlineStatusOnBranches(branches, types.ItemOperationFastForwarding, fastForward)
|
||||
}
|
||||
|
||||
// Does the part of FastForwardBranches that looks at the model, and so has to
|
||||
// run on the UI thread. Returns the rest, which is to be run on a worker; this
|
||||
// lets other operations include the fast-forward in their own task.
|
||||
func (self *BranchesHelper) PrepareFastForward(branches []*models.Branch) (func(gocui.Task) error, error) {
|
||||
toForward := lo.Map(branches, func(branch *models.Branch, _ int) *branchToForward {
|
||||
worktree, _ := self.worktreeForBranch(branch)
|
||||
return &branchToForward{branch: branch, worktree: worktree}
|
||||
})
|
||||
anyCheckedOut := lo.SomeBy(toForward, func(f *branchToForward) bool { return f.worktree != nil })
|
||||
|
||||
// Updating that worktree would move its detached HEAD, which belongs to the
|
||||
// rebase or bisect, and leave the branch alone
|
||||
for _, f := range toForward {
|
||||
if f.worktree != nil && f.worktree.IsRebasingOrBisecting {
|
||||
return nil, errors.New(utils.ResolvePlaceholderString(
|
||||
self.c.Tr.FwdBranchRebasingOrBisecting,
|
||||
map[string]string{"branchName": f.branch.Name, "worktreeName": f.worktree.Name},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return func(task gocui.Task) error {
|
||||
defer func() {
|
||||
if anyCheckedOut {
|
||||
// The files of those worktrees have changed as well
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
} else {
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
|
||||
}
|
||||
}()
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.FastForwardBranch)
|
||||
|
||||
if err := self.fetchUpstreamBranches(task, branches); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Look at all the branches before moving any of them, so that one we
|
||||
// have to refuse leaves the others alone too
|
||||
for _, f := range toForward {
|
||||
if err := self.planForwardingBranch(f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return self.forwardBranches(self.c.Git(), toForward)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Runs f on a worker with all the given branches shown as being in the given
|
||||
// operation while it runs
|
||||
func (self *BranchesHelper) WithInlineStatusOnBranches(branches []*models.Branch, operation types.ItemOperation, f func(gocui.Task) error) error {
|
||||
return WithInlineStatusOnItems(self.c, branches, operation, context.LOCAL_BRANCHES_CONTEXT_KEY, f)
|
||||
}
|
||||
|
||||
func (self *BranchesHelper) fetchUpstreamBranches(task gocui.Task, branches []*models.Branch) error {
|
||||
remotes := lo.Uniq(lo.Map(branches, func(branch *models.Branch, _ int) string {
|
||||
return branch.UpstreamRemote
|
||||
}))
|
||||
|
||||
for _, remote := range remotes {
|
||||
remoteBranches := []string{}
|
||||
for _, branch := range branches {
|
||||
if branch.UpstreamRemote == remote {
|
||||
remoteBranches = append(remoteBranches, branch.UpstreamBranch)
|
||||
}
|
||||
}
|
||||
|
||||
if err := self.c.Git().Sync.FetchRemoteBranches(task, remote, remoteBranches); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Works out whether the branch has to be reset to its upstream, and returns an
|
||||
// error if it can't be brought there at all.
|
||||
func (self *BranchesHelper) planForwardingBranch(f *branchToForward) error {
|
||||
f.reset = !self.c.Git().Branch.IsAncestor(
|
||||
f.branch.FullRefName(), f.branch.FullUpstreamRefName())
|
||||
if !f.reset {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Moving the branch to its upstream means giving up the commits it is
|
||||
// ahead by, so make sure that none of them is ours
|
||||
hasLocalOnlyCommits, err := self.c.Git().Branch.HasLocalOnlyCommits(f.branch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasLocalOnlyCommits {
|
||||
return errors.New(utils.ResolvePlaceholderString(
|
||||
self.c.Tr.FwdLocalOnlyCommits,
|
||||
map[string]string{"branchName": f.branch.Name},
|
||||
))
|
||||
}
|
||||
|
||||
if f.worktree != nil {
|
||||
// Resetting the branch changes the files of the worktree under the
|
||||
// user's feet, so only do it while they have no changes of their own
|
||||
// there
|
||||
worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree)
|
||||
hasChanges, err := self.c.Git().WorkingTree.HasChangesToTrackedFiles(worktreeGitDir, worktreePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasChanges {
|
||||
return errors.New(utils.ResolvePlaceholderString(
|
||||
self.c.Tr.FwdUncommittedChanges,
|
||||
map[string]string{"branchName": f.branch.Name},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keeps going when a branch fails to update, so that it doesn't hold up the
|
||||
// others, and returns the errors of all the failed ones.
|
||||
func (self *BranchesHelper) forwardBranches(git *commands.GitCommand, toForward []*branchToForward) error {
|
||||
var errs []error
|
||||
|
||||
// The branches that aren't checked out anywhere are nothing but refs to
|
||||
// update, so they can all be done in one go
|
||||
updateCommands := ""
|
||||
for _, f := range toForward {
|
||||
if f.worktree == nil {
|
||||
updateCommands += fmt.Sprintf("update %s %s %s\n",
|
||||
f.branch.FullRefName(), f.branch.FullUpstreamRefName(), f.branch.CommitHash)
|
||||
}
|
||||
}
|
||||
|
||||
if updateCommands != "" {
|
||||
self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false)
|
||||
if err := git.Branch.UpdateBranchRefs(updateCommands, "lazygit: update to upstream branch"); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A branch that is checked out somewhere needs the files of that worktree
|
||||
// to be updated along with it
|
||||
for _, f := range toForward {
|
||||
if f.worktree == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree)
|
||||
|
||||
var err error
|
||||
if f.reset {
|
||||
err = git.WorkingTree.ResetKeep(
|
||||
f.branch.FullUpstreamRefName(), worktreeGitDir, worktreePath)
|
||||
} else {
|
||||
err = git.Branch.FastForwardMerge(
|
||||
f.branch.FullUpstreamRefName(), worktreeGitDir, worktreePath)
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Returns the git dir and the path to pass for the given worktree; both are
|
||||
// empty for the current one, which git commands use by default anyway.
|
||||
func (self *BranchesHelper) worktreeArgs(worktree *models.Worktree) (string, string) {
|
||||
if worktree.IsCurrent {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return worktree.GitDir, worktree.Path
|
||||
}
|
||||
|
||||
// Reads the branches from the model, so it must be called on the UI thread; the
|
||||
// git work happens on a worker.
|
||||
func (self *BranchesHelper) AutoForwardBranches(background bool) {
|
||||
if self.c.UserConfig().Git.AutoForwardBranches == "none" {
|
||||
return
|
||||
}
|
||||
|
||||
branches := self.c.Model().Branches
|
||||
if len(branches) == 0 {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
allBranches := self.c.UserConfig().Git.AutoForwardBranches == "allBranches"
|
||||
updateCommands := ""
|
||||
toForward := []*branchToForward{}
|
||||
// The first branch is the currently checked out branch; skip it
|
||||
for _, branch := range branches[1:] {
|
||||
if branch.RemoteBranchStoredLocally() &&
|
||||
!self.checkedOutByOtherWorktree(branch) &&
|
||||
(allBranches || lo.Contains(self.c.UserConfig().Git.MainBranches, branch.Name)) {
|
||||
isStrictlyBehind := branch.IsBehindForPull() && !branch.IsAheadForPull()
|
||||
if isStrictlyBehind {
|
||||
updateCommands += fmt.Sprintf("update %s %s %s\n", branch.FullRefName(), branch.FullUpstreamRefName(), branch.CommitHash)
|
||||
}
|
||||
if !branch.RemoteBranchStoredLocally() ||
|
||||
!(allBranches || lo.Contains(self.c.UserConfig().Git.MainBranches, branch.Name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
isStrictlyBehind := branch.IsBehindForPull() && !branch.IsAheadForPull()
|
||||
if !isStrictlyBehind {
|
||||
continue
|
||||
}
|
||||
|
||||
// Changing the files of the current worktree without being asked to
|
||||
// would be surprising. A worktree that is mid-rebase or mid-bisect has
|
||||
// its HEAD detached from the branch, so the branch can't be moved
|
||||
// there, and neither can it in a worktree whose directory is missing.
|
||||
worktree, _ := self.worktreeForBranch(branch)
|
||||
if worktree != nil && (worktree.IsCurrent || worktree.IsRebasingOrBisecting || worktree.IsPathMissing) {
|
||||
continue
|
||||
}
|
||||
|
||||
toForward = append(toForward, &branchToForward{branch: branch, worktree: worktree})
|
||||
}
|
||||
|
||||
if updateCommands == "" {
|
||||
return nil
|
||||
if len(toForward) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.AutoForwardBranches)
|
||||
self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false)
|
||||
err := self.c.Git().Branch.UpdateBranchRefs(updateCommands)
|
||||
// A background worker doesn't block switching repos, so it has to stick
|
||||
// to the git commands of the repo that the branches came from
|
||||
git := self.c.Git()
|
||||
onWorker := lo.Ternary(background, self.c.OnWorkerBackground, self.c.OnWorker)
|
||||
onWorker(func(gocui.Task) error {
|
||||
// Only change the files of another worktree while the user has no
|
||||
// changes of their own there
|
||||
toForward := lo.Filter(toForward, func(f *branchToForward, _ int) bool {
|
||||
if f.worktree == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background})
|
||||
worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree)
|
||||
hasChanges, err := git.WorkingTree.HasChangesToTrackedFiles(worktreeGitDir, worktreePath)
|
||||
if err != nil {
|
||||
self.c.Log.Errorf("Failed to check worktree %s for changes: %v", f.worktree.Name, err)
|
||||
}
|
||||
return err == nil && !hasChanges
|
||||
})
|
||||
if len(toForward) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
self.c.LogAction(self.c.Tr.Actions.AutoForwardBranches)
|
||||
err := self.forwardBranches(git, toForward)
|
||||
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background})
|
||||
|
||||
if background && err != nil {
|
||||
// Surface the error in the log rather than as a popup for background
|
||||
// work
|
||||
self.c.Log.Error(err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ import (
|
||||
)
|
||||
|
||||
type DiffHelper struct {
|
||||
c *HelperCommon
|
||||
c *HelperCommon
|
||||
diffLineHelper *DiffLineHelper
|
||||
}
|
||||
|
||||
func NewDiffHelper(c *HelperCommon) *DiffHelper {
|
||||
func NewDiffHelper(c *HelperCommon, diffLineHelper *DiffLineHelper) *DiffHelper {
|
||||
return &DiffHelper{
|
||||
c: c,
|
||||
c: c,
|
||||
diffLineHelper: diffLineHelper,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,11 +76,11 @@ func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Comm
|
||||
}
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
|
||||
prefix := style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName())
|
||||
return types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
return types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
}
|
||||
|
||||
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit))
|
||||
return types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
return types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
}
|
||||
|
||||
func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string {
|
||||
@@ -98,6 +100,24 @@ func (self *DiffHelper) ExitDiffMode() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderToMainAgain renders the current side panel into the main view again, if
|
||||
// that is what the main view shows. This is for when something that the
|
||||
// rendering depends on has changed, such as the diff renderer.
|
||||
func (self *DiffHelper) RenderToMainAgain() {
|
||||
currentSide := self.c.Context().CurrentSide()
|
||||
currentKey := self.c.Context().Current().GetKey()
|
||||
if currentSide.GetKey() == currentKey ||
|
||||
currentKey == context.NORMAL_MAIN_CONTEXT_KEY ||
|
||||
currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY {
|
||||
// Whatever changed can make the diff come out differently, such as a new
|
||||
// renderer laying it out its own way, so the line you were looking at could
|
||||
// end up anywhere in the view; keep it in front of you.
|
||||
self.diffLineHelper.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
|
||||
self.diffLineHelper.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
|
||||
currentSide.HandleRenderToMain()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *DiffHelper) RenderDiff() {
|
||||
args := self.DiffArgs()
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
|
||||
@@ -106,7 +126,7 @@ func (self *DiffHelper) RenderDiff() {
|
||||
self.c.Tr.ShowingGitDiff,
|
||||
"git diff "+strings.Join(args, " "),
|
||||
)
|
||||
task := types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
task := types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
type DiffLineHelper struct {
|
||||
c *HelperCommon
|
||||
}
|
||||
|
||||
func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper {
|
||||
return &DiffLineHelper{c: c}
|
||||
}
|
||||
|
||||
// GetDiffLineInfo recovers the identity — file, kind, and old/new line number —
|
||||
// of the diff row at the given (wrapped) view line of the given view. It is the
|
||||
// seam every consumer of a diff row goes through, so that how we recover that
|
||||
// identity can change without them noticing.
|
||||
//
|
||||
// There are two ways, and which one is used is settled for the rendering as a
|
||||
// whole (see renderingStatesDiffLines). A diff renderer that speaks the OSC 1717
|
||||
// protocol states the identity of each line it renders. That is the only way to
|
||||
// recover it from a rendering that doesn't look like a diff any more (columns, or
|
||||
// +/- markers replaced by colour), and a row such a renderer says nothing about
|
||||
// has no identity. Otherwise we parse the view's contents as a unified diff; this
|
||||
// works for the renderings that keep a diff's structure (no renderer, `git diff
|
||||
// --color`, a renderer that only colorizes) and fails for the rest.
|
||||
//
|
||||
// ok is false when the row's identity can't be recovered, in which case the
|
||||
// caller must not act on the line at all.
|
||||
func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) {
|
||||
identities, ok := self.diffLineIdentitiesAt(view, viewLineIdx)
|
||||
if !ok {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
return identities[0], true
|
||||
}
|
||||
|
||||
// diffLineIdentitiesAt recovers every diff line the row at the given (wrapped) view
|
||||
// line shows, left to right. It is GetDiffLineInfo's form for a reader that can't
|
||||
// settle for the line the row leads with: an end of a selection covers its whole
|
||||
// row, so where a rendering puts a modification's two halves side by side it covers
|
||||
// both of them. ok is false when the row's identity can't be recovered at all.
|
||||
func (self *DiffLineHelper) diffLineIdentitiesAt(
|
||||
view *gocui.View, viewLineIdx int,
|
||||
) ([]types.DiffLineInfo, bool) {
|
||||
// The cursor and clicks land on a view line, which counts wrapped segments;
|
||||
// the contents are indexed by unwrapped buffer line.
|
||||
bufferLineIdx, ok := view.BufferLineForViewLine(viewLineIdx)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
contents := view.DiffLineContents()
|
||||
if bufferLineIdx >= len(contents) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if renderingStatesDiffLines(contents) {
|
||||
if identities := self.diffLineIdentitiesFromRecords(contents[bufferLineIdx].Metadata); len(identities) > 0 {
|
||||
return identities, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
parsed, ok := parseDiffLineFromBuffer(diffLineTexts(contents), bufferLineIdx)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return []types.DiffLineInfo{self.diffLineInfo(parsed)}, true
|
||||
}
|
||||
|
||||
// diffLineIdentitiesFromRecords recovers the identity of every diff line the row's
|
||||
// records state, left to right. A row can carry more than one record, when the
|
||||
// rendering puts two diff lines on it (a side-by-side row shows a deletion and the
|
||||
// addition replacing it). Which of them a reader is after depends on the reader: the
|
||||
// one the row leads with is the row's own identity (see GetDiffLineInfo and
|
||||
// resolveDiffLines), while a reader looking for a particular line has to consider
|
||||
// them all, since which of a modification's two halves leads a row is up to the
|
||||
// rendering.
|
||||
func (self *DiffLineHelper) diffLineIdentitiesFromRecords(metadata []string) []types.DiffLineInfo {
|
||||
return self.diffLineInfos(parseDiffLineRecords(metadata))
|
||||
}
|
||||
|
||||
// resolvedDiffLine is one rendered row's recovered identity, plus whether it could
|
||||
// be recovered at all — the element of the table resolveDiffLines produces.
|
||||
type resolvedDiffLine struct {
|
||||
info types.DiffLineInfo
|
||||
ok bool
|
||||
}
|
||||
|
||||
// resolveDiffLines recovers the identity of every row of a rendered diff in one
|
||||
// pass, indexed 1:1 with contents. It is the batch form of GetDiffLineInfo, for the
|
||||
// whole-buffer scans (which change lines are where, which file each row belongs
|
||||
// to). A row's identity is the line it leads with, of those resolveDiffLineIdentities
|
||||
// finds on it.
|
||||
func (self *DiffLineHelper) resolveDiffLines(contents []gocui.DiffLineContent) []resolvedDiffLine {
|
||||
resolved := make([]resolvedDiffLine, len(contents))
|
||||
for i, identities := range self.resolveDiffLineIdentities(contents) {
|
||||
if len(identities) > 0 {
|
||||
resolved[i] = resolvedDiffLine{identities[0], true}
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// resolveDiffLineIdentities recovers every diff line each row of a rendered diff
|
||||
// shows, in one pass, indexed 1:1 with contents. It reads the rendering the way
|
||||
// GetDiffLineInfo does, by the renderer's records or by parsing it as a unified diff
|
||||
// (see parseDiffLineIdentities), and is the form of the batch resolver for the
|
||||
// readers that can't settle for the line a row leads with: looking for a remembered
|
||||
// line in a new rendering has to consider both halves of a modification, since a
|
||||
// side-by-side row leads with the deletion whose addition was what got remembered
|
||||
// under a unified one.
|
||||
func (self *DiffLineHelper) resolveDiffLineIdentities(contents []gocui.DiffLineContent) [][]types.DiffLineInfo {
|
||||
identities := make([][]types.DiffLineInfo, len(contents))
|
||||
for i, parsed := range parseDiffLineIdentities(contents) {
|
||||
if len(parsed) > 0 {
|
||||
identities[i] = self.diffLineInfos(parsed)
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
// diffLineInfo turns a parser's result into the absolute-path identity consumers
|
||||
// work with. The path arrives repo-relative from the diff header, but a renderer
|
||||
// states it however it likes, absolute paths included.
|
||||
func (self *DiffLineHelper) diffLineInfo(parsed parsedDiffLine) types.DiffLineInfo {
|
||||
return diffLineInfoIn(self.c.Git().RepoPaths.WorktreePath(), parsed)
|
||||
}
|
||||
|
||||
// diffLineInfos is diffLineInfo over every line of a row.
|
||||
func (self *DiffLineHelper) diffLineInfos(parsed []parsedDiffLine) []types.DiffLineInfo {
|
||||
infos := make([]types.DiffLineInfo, len(parsed))
|
||||
for i, line := range parsed {
|
||||
infos[i] = self.diffLineInfo(line)
|
||||
}
|
||||
return infos
|
||||
}
|
||||
|
||||
// diffLineInfoIn is diffLineInfo against a given worktree, for the callers that can't
|
||||
// ask which repo we are in where they run: a repo switch replaces it, so only the UI
|
||||
// thread may read it.
|
||||
func diffLineInfoIn(worktreePath string, parsed parsedDiffLine) types.DiffLineInfo {
|
||||
path := parsed.Path
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(worktreePath, path)
|
||||
}
|
||||
|
||||
return types.DiffLineInfo{
|
||||
Path: path,
|
||||
Type: parsed.Type,
|
||||
NewLine: parsed.NewLine,
|
||||
OldLine: parsed.OldLine,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
// diffFilePrefix marks the start of a file's section in a (possibly multi-file)
|
||||
// unified diff.
|
||||
const diffFilePrefix = "diff --git "
|
||||
|
||||
// submodulePrefix opens the line a submodule's section of a diff starts with, which
|
||||
// stands in for the "diff --git" header a file of the repo gets.
|
||||
const submodulePrefix = "Submodule "
|
||||
|
||||
// submoduleSectionPattern matches that line and captures the submodule's path. git
|
||||
// writes one of two kinds: the commit the submodule is checked out at has moved
|
||||
// ("Submodule sub a32f27c..2d9f921:", with "..." in place of ".." where the move is
|
||||
// no fast-forward, " (rewind)" where it goes backwards, and a message in brackets in
|
||||
// place of the colon where the two commits can't both be read), or its working tree
|
||||
// is dirty ("Submodule sub contains untracked content").
|
||||
//
|
||||
// The path is captured greedily: git writes it unquoted, so a path that itself ends
|
||||
// in something reading like a range of commits is told apart by taking the last such
|
||||
// range on the line.
|
||||
var submoduleSectionPattern = regexp.MustCompile(
|
||||
`^Submodule (.+) (?:contains (?:untracked|modified) content|[0-9a-f]+\.{2,3}[0-9a-f]+(?: \(.*\))?:?)$`)
|
||||
|
||||
// parsedDiffLine is what the parser recovers about a row of a rendered diff.
|
||||
// Path is the path as the diff header spells it, i.e. relative to the repo root;
|
||||
// the caller turns it into the absolute path of types.DiffLineInfo.
|
||||
type parsedDiffLine struct {
|
||||
Path string
|
||||
Type types.DiffLineType
|
||||
NewLine int
|
||||
OldLine int
|
||||
}
|
||||
|
||||
// bufferLineParse is the parser's result for one buffer line: the recovered
|
||||
// identity, and whether the line could be resolved at all (false for a line in
|
||||
// an unparseable section, or outside any file section).
|
||||
type bufferLineParse struct {
|
||||
parsed parsedDiffLine
|
||||
ok bool
|
||||
}
|
||||
|
||||
// parseDiffLineFromBuffer recovers the identity of a row of a rendered diff by
|
||||
// parsing the view's decolorized contents.
|
||||
//
|
||||
// bufferLines is the full unwrapped view buffer; targetIdx is the buffer line to
|
||||
// resolve. A commit's diff spans several files, so we isolate the file section
|
||||
// containing targetIdx and parse just that one (see parseFileSection). Use this
|
||||
// for a single line, e.g. the one under the cursor; to resolve every line of a
|
||||
// buffer, use parseAllDiffLinesFromBuffer, which parses each section only once.
|
||||
//
|
||||
// ok is false when the buffer isn't a parseable unified diff at targetIdx,
|
||||
// because the diff renderer restructured it, so that the caller can fall back.
|
||||
func parseDiffLineFromBuffer(bufferLines []string, targetIdx int) (parsedDiffLine, bool) {
|
||||
if targetIdx < 0 || targetIdx >= len(bufferLines) {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
start, end := fileSectionBounds(bufferLines, targetIdx)
|
||||
if start == -1 {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
r := parseFileSection(bufferLines[start:end], end == len(bufferLines))[targetIdx-start]
|
||||
return r.parsed, r.ok
|
||||
}
|
||||
|
||||
// parseAllDiffLinesFromBuffer resolves every line of a (possibly multi-file)
|
||||
// diff buffer in one pass, parsing each file section exactly once. It is the
|
||||
// batch form of parseDiffLineFromBuffer, for callers that scan a whole buffer:
|
||||
// resolving line by line would re-parse a section once per line of it — O(n²) on
|
||||
// a large single-file diff — whereas this is O(n). The result is indexed 1:1
|
||||
// with bufferLines; a line in an unparseable section, or above the first one, is
|
||||
// left ok=false.
|
||||
func parseAllDiffLinesFromBuffer(bufferLines []string) []bufferLineParse {
|
||||
result := make([]bufferLineParse, len(bufferLines))
|
||||
for i := 0; i < len(bufferLines); {
|
||||
if !startsFileSection(bufferLines[i]) {
|
||||
i++ // in no file section; leave it unresolved
|
||||
continue
|
||||
}
|
||||
end := fileSectionEnd(bufferLines, i)
|
||||
copy(result[i:end], parseFileSection(bufferLines[i:end], end == len(bufferLines)))
|
||||
i = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// diffLineTexts extracts the text of each rendered row — the material the buffer
|
||||
// parser works on.
|
||||
func diffLineTexts(contents []gocui.DiffLineContent) []string {
|
||||
texts := make([]string, len(contents))
|
||||
for i, content := range contents {
|
||||
texts[i] = content.Text
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
// renderingStatesDiffLines reports whether the renderer stated, for at least one row
|
||||
// of the rendering, which diff line it shows. The version-only record a renderer
|
||||
// announces the protocol with names no line, and doesn't count.
|
||||
//
|
||||
// The answer settles how the whole rendering is read. A renderer that states its
|
||||
// lines lays the diff out as it likes, so its text is no unified diff and must not be
|
||||
// parsed as one, not even for the rows it says nothing about. Such a row can read
|
||||
// like a diff header when the file being diffed is itself a diff; the parser would
|
||||
// take that for the start of a file section and place every untagged row below it
|
||||
// in a file the diff doesn't have. So the rows a renderer leaves untagged (dividers,
|
||||
// padding) have no identity, as the protocol has it. A rendering without any record
|
||||
// is a diff that describes itself, and is parsed as one.
|
||||
func renderingStatesDiffLines(contents []gocui.DiffLineContent) bool {
|
||||
return slices.ContainsFunc(contents, func(content gocui.DiffLineContent) bool {
|
||||
return slices.ContainsFunc(content.Metadata, func(record string) bool {
|
||||
_, ok := parseDiffLineMetadata(record)
|
||||
return ok
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// parseDiffLineRecords parses the records a row carries, left to right, leaving out
|
||||
// the ones we don't understand. A row carries more than one when the rendering puts
|
||||
// two diff lines on it, as a side-by-side row does with a deletion and the addition
|
||||
// replacing it.
|
||||
func parseDiffLineRecords(metadata []string) []parsedDiffLine {
|
||||
parsed := make([]parsedDiffLine, 0, len(metadata))
|
||||
for _, record := range metadata {
|
||||
if line, ok := parseDiffLineMetadata(record); ok {
|
||||
parsed = append(parsed, line)
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// parseDiffLineIdentities recovers, for every row of a rendering, the diff lines it
|
||||
// shows, indexed 1:1 with contents; a row that shows none we can place gets an empty
|
||||
// entry. The rendering is read the way renderingStatesDiffLines settles: by the
|
||||
// renderer's records, every one a row carries, or else by parsing the rendering as a
|
||||
// unified diff, where each row shows one line. Each file's section is parsed once;
|
||||
// resolving row by row would re-run that parse once per row, O(n²) on a large
|
||||
// single-file diff.
|
||||
func parseDiffLineIdentities(contents []gocui.DiffLineContent) [][]parsedDiffLine {
|
||||
identities := make([][]parsedDiffLine, len(contents))
|
||||
if renderingStatesDiffLines(contents) {
|
||||
for i, content := range contents {
|
||||
if parsed := parseDiffLineRecords(content.Metadata); len(parsed) > 0 {
|
||||
identities[i] = parsed
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
for i, parsed := range parseAllDiffLinesFromBuffer(diffLineTexts(contents)) {
|
||||
if parsed.ok {
|
||||
identities[i] = []parsedDiffLine{parsed.parsed}
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
// fileSectionBounds returns the half-open range [start, end) of the file section
|
||||
// containing targetIdx: the nearest line starting a section at or above it, up to
|
||||
// where that section ends. start is -1 when targetIdx is above the first file
|
||||
// section, or below the end of the last one that begins above it.
|
||||
func fileSectionBounds(bufferLines []string, targetIdx int) (start, end int) {
|
||||
for start = targetIdx; start >= 0; start-- {
|
||||
if !startsFileSection(bufferLines[start]) {
|
||||
continue
|
||||
}
|
||||
if end = fileSectionEnd(bufferLines, start); targetIdx < end {
|
||||
return start, end
|
||||
}
|
||||
return -1, -1
|
||||
}
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// fileSectionEnd returns the line the file section beginning at start ends before.
|
||||
//
|
||||
// A file's section runs to the next one, since every line between them is part of
|
||||
// its diff. A submodule's runs only as far as what git writes for it — the line
|
||||
// naming it, and the log of the commits it moved over — because the lines after
|
||||
// that need not belong to any section at all. A diff renderer's output has no
|
||||
// "diff --git" line to stop at, and the rows it puts between one file and the next
|
||||
// belong to neither.
|
||||
func fileSectionEnd(bufferLines []string, start int) int {
|
||||
if submodulePath(bufferLines[start]) != "" {
|
||||
end := start + 1
|
||||
for end < len(bufferLines) && isSubmoduleLogLine(bufferLines[end]) {
|
||||
end++
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
for i := start + 1; i < len(bufferLines); i++ {
|
||||
if startsFileSection(bufferLines[i]) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(bufferLines)
|
||||
}
|
||||
|
||||
// startsFileSection reports whether the line opens a section of a diff: git's header
|
||||
// for a file of the repo, or the line a submodule's section begins with.
|
||||
func startsFileSection(line string) bool {
|
||||
return strings.HasPrefix(line, diffFilePrefix) || submodulePath(line) != ""
|
||||
}
|
||||
|
||||
// isSubmoduleLogLine reports whether the line is one of the commits git lists under
|
||||
// a submodule's header, which it writes as two spaces, the direction the commit was
|
||||
// moved in, and the commit's subject.
|
||||
func isSubmoduleLogLine(line string) bool {
|
||||
return strings.HasPrefix(line, " > ") || strings.HasPrefix(line, " < ")
|
||||
}
|
||||
|
||||
// submodulePath returns the submodule whose section the given line opens, and "" for
|
||||
// every other line.
|
||||
//
|
||||
// A submodule gets no "diff --git" header and no hunks: git states which commits it
|
||||
// moved between and lists them, so that one line is all there is to take the path
|
||||
// from. The prefix is tested first so that the pattern is run over next to no lines
|
||||
// of a diff.
|
||||
func submodulePath(line string) string {
|
||||
if !strings.HasPrefix(line, submodulePrefix) {
|
||||
return ""
|
||||
}
|
||||
if match := submoduleSectionPattern.FindStringSubmatch(line); match != nil {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseFileSection parses one file's diff section (fileLines, starting at the line
|
||||
// that opens it) a single time and returns the identity of each of its
|
||||
// lines, indexed 1:1 with fileLines. patch.Parse's line indices line up with the
|
||||
// section's buffer lines, so the type and the old/new line numbers fall out of
|
||||
// the patch arithmetic. A submodule's section has no hunks at all, so every row of
|
||||
// it comes out as a header of the submodule, which is what they are: what git states
|
||||
// there is which commits it moved between, not lines of a file.
|
||||
//
|
||||
// Every line is left ok=false when the section has no
|
||||
// recoverable path or isn't a well-formed unified diff — the rendering
|
||||
// restructured it, and acting on a mis-parse would land us on the wrong line, so
|
||||
// the caller should fall back.
|
||||
//
|
||||
// endsTheBuffer says the section runs to the end of what we were given. That is
|
||||
// where a diff we have only part of breaks off. A long one is read a screenful
|
||||
// at a time and the rest as the user scrolls, so its last hunk holds fewer lines
|
||||
// than its header declares until the reading is done. Insisting on the whole
|
||||
// hunk there would leave every line of the file unresolved while the diff is the
|
||||
// one on screen, so a section in that position is held to what has arrived.
|
||||
func parseFileSection(fileLines []string, endsTheBuffer bool) []bufferLineParse {
|
||||
result := make([]bufferLineParse, len(fileLines))
|
||||
|
||||
relPath := pathFromDiffHeader(fileLines)
|
||||
if relPath == "" {
|
||||
return result
|
||||
}
|
||||
p := patch.Parse(strings.Join(fileLines, "\n"))
|
||||
isWellFormed := p.IsWellFormed
|
||||
if endsTheBuffer {
|
||||
isWellFormed = p.IsWellFormedSoFar
|
||||
}
|
||||
if !isWellFormed() {
|
||||
return result
|
||||
}
|
||||
patchLines := p.Lines()
|
||||
for i := range fileLines {
|
||||
if i >= len(patchLines) {
|
||||
break
|
||||
}
|
||||
parsed := parsedDiffLine{
|
||||
Path: relPath,
|
||||
Type: diffLineTypeForKind(patchLines[i].Kind),
|
||||
NewLine: p.LineNumberOfLine(i),
|
||||
}
|
||||
if parsed.Type == types.DiffLineDeleted {
|
||||
parsed.OldLine = p.OldLineNumberOfLine(i)
|
||||
}
|
||||
result[i] = bufferLineParse{parsed, true}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func diffLineTypeForKind(kind patch.PatchLineKind) types.DiffLineType {
|
||||
switch kind {
|
||||
case patch.PATCH_HEADER:
|
||||
return types.DiffLineFileHeader
|
||||
case patch.HUNK_HEADER:
|
||||
return types.DiffLineHunkHeader
|
||||
case patch.ADDITION:
|
||||
return types.DiffLineAdded
|
||||
case patch.DELETION:
|
||||
return types.DiffLineDeleted
|
||||
case patch.CONTEXT:
|
||||
return types.DiffLineContext
|
||||
default:
|
||||
return types.DiffLineOther
|
||||
}
|
||||
}
|
||||
|
||||
// pathFromDiffHeader extracts the new-file path of a single diff section. A
|
||||
// submodule's section states its path in the line it opens with. For a file of the
|
||||
// repo the path comes from the "+++ b/<path>" line, falling back to "--- a/<path>"
|
||||
// when the new path is /dev/null (a deleted file), and to the "diff --git" line when
|
||||
// there are no such lines at all (a pure rename, which has no hunks).
|
||||
func pathFromDiffHeader(fileLines []string) string {
|
||||
if path := submodulePath(fileLines[0]); path != "" {
|
||||
return path
|
||||
}
|
||||
|
||||
var oldPath, newPath string
|
||||
for _, line := range fileLines {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
break // past the header
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+++ "):
|
||||
newPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "+++ "))
|
||||
case strings.HasPrefix(line, "--- "):
|
||||
oldPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "--- "))
|
||||
}
|
||||
}
|
||||
|
||||
if newPath != "" && newPath != "/dev/null" {
|
||||
return newPath
|
||||
}
|
||||
if oldPath != "" && oldPath != "/dev/null" {
|
||||
return oldPath
|
||||
}
|
||||
return pathFromDiffGitLine(fileLines[0])
|
||||
}
|
||||
|
||||
// pathFromDiffHeaderField decodes one path field of a diff header — the part
|
||||
// after "--- " or "+++ ", or one of the two paths on the "diff --git" line —
|
||||
// into the repo-relative path it names.
|
||||
//
|
||||
// git spells such a field in three ways: plain; terminated by a tab, when the
|
||||
// path contains a space; or C-quoted as a whole, when the path contains
|
||||
// characters git won't print raw — which, with core.quotePath enabled (the
|
||||
// default), includes every non-ASCII byte, so `café` arrives as
|
||||
// `"b/caf\303\251"`. The quoting is Go's string syntax, octal escapes included,
|
||||
// so strconv decodes it for us.
|
||||
//
|
||||
// Returns "" for a quoted field we can't decode: better to resolve nothing than
|
||||
// to point a consumer at a path that doesn't exist.
|
||||
func pathFromDiffHeaderField(field string) string {
|
||||
field = strings.TrimSuffix(field, "\t")
|
||||
|
||||
if strings.HasPrefix(field, `"`) {
|
||||
unquoted, err := strconv.Unquote(field)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
field = unquoted
|
||||
}
|
||||
|
||||
return stripDiffPathPrefix(field)
|
||||
}
|
||||
|
||||
// stripDiffPathPrefix removes the a/ or b/ prefix git puts on the paths in a
|
||||
// diff header. We ask git for these prefixes explicitly (diff.noprefix=false),
|
||||
// so they are always there.
|
||||
func stripDiffPathPrefix(path string) string {
|
||||
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
|
||||
return path[2:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// parseDiffLineMetadata parses the payload of an OSC 1717 record, in which a
|
||||
// diff renderer states which line of which file it is rendering. The v1 payload
|
||||
// is positional and ';'-delimited:
|
||||
//
|
||||
// version;type;new-line;old-line;file
|
||||
//
|
||||
// The file comes last so that it may itself contain a ';'. The old-file line is
|
||||
// empty unless the line is a deletion, the only kind that needs it, and the
|
||||
// new-file line is empty on a file header, the one kind that has no line.
|
||||
//
|
||||
// ok is false for a payload of an unknown version or shape, so that the caller
|
||||
// can fall back to reading the rendered text.
|
||||
func parseDiffLineMetadata(payload string) (parsedDiffLine, bool) {
|
||||
fields := strings.SplitN(payload, ";", 5)
|
||||
if len(fields) < 5 || fields[0] != "1" {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
|
||||
lineType, ok := diffLineTypeFromMetadata(fields[1])
|
||||
if !ok {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
|
||||
newLine := 0
|
||||
if fields[2] != "" {
|
||||
var err error
|
||||
if newLine, err = strconv.Atoi(fields[2]); err != nil {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
} else if lineType != types.DiffLineFileHeader {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
|
||||
oldLine := 0
|
||||
if fields[3] != "" {
|
||||
var err error
|
||||
if oldLine, err = strconv.Atoi(fields[3]); err != nil {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
}
|
||||
|
||||
return parsedDiffLine{Path: fields[4], Type: lineType, NewLine: newLine, OldLine: oldLine}, true
|
||||
}
|
||||
|
||||
func diffLineTypeFromMetadata(typeField string) (types.DiffLineType, bool) {
|
||||
switch typeField {
|
||||
case "c":
|
||||
return types.DiffLineContext, true
|
||||
case "a":
|
||||
return types.DiffLineAdded, true
|
||||
case "d":
|
||||
return types.DiffLineDeleted, true
|
||||
case "f":
|
||||
return types.DiffLineFileHeader, true
|
||||
case "h":
|
||||
return types.DiffLineHunkHeader, true
|
||||
default:
|
||||
return types.DiffLineOther, false
|
||||
}
|
||||
}
|
||||
|
||||
// pathFromDiffGitLine extracts the new-file path from a "diff --git a/X b/X"
|
||||
// line, where the two paths are separated by a space and either may be quoted.
|
||||
// A path containing " b/" (or ` "b/`) would defeat this, but the +++/--- lines
|
||||
// are unambiguous and we only get here when they are absent.
|
||||
func pathFromDiffGitLine(line string) string {
|
||||
rest := strings.TrimPrefix(line, diffFilePrefix)
|
||||
if idx := strings.LastIndex(rest, ` "b/`); idx != -1 {
|
||||
return pathFromDiffHeaderField(rest[idx+1:])
|
||||
}
|
||||
if idx := strings.LastIndex(rest, " b/"); idx != -1 {
|
||||
return pathFromDiffHeaderField(rest[idx+1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// A two-file commit diff as it appears (decolorized) in the main view. file1 has
|
||||
// two consecutive deletions (grape, pear) that share a new-file line number;
|
||||
// file2 has two consecutive additions.
|
||||
const twoFileDiff = `diff --git a/file1.go b/file1.go
|
||||
index 1111111..2222222 100644
|
||||
--- a/file1.go
|
||||
+++ b/file1.go
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
-grape
|
||||
-pear
|
||||
lemon
|
||||
diff --git a/dir/file2.go b/dir/file2.go
|
||||
index 3333333..4444444 100644
|
||||
--- a/dir/file2.go
|
||||
+++ b/dir/file2.go
|
||||
@@ -10,2 +9,4 @@ func foo() {
|
||||
ctx
|
||||
+added1
|
||||
+added2
|
||||
ctx2`
|
||||
|
||||
func TestParseDiffLineFromBuffer(t *testing.T) {
|
||||
bufferLines := strings.Split(twoFileDiff, "\n")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
targetIdx int
|
||||
expected parsedDiffLine
|
||||
expectOk bool
|
||||
}{
|
||||
{"file header", 0, parsedDiffLine{Path: "file1.go", Type: types.DiffLineFileHeader, NewLine: 1}, true},
|
||||
{"hunk header", 4, parsedDiffLine{Path: "file1.go", Type: types.DiffLineHunkHeader, NewLine: 1}, true},
|
||||
{"context line", 5, parsedDiffLine{Path: "file1.go", Type: types.DiffLineContext, NewLine: 1}, true},
|
||||
// The two deletions share new-file line 2 but have distinct old-file lines.
|
||||
{"first deletion", 6, parsedDiffLine{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
|
||||
{"second deletion", 7, parsedDiffLine{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
|
||||
// The second file: its path comes from the second "diff --git" section,
|
||||
// and its additions get distinct new-file line numbers.
|
||||
{"first addition", 15, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, true},
|
||||
{"second addition", 16, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 11}, true},
|
||||
{"out of range", 999, parsedDiffLine{}, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result, ok := parseDiffLineFromBuffer(bufferLines, s.targetIdx)
|
||||
assert.Equal(t, s.expectOk, ok)
|
||||
if s.expectOk {
|
||||
assert.Equal(t, s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferRename(t *testing.T) {
|
||||
// A rename with no content change has no hunks and no +++/--- lines, so the
|
||||
// path has to come from the "diff --git" line; a rename with a content
|
||||
// change has them, and they carry the new path.
|
||||
pureRename := strings.Split(`diff --git a/old.go b/new.go
|
||||
similarity index 100%
|
||||
rename from old.go
|
||||
rename to new.go`, "\n")
|
||||
|
||||
result, ok := parseDiffLineFromBuffer(pureRename, 2)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result)
|
||||
|
||||
renameWithModification := strings.Split(`diff --git a/old.go b/new.go
|
||||
similarity index 62%
|
||||
rename from old.go
|
||||
rename to new.go
|
||||
index 1111111..2222222 100644
|
||||
--- a/old.go
|
||||
+++ b/new.go
|
||||
@@ -1,2 +1,2 @@
|
||||
apple
|
||||
-grape
|
||||
+kiwi`, "\n")
|
||||
|
||||
result, ok = parseDiffLineFromBuffer(renameWithModification, 10)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineAdded, NewLine: 2}, result)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferDeletedFile(t *testing.T) {
|
||||
// The new path is /dev/null, so the identity comes from the old path.
|
||||
deletedFile := strings.Split(`diff --git a/gone.go b/gone.go
|
||||
deleted file mode 100644
|
||||
index 1111111..0000000
|
||||
--- a/gone.go
|
||||
+++ /dev/null
|
||||
@@ -1,2 +0,0 @@
|
||||
-apple
|
||||
-grape`, "\n")
|
||||
|
||||
result, ok := parseDiffLineFromBuffer(deletedFile, 7)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "gone.go", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 2}, result)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferSubmodule(t *testing.T) {
|
||||
// A submodule has no "diff --git" header and no hunks: git opens its section with
|
||||
// the commits it moved between and lists them below. So the section ends the one
|
||||
// above it, and every row of it belongs to the submodule as a whole.
|
||||
withSubmodule := strings.Split(`diff --git a/file.txt b/file.txt
|
||||
index 1111111..2222222 100644
|
||||
--- a/file.txt
|
||||
+++ b/file.txt
|
||||
@@ -1,2 +1,3 @@
|
||||
hello
|
||||
world
|
||||
+world
|
||||
Submodule modules/xyz a32f27c..2d9f921:
|
||||
> bump the thing`, "\n")
|
||||
|
||||
for _, targetIdx := range []int{8, 9} {
|
||||
result, ok := parseDiffLineFromBuffer(withSubmodule, targetIdx)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t,
|
||||
parsedDiffLine{Path: "modules/xyz", Type: types.DiffLineFileHeader, NewLine: 1},
|
||||
result, "line %d", targetIdx)
|
||||
}
|
||||
|
||||
result, ok := parseDiffLineFromBuffer(withSubmodule, 7)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "file.txt", Type: types.DiffLineAdded, NewLine: 3}, result)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferSubmoduleInARendering(t *testing.T) {
|
||||
// A diff renderer passes the lines git writes for a submodule through as they
|
||||
// are, while printing nothing below them that a section could end at. The
|
||||
// section has to end where what git writes for the submodule ends all the same:
|
||||
// the rows below belong to the other files of the diff, and can only be placed
|
||||
// by the records the renderer states for them.
|
||||
rendered := strings.Split(`Submodule modules/xyz a32f27c..2d9f921:
|
||||
> bump the thing
|
||||
|
||||
products/a.txt
|
||||
|
||||
one
|
||||
two`, "\n")
|
||||
|
||||
all := parseAllDiffLinesFromBuffer(rendered)
|
||||
assert.Equal(t, "modules/xyz", all[0].parsed.Path)
|
||||
assert.Equal(t, "modules/xyz", all[1].parsed.Path)
|
||||
for i := 2; i < len(rendered); i++ {
|
||||
assert.False(t, all[i].ok, "line %d: %q", i, rendered[i])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmodulePath(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
line string
|
||||
expected string
|
||||
}{
|
||||
{"moved on", "Submodule modules/xyz a32f27c..2d9f921:", "modules/xyz"},
|
||||
{"moved back", "Submodule modules/xyz 2d9f921..a32f27c (rewind):", "modules/xyz"},
|
||||
{"moved sideways", "Submodule modules/xyz a32f27c...2d9f921:", "modules/xyz"},
|
||||
{"added", "Submodule modules/xyz 0000000...2d9f921 (new submodule)", "modules/xyz"},
|
||||
{"removed", "Submodule modules/xyz a32f27c...0000000 (submodule deleted)", "modules/xyz"},
|
||||
{"commits missing", "Submodule modules/xyz a32f27c...2d9f921 (commits not present)", "modules/xyz"},
|
||||
{"dirty", "Submodule modules/xyz contains modified content", "modules/xyz"},
|
||||
{"with something new in it", "Submodule modules/xyz contains untracked content", "modules/xyz"},
|
||||
// git writes the path unquoted, so one with a space in it, or one ending in
|
||||
// something that reads like a range of commits, is told apart by matching the
|
||||
// last range on the line.
|
||||
{"path with a space", "Submodule my modules/xyz a32f27c..2d9f921:", "my modules/xyz"},
|
||||
{"path reading like a range", "Submodule a32f27c..2d9f921 deadbee..fa1afe1:", "a32f27c..2d9f921"},
|
||||
// A line of a file that reads like one of these is indented by the column the
|
||||
// diff states the line's side in, so it cannot be mistaken for one.
|
||||
{"a line of a file", " Submodule modules/xyz contains modified content", ""},
|
||||
{"something else entirely", "Submodule support was added", ""},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, submodulePath(s.line))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferNotADiff(t *testing.T) {
|
||||
// A rendering with no "diff --git" line can't be parsed, so the caller falls
|
||||
// back rather than acting on the line.
|
||||
bufferLines := []string{"some", "lines", "that", "are not a diff"}
|
||||
_, ok := parseDiffLineFromBuffer(bufferLines, 2)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferGutterMangled(t *testing.T) {
|
||||
// A diff renderer that moves the line numbers into a gutter keeps the diff
|
||||
// and hunk headers but pushes the +/- markers off the start of each body
|
||||
// line, so every line reads as context. The body no longer matches the hunk
|
||||
// header, so we refuse to parse rather than return a confident mis-parse.
|
||||
mangled := strings.Split(`diff --git a/file1.txt b/file1.txt
|
||||
index 1111111..2222222 100644
|
||||
--- a/file1.txt
|
||||
+++ b/file1.txt
|
||||
@@ -1,5 +1,3 @@
|
||||
1 ⋮ 1 │ apple
|
||||
2 ⋮ │-grape
|
||||
3 ⋮ │-pear
|
||||
4 ⋮ 2 │ lemon
|
||||
5 ⋮ 3 │ mango`, "\n")
|
||||
|
||||
_, ok := parseDiffLineFromBuffer(mangled, 6)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferReadInPart(t *testing.T) {
|
||||
lines := strings.Split(twoFileDiff, "\n")
|
||||
|
||||
// A long diff is read a screenful at a time, so the buffer breaks off part way
|
||||
// through a hunk. The lines that did arrive are resolved all the same, since
|
||||
// holding out for the whole hunk would leave the diff on screen with nothing to
|
||||
// act on.
|
||||
cutShort := lines[:len(lines)-1]
|
||||
result, ok := parseDiffLineFromBuffer(cutShort, 15)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, result)
|
||||
|
||||
// Only the section the buffer breaks off in is read that way. One that another
|
||||
// section follows is all there, so a hunk short of what its header declares means
|
||||
// the rendering restructured the diff, and none of it is resolved.
|
||||
shortFirstSection := append(slices.Clone(lines[:8]), lines[9:]...)
|
||||
_, ok = parseDiffLineFromBuffer(shortFirstSection, 5)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestPathFromDiffHeaderField(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
field string
|
||||
expected string
|
||||
}{
|
||||
{"new side", "b/file.go", "file.go"},
|
||||
{"old side", "a/file.go", "file.go"},
|
||||
{"a missing file", "/dev/null", "/dev/null"},
|
||||
// git terminates the field with a tab when the path has a space in it.
|
||||
{"path with a space", "b/with space.go\t", "with space.go"},
|
||||
// With core.quotePath enabled (the default) every non-ASCII byte is
|
||||
// escaped, and the field is quoted as a whole, prefix included.
|
||||
{"non-ASCII path", `"b/caf\303\251.go"`, "café.go"},
|
||||
{"non-ASCII path with a space", "\"b/caf\\303\\251 x.go\"\t", "café x.go"},
|
||||
{"path with a double quote", `"b/we\"ird.go"`, `we"ird.go`},
|
||||
{"path with a backslash", `"b/back\\slash.go"`, `back\slash.go`},
|
||||
{"path with a tab", `"b/tab\there.go"`, "tab\there.go"},
|
||||
{"undecodable", `"b/unterminated`, ""},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, pathFromDiffHeaderField(s.field))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferQuotedPath(t *testing.T) {
|
||||
// A rename of a file whose name needs quoting, with a content change: the
|
||||
// path is quoted on the "diff --git" line and on both of the +++/--- lines.
|
||||
renamed := []string{
|
||||
`diff --git "a/caf\303\251 old.go" "b/caf\303\251 new.go"`,
|
||||
"similarity index 62%",
|
||||
`rename from "caf\303\251 old.go"`,
|
||||
`rename to "caf\303\251 new.go"`,
|
||||
"index 1111111..2222222 100644",
|
||||
"--- \"a/caf\\303\\251 old.go\"\t",
|
||||
"+++ \"b/caf\\303\\251 new.go\"\t",
|
||||
"@@ -1,2 +1,2 @@",
|
||||
" apple",
|
||||
"-grape",
|
||||
"+kiwi",
|
||||
}
|
||||
|
||||
result, ok := parseDiffLineFromBuffer(renamed, 10)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineAdded, NewLine: 2}, result)
|
||||
|
||||
// The same rename without a content change has no +++/--- lines, so the path
|
||||
// comes from the "diff --git" line, where both paths are quoted.
|
||||
result, ok = parseDiffLineFromBuffer(renamed[:4], 2)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result)
|
||||
}
|
||||
|
||||
func TestParseAllDiffLinesFromBuffer(t *testing.T) {
|
||||
// Some decoration above the diff, which belongs to no file section: a commit
|
||||
// message and a diffstat, as `git show` renders them.
|
||||
bufferLines := append(
|
||||
[]string{"commit 1234567", "", " do a thing", "", " file1.go | 2 --", ""},
|
||||
strings.Split(twoFileDiff, "\n")...,
|
||||
)
|
||||
|
||||
all := parseAllDiffLinesFromBuffer(bufferLines)
|
||||
|
||||
// The batch parse resolves each file section once, and has to agree with
|
||||
// resolving the lines one at a time.
|
||||
assert.Len(t, all, len(bufferLines))
|
||||
for i := range bufferLines {
|
||||
parsed, ok := parseDiffLineFromBuffer(bufferLines, i)
|
||||
assert.Equal(t, bufferLineParse{parsed, ok}, all[i], "line %d: %q", i, bufferLines[i])
|
||||
}
|
||||
|
||||
// The lines above the first file section are left unresolved.
|
||||
for i := range 6 {
|
||||
assert.False(t, all[i].ok)
|
||||
}
|
||||
assert.True(t, all[6].ok)
|
||||
}
|
||||
|
||||
func TestParseDiffLineMetadata(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
payload string
|
||||
expected parsedDiffLine
|
||||
expectOk bool
|
||||
}{
|
||||
{"context", "1;c;1;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineContext, NewLine: 1}, true},
|
||||
{"added", "1;a;3;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineAdded, NewLine: 3}, true},
|
||||
// A deletion carries both numbers; two consecutive deletions share the
|
||||
// new-file line and differ only in the old-file one.
|
||||
{"first deletion", "1;d;2;2;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
|
||||
{"second deletion", "1;d;2;3;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
|
||||
// A whole-file deletion has new-file position 0 and the old path.
|
||||
{"deleted file", "1;d;0;1;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 1}, true},
|
||||
// The path is the last field, so a ';' within it survives.
|
||||
{"path with semicolon", "1;c;5;;weird;name.txt", parsedDiffLine{Path: "weird;name.txt", Type: types.DiffLineContext, NewLine: 5}, true},
|
||||
// A renderer may state the path absolutely; the parser keeps it verbatim
|
||||
// and leaves resolving it to the caller.
|
||||
{"absolute path", "1;a;7;;/abs/foo.txt", parsedDiffLine{Path: "/abs/foo.txt", Type: types.DiffLineAdded, NewLine: 7}, true},
|
||||
// A file header has no line number; a hunk header carries the new-file
|
||||
// line of the hunk's first line (0 for a whole-file deletion, mirroring
|
||||
// `@@ -1,N +0,0 @@`).
|
||||
{"file header", "1;f;;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineFileHeader}, true},
|
||||
{"hunk header", "1;h;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineHunkHeader, NewLine: 10}, true},
|
||||
{"hunk header of a deleted file", "1;h;0;;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineHunkHeader, NewLine: 0}, true},
|
||||
// A file header's line number is always empty, but a renderer that fills
|
||||
// it in anyway is taken at its word rather than rejected.
|
||||
{"file header with a line number", "1;f;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineFileHeader, NewLine: 10}, true},
|
||||
|
||||
{"unknown version", "2;c;1;;foo.txt", parsedDiffLine{}, false},
|
||||
{"unknown type", "1;x;1;;foo.txt", parsedDiffLine{}, false},
|
||||
{"too few fields", "1;c;1", parsedDiffLine{}, false},
|
||||
{"non-numeric new-line", "1;c;x;;foo.txt", parsedDiffLine{}, false},
|
||||
{"non-numeric old-line", "1;d;2;y;foo.txt", parsedDiffLine{}, false},
|
||||
// Only a file header may omit the new-file line; on any other kind the
|
||||
// record is malformed, and rejecting it falls the row back to the diff
|
||||
// text rather than acting on a line number we don't have.
|
||||
{"empty new-line on a content line", "1;c;;;foo.txt", parsedDiffLine{}, false},
|
||||
{"empty new-line on a hunk header", "1;h;;;foo.txt", parsedDiffLine{}, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result, ok := parseDiffLineMetadata(s.payload)
|
||||
assert.Equal(t, s.expectOk, ok)
|
||||
if s.expectOk {
|
||||
assert.Equal(t, s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderingStatesDiffLines(t *testing.T) {
|
||||
row := func(text string, records ...string) gocui.DiffLineContent {
|
||||
return gocui.DiffLineContent{Text: text, Metadata: records}
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
contents []gocui.DiffLineContent
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "a rendering without records is read as a diff",
|
||||
contents: []gocui.DiffLineContent{row("diff --git a/foo.txt b/foo.txt"), row("+one")},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "a record on any row makes the records the source",
|
||||
contents: []gocui.DiffLineContent{row("foo.txt"), row("one", "1;c;1;;foo.txt"), row("")},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
// A renderer announces the protocol with a record that names no line; a
|
||||
// rendering with nothing but that one says nothing about its rows.
|
||||
name: "the version-only handshake record doesn't count",
|
||||
contents: []gocui.DiffLineContent{row("foo.txt", "1"), row("one")},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "records of a version we don't understand don't count",
|
||||
contents: []gocui.DiffLineContent{row("one", "2;c;1;;foo.txt")},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "an empty rendering states nothing",
|
||||
contents: nil,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, renderingStatesDiffLines(s.contents))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiffLineIdentities(t *testing.T) {
|
||||
row := func(text string, records ...string) gocui.DiffLineContent {
|
||||
return gocui.DiffLineContent{Text: text, Metadata: records}
|
||||
}
|
||||
|
||||
t.Run("a rendering with records is read by them alone", func(t *testing.T) {
|
||||
// A renderer's picture of a commit that adds a test whose input is a diff. The
|
||||
// test's "diff --git" line is an added line of the test's file and is shown on
|
||||
// a row of its own, and the rows the renderer puts between hunks carry no
|
||||
// record. Parsed as a diff, that row would open a section of a file the diff
|
||||
// doesn't have and claim every untagged row below it.
|
||||
contents := []gocui.DiffLineContent{
|
||||
row("src/parser.rs", "1;f;;;src/parser.rs"),
|
||||
row(`let input = "\`, "1;c;10;;src/parser.rs"),
|
||||
row("diff --git a/img.png b/img.png", "1;a;11;;src/parser.rs"),
|
||||
row("Binary files a/img.png and b/img.png differ", "1;a;12;;src/parser.rs"),
|
||||
row(""),
|
||||
row("fn later() {}", "1;c;40;;src/parser.rs"),
|
||||
}
|
||||
|
||||
assert.Equal(t, [][]parsedDiffLine{
|
||||
{{Path: "src/parser.rs", Type: types.DiffLineFileHeader}},
|
||||
{{Path: "src/parser.rs", Type: types.DiffLineContext, NewLine: 10}},
|
||||
{{Path: "src/parser.rs", Type: types.DiffLineAdded, NewLine: 11}},
|
||||
{{Path: "src/parser.rs", Type: types.DiffLineAdded, NewLine: 12}},
|
||||
nil,
|
||||
{{Path: "src/parser.rs", Type: types.DiffLineContext, NewLine: 40}},
|
||||
}, parseDiffLineIdentities(contents))
|
||||
})
|
||||
|
||||
t.Run("a row with two records shows both of their lines", func(t *testing.T) {
|
||||
contents := []gocui.DiffLineContent{
|
||||
row("two │ TWO", "1;d;2;2;file1", "1;a;2;;file1"),
|
||||
}
|
||||
|
||||
assert.Equal(t, [][]parsedDiffLine{
|
||||
{
|
||||
{Path: "file1", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2},
|
||||
{Path: "file1", Type: types.DiffLineAdded, NewLine: 2},
|
||||
},
|
||||
}, parseDiffLineIdentities(contents))
|
||||
})
|
||||
|
||||
t.Run("a rendering without records is parsed as a diff", func(t *testing.T) {
|
||||
bufferLines := strings.Split(twoFileDiff, "\n")
|
||||
contents := make([]gocui.DiffLineContent, len(bufferLines))
|
||||
for i, line := range bufferLines {
|
||||
contents[i] = row(line)
|
||||
}
|
||||
|
||||
identities := parseDiffLineIdentities(contents)
|
||||
for i, parsed := range parseAllDiffLinesFromBuffer(bufferLines) {
|
||||
assert.True(t, parsed.ok, "line %d", i)
|
||||
assert.Equal(t, []parsedDiffLine{parsed.parsed}, identities[i], "line %d", i)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// The questions a diff view can be asked about what it is showing — where the change
|
||||
// lines are, which block or file a row belongs to — answered in the view-line terms a
|
||||
// cursor and a click speak. They are all built on the identities recovered in
|
||||
// diff_line_helper.go, which is where the answering stops and the recovering starts.
|
||||
|
||||
// changeLines resolves view's rendered diff to one flag per buffer line: whether
|
||||
// that row is a change line (an addition or a deletion), as opposed to context, a
|
||||
// header, or a row whose identity couldn't be recovered. Those are the rows a
|
||||
// selection is anchored on and navigation moves between.
|
||||
func (self *DiffLineHelper) changeLines(view *gocui.View) []bool {
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
isChange := make([]bool, len(resolved))
|
||||
for i, r := range resolved {
|
||||
isChange[i] = r.ok && r.info.IsChange()
|
||||
}
|
||||
return isChange
|
||||
}
|
||||
|
||||
// FirstChangeLineInView returns the view line of the first change line on screen. It
|
||||
// is where the selection goes when the main view is focused by keyboard: focusing a
|
||||
// diff you are reading points at something in it without moving it, so the search
|
||||
// stops at the bottom of the viewport rather than going after a change further down.
|
||||
// ok is false when the viewport holds no change line — scrolled into a long stretch
|
||||
// of context, or past the last change.
|
||||
func (self *DiffLineHelper) FirstChangeLineInView(view *gocui.View) (int, bool) {
|
||||
top, bottom, ok := visibleBufferLines(view)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
isChange := self.changeLines(view)
|
||||
for i := top; i <= min(bottom, len(isChange)-1); i++ {
|
||||
if isChange[i] {
|
||||
return view.ViewLineForBufferLine(i)
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// FirstChangeBlockInView returns the view line of the first change block on screen:
|
||||
// the first one that *begins* in the viewport, and failing that the one that reaches
|
||||
// into the viewport from above, whose start is off screen. Hunk mode wants that order
|
||||
// for the block it offers up on focus: preferably a block whose beginning the user can
|
||||
// see, rather than the tail of one they have scrolled past the start of. The block
|
||||
// bleeding in from above is kept as the answer for a change too long to fit on screen,
|
||||
// where there is no other. ok is false when the viewport shows no change line.
|
||||
func (self *DiffLineHelper) FirstChangeBlockInView(view *gocui.View) (int, bool) {
|
||||
top, bottom, ok := visibleBufferLines(view)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
isChange := self.changeLines(view)
|
||||
for i := top; i <= min(bottom, len(isChange)-1); i++ {
|
||||
if isChange[i] && (i == 0 || !isChange[i-1]) {
|
||||
return view.ViewLineForBufferLine(i)
|
||||
}
|
||||
}
|
||||
// A block covering the top line is one that began above it: nothing else can put a
|
||||
// change there once no block starts on screen.
|
||||
if top < len(isChange) && isChange[top] {
|
||||
return view.ViewLineForBufferLine(top)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// visibleBufferLines returns the first and last line of view's content that the
|
||||
// viewport shows any part of, for the queries that only care about what the user can
|
||||
// see. The last line is the one at the bottom edge, or the content's last when the
|
||||
// content ends above it. ok is false for a view showing no content at all.
|
||||
func visibleBufferLines(view *gocui.View) (int, int, bool) {
|
||||
top, ok := view.BufferLineForViewLine(view.OriginY())
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
lastVisible := min(view.OriginY()+view.InnerHeight(), view.ViewLinesHeight()) - 1
|
||||
bottom, ok := view.BufferLineForViewLine(lastVisible)
|
||||
if !ok {
|
||||
return top, top, true
|
||||
}
|
||||
return top, bottom, true
|
||||
}
|
||||
|
||||
// ViewHasChangeLines reports whether view's rendered diff holds any change line at
|
||||
// all, i.e. whether there is anything to select. It is false over a non-diff
|
||||
// placeholder, and over a diff with nothing in it — an empty commit, a binary file —
|
||||
// which are the cases where the focused main view shows no selection.
|
||||
func (self *DiffLineHelper) ViewHasChangeLines(view *gocui.View) bool {
|
||||
return lo.Contains(self.changeLines(view), true)
|
||||
}
|
||||
|
||||
// IsChangeLine reports whether the given view line of view's rendered diff is a
|
||||
// change line rather than context, a header, or an unresolvable row — i.e. whether
|
||||
// pointing at it points at something a patch could be built from.
|
||||
func (self *DiffLineHelper) IsChangeLine(view *gocui.View, viewLineIdx int) bool {
|
||||
info, ok := self.GetDiffLineInfo(view, viewLineIdx)
|
||||
return ok && info.IsChange()
|
||||
}
|
||||
|
||||
// IsSingleHunkForWholeFile reports whether the file the given change line belongs to
|
||||
// is shown as one solid block of changes — every row of its diff a change of the same
|
||||
// kind, no context — which is what a newly added or deleted file looks like. That is
|
||||
// the case where widening the selection to the change block would select the file
|
||||
// entire, so hunk mode drops to a single line there instead. It asks of a rendered
|
||||
// diff the question patch.Patch.IsSingleHunkForWholeFile asks of a patch.
|
||||
//
|
||||
// It says false while the diff is still being read in, since the rows that would
|
||||
// answer otherwise — a context line, a change of the other kind — may not have
|
||||
// arrived yet. That errs towards hunk mode, which is what the user asked for.
|
||||
func (self *DiffLineHelper) IsSingleHunkForWholeFile(view *gocui.View, changeViewLine int) bool {
|
||||
if manager := self.c.GetViewBufferManagerForView(view); manager != nil && manager.IsLoading() {
|
||||
return false
|
||||
}
|
||||
|
||||
anchor, ok := view.BufferLineForViewLine(changeViewLine)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
if anchor >= len(resolved) || !resolved[anchor].ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// The question is per file: a commit's diff may hold a newly added file next to an
|
||||
// edited one.
|
||||
path := resolved[anchor].info.Path
|
||||
kind := resolved[anchor].info.Type
|
||||
for _, row := range resolved {
|
||||
if !row.ok || row.info.Path != path {
|
||||
continue
|
||||
}
|
||||
if row.info.Type == types.DiffLineContext {
|
||||
return false
|
||||
}
|
||||
if row.info.IsChange() && row.info.Type != kind {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ChangeBlockBounds returns the inclusive view-line range of the change block to
|
||||
// select in hunk mode around anchorViewLine. A change block is lazygit's notion of a
|
||||
// hunk — a run of consecutive added or deleted lines bounded by context, of which a
|
||||
// single git @@ hunk may hold several. When the anchor is context, the block used is
|
||||
// the first at or below it, or — with nothing below, the cursor sitting past the last
|
||||
// change — the nearest above, so that hunk mode always has a block to select. ok is
|
||||
// false only when the diff holds no change line at all.
|
||||
func (self *DiffLineHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine int) (int, int, bool) {
|
||||
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
isChange := self.changeLines(view)
|
||||
start := anchor
|
||||
for start < len(isChange) && !isChange[start] {
|
||||
start++
|
||||
}
|
||||
if start >= len(isChange) {
|
||||
for start = min(anchor, len(isChange)-1); start >= 0 && !isChange[start]; start-- {
|
||||
}
|
||||
if start < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
end := start
|
||||
for start > 0 && isChange[start-1] {
|
||||
start--
|
||||
}
|
||||
for end < len(isChange)-1 && isChange[end+1] {
|
||||
end++
|
||||
}
|
||||
|
||||
startView, startOk := view.ViewLineForBufferLine(start)
|
||||
// The block's last line goes to its last view line, so that a line the view
|
||||
// wrapped is highlighted to its end rather than only where it begins.
|
||||
endView, endOk := view.LastViewLineForBufferLine(end)
|
||||
if !startOk || !endOk {
|
||||
return 0, 0, false
|
||||
}
|
||||
return startView, endView, true
|
||||
}
|
||||
|
||||
// SelectedHunkBounds returns the change block selected in hunk mode. The range
|
||||
// anchor stays on the block's far end when a click moves the cursor before its
|
||||
// handler runs, so it still identifies the selected block.
|
||||
func (self *DiffLineHelper) SelectedHunkBounds(view *gocui.View) (int, int, bool) {
|
||||
anchor := view.RangeSelectStartY()
|
||||
if anchor < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return self.ChangeBlockBounds(view, anchor)
|
||||
}
|
||||
|
||||
// AdjacentChangeBlock returns the view line to move to for next/previous change-block
|
||||
// navigation in view's rendered diff, starting from anchorViewLine. A change block is
|
||||
// lazygit's notion of a hunk (see ChangeBlockBounds). forward=true targets the start
|
||||
// of the next block, forward=false the start of the previous one — from mid-block that
|
||||
// means the previous block, rather than the one we are in. ok is false when there's no
|
||||
// further block, so the caller leaves the view where it is.
|
||||
func (self *DiffLineHelper) AdjacentChangeBlock(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
|
||||
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
target, ok := changeBlockStart(self.changeLines(view), anchor, forward)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return view.ViewLineForBufferLine(target)
|
||||
}
|
||||
|
||||
// AdjacentFile returns the view line to move to for next/previous file navigation in
|
||||
// view's (possibly multi-file) rendered diff, starting from anchorViewLine: the first
|
||||
// located row of the neighbouring file, found where the rows' file changes. ok is
|
||||
// false at the first or last file.
|
||||
func (self *DiffLineHelper) AdjacentFile(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
|
||||
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
target, ok := fileStart(self.filePaths(view), anchor, forward)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return view.ViewLineForBufferLine(target)
|
||||
}
|
||||
|
||||
// filePaths resolves view's rendered diff to the path each buffer line belongs to,
|
||||
// empty for a row whose identity couldn't be recovered.
|
||||
func (self *DiffLineHelper) filePaths(view *gocui.View) []string {
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
paths := make([]string, len(resolved))
|
||||
for i, row := range resolved {
|
||||
if row.ok {
|
||||
paths[i] = row.info.Path
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// fileStart finds, in a diff whose lines carry the file path they belong to (empty for
|
||||
// a row no backend could place), the first located row of the file adjacent to `from`
|
||||
// in the given direction — the row file navigation lands on. It is the pure index
|
||||
// arithmetic behind AdjacentFile.
|
||||
//
|
||||
// A file is identified by its path, so we look for where the path changes, skipping
|
||||
// rows that carry none: those are the blank separator rows between files, or the
|
||||
// header rows of a diff renderer that doesn't state which file its headers belong to.
|
||||
// So the landing row is the file's header wherever the source says so — a parseable
|
||||
// buffer, or a renderer that tags its headers — and the file's first content line
|
||||
// otherwise, which is an accepted degradation.
|
||||
func fileStart(paths []string, from int, forward bool) (int, bool) {
|
||||
anchorPath, ok := anchorFilePath(paths, from)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if forward {
|
||||
for i := from; i < len(paths); i++ {
|
||||
if paths[i] != "" && paths[i] != anchorPath {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Walk back past the current file (its rows and any unlocated ones) to the previous
|
||||
// file's last located row, then back over that whole file, landing on its first.
|
||||
i := from
|
||||
for i >= 0 && (paths[i] == "" || paths[i] == anchorPath) {
|
||||
i--
|
||||
}
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
prevPath := paths[i]
|
||||
for i > 0 && (paths[i-1] == "" || paths[i-1] == prevPath) {
|
||||
i--
|
||||
}
|
||||
for paths[i] != prevPath {
|
||||
i++
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
// anchorFilePath returns the path of the file the anchor sits in: the first row at or
|
||||
// below it that carries a path — the file whose content is at or below the top of the
|
||||
// view — falling back to the nearest above when there is nothing below. Scanning down
|
||||
// first matters because the anchor is often a file-header row that carries no path of
|
||||
// its own, whose nearest tagged row above is the *previous* file's content; taking
|
||||
// that would make next-file navigation jump back into the file just left, so a second
|
||||
// press wouldn't advance. ok is false when no row carries a path.
|
||||
func anchorFilePath(paths []string, from int) (string, bool) {
|
||||
if from < 0 {
|
||||
return "", false
|
||||
}
|
||||
for i := from; i < len(paths); i++ {
|
||||
if paths[i] != "" {
|
||||
return paths[i], true
|
||||
}
|
||||
}
|
||||
for i := min(from, len(paths)) - 1; i >= 0; i-- {
|
||||
if paths[i] != "" {
|
||||
return paths[i], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// changeBlockStart finds, in a diff whose lines are flagged by isChange, the first
|
||||
// line of the change block adjacent to `from` in the given direction. It is the pure
|
||||
// index arithmetic behind AdjacentChangeBlock.
|
||||
func changeBlockStart(isChange []bool, from int, forward bool) (int, bool) {
|
||||
if from < 0 || from >= len(isChange) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if forward {
|
||||
i := from
|
||||
for i < len(isChange) && isChange[i] { // leave the current block
|
||||
i++
|
||||
}
|
||||
for i < len(isChange) && !isChange[i] { // skip the separating context
|
||||
i++
|
||||
}
|
||||
if i == len(isChange) {
|
||||
return 0, false
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
i := from
|
||||
for i >= 0 && isChange[i] { // leave the current block
|
||||
i--
|
||||
}
|
||||
for i >= 0 && !isChange[i] { // skip context, landing on the previous block's last line
|
||||
i--
|
||||
}
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
for i > 0 && isChange[i-1] { // walk back to that block's first line
|
||||
i--
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestChangeBlockStart(t *testing.T) {
|
||||
// A diff with three change blocks separated by context:
|
||||
// 0 file header 1 hunk header 2 context
|
||||
// 3 + 4 + (block A)
|
||||
// 5 context
|
||||
// 6 - (block B)
|
||||
// 7 context
|
||||
// 8 + (block C)
|
||||
isChange := []bool{false, false, false, true, true, false, true, false, true}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
from int
|
||||
forward bool
|
||||
expected int
|
||||
found bool
|
||||
}{
|
||||
{"forward from a header lands on the first block", 0, true, 3, true},
|
||||
{"forward from separating context lands on the next block", 5, true, 6, true},
|
||||
{"forward from the start of a block skips to the next", 3, true, 6, true},
|
||||
{"forward from inside a block skips the rest of it", 4, true, 6, true},
|
||||
{"forward from the last block finds nothing", 8, true, 0, false},
|
||||
{"backward from a later block lands on the previous one's start", 8, false, 6, true},
|
||||
{"backward from a block start lands on the previous block's start", 6, false, 3, true},
|
||||
{"backward from inside the first block finds nothing", 4, false, 0, false},
|
||||
{"backward from the first block's start finds nothing", 3, false, 0, false},
|
||||
{"backward from context lands on the preceding block's start", 7, false, 6, true},
|
||||
{"an anchor past the end finds nothing", 9, true, 0, false},
|
||||
{"a negative anchor finds nothing", -1, true, 0, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
got, found := changeBlockStart(isChange, s.from, s.forward)
|
||||
assert.Equal(t, s.found, found)
|
||||
if s.found {
|
||||
assert.Equal(t, s.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStart(t *testing.T) {
|
||||
// A parseable two-file diff: every row carries its file's path, headers included,
|
||||
// as the buffer parser reports it.
|
||||
parseable := []string{"a", "a", "a", "a", "b", "b", "b", "b"}
|
||||
|
||||
// The same diff as a renderer that doesn't say which file its headers belong to
|
||||
// emits it: only content lines carry the path, so navigation can land no higher
|
||||
// than each file's first content line.
|
||||
contentOnly := []string{"", "", "a", "a", "", "", "b", "b"}
|
||||
|
||||
// Three such files, to exercise navigating from one file's untagged header to the
|
||||
// next: the row just above b's header is a's content, so the anchor's file has to
|
||||
// be found by scanning down (b) rather than up (a) — otherwise next-file would
|
||||
// jump back into b and a second press couldn't advance.
|
||||
contentOnlyThree := []string{"", "", "a", "a", "", "", "b", "b", "", "", "c", "c"}
|
||||
|
||||
// A renderer that does tag its header rows: the file header and the hunk-header box
|
||||
// carry the file's path, but the blank separator rows around them carry nothing.
|
||||
// Navigation must land on the header's first row, not the blank line above it.
|
||||
// 0 blank 1-2 file hdr 3 blank 4-6 hunk hdr box 7 content
|
||||
// 8 blank 9-10 file hdr 11 blank 12-13 hunk hdr box 14 content
|
||||
headerTagged := []string{"", "a", "a", "", "a", "a", "a", "a", "", "b", "b", "", "b", "b", "b"}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
paths []string
|
||||
from int
|
||||
forward bool
|
||||
expected int
|
||||
found bool
|
||||
}{
|
||||
{"forward lands on the next file's header", parseable, 1, true, 4, true},
|
||||
{"forward from the last file finds nothing", parseable, 5, true, 0, false},
|
||||
{"backward lands on the previous file's header", parseable, 5, false, 0, true},
|
||||
{"backward from the first file finds nothing", parseable, 1, false, 0, false},
|
||||
{"forward lands on the next file's first content line", contentOnly, 2, true, 6, true},
|
||||
{"backward lands on the previous file's first content line", contentOnly, 7, false, 2, true},
|
||||
{"forward from an untagged header advances past it", contentOnly, 0, true, 6, true},
|
||||
{"a second forward press advances again", contentOnlyThree, 4, true, 10, true},
|
||||
{"forward lands on a tagged file header", headerTagged, 7, true, 9, true},
|
||||
{"backward lands on a tagged file header", headerTagged, 14, false, 1, true},
|
||||
{"a diff with no located rows finds nothing", []string{"", ""}, 0, true, 0, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
got, found := fileStart(s.paths, s.from, s.forward)
|
||||
assert.Equal(t, s.found, found)
|
||||
if s.found {
|
||||
assert.Equal(t, s.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/tasks"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// Keeping a diff view where it is when the same diff is rendered again differently.
|
||||
// The line the user is on is remembered by identity (diff_line_helper.go), because a
|
||||
// new rendering puts it on a different line of the view — and may not have it at all,
|
||||
// which is what the fallbacks below are for.
|
||||
|
||||
// diffLineAnchor is a line for a restore to land on: the identity to find it by in
|
||||
// the new rendering, and the screen row it was on, so that it can be put back there.
|
||||
type diffLineAnchor struct {
|
||||
identity types.DiffLineInfo
|
||||
row int
|
||||
}
|
||||
|
||||
// PreserveDiffPositionOnRerender remembers where a diff view is and puts it back
|
||||
// there as it next re-renders, instead of leaving the user at the top of a new
|
||||
// rendering of the diff they were already reading. Call it on the view about to be
|
||||
// re-rendered, right before triggering the re-render — on both panes of the main
|
||||
// window where both are being rendered again, since either of them may hold the diff
|
||||
// being read; a pane that isn't showing is left alone.
|
||||
//
|
||||
// The line to keep is the end of the selection that is on screen, and the middle
|
||||
// visible line when there is no selection or the whole of it has been scrolled out of
|
||||
// sight — what the user is looking at, rather than the view's top edge or a selection
|
||||
// they have long since left behind. It may not survive the re-render: a context line
|
||||
// goes when the context size shrinks, and a whole hunk or file goes when whitespace
|
||||
// stops counting. So the lines around it come along as fallbacks and the view lands on
|
||||
// the nearest one that is still there, put back on the screen row it was on. With none
|
||||
// of them left — and with a renderer that says nothing about its rows there is nothing
|
||||
// to look for in the first place — the view keeps the scroll offset it had, which is
|
||||
// still nearer to what was being read than the top of the diff.
|
||||
//
|
||||
// An off-screen selection is still put back on the diff line it was on, wherever the
|
||||
// new rendering has that; it is only the view that stays where it is.
|
||||
//
|
||||
// A range or hunk selection has a second end, which is remembered the same way, so
|
||||
// that it still covers the same lines of the diff afterwards.
|
||||
func (self *DiffLineHelper) PreserveDiffPositionOnRerender(view *gocui.View) {
|
||||
// A view that isn't the one its window is currently showing — the merge-conflicts
|
||||
// view takes the main window over — isn't the one about to be re-rendered, so a
|
||||
// restore installed on it would sit there and claim a later render instead.
|
||||
if !view.Visible {
|
||||
return
|
||||
}
|
||||
|
||||
// The re-render is produced by a different command from the one behind what is on
|
||||
// screen — another context size, another renderer — so without being told otherwise
|
||||
// it would be taken for content the user has never seen and shown from the top.
|
||||
// Whether or not a line of the old rendering can be found in the new one, the offset
|
||||
// into it is nearer to where they were reading than the top is.
|
||||
if manager := self.c.GetViewBufferManagerForView(view); manager != nil {
|
||||
manager.SetKeepScrollPositionForNextTask()
|
||||
}
|
||||
|
||||
showSelection := view.Highlight
|
||||
anchorViewLine := view.MiddleVisibleLineIdx()
|
||||
farEnd, hasFarEnd := types.DiffLineInfo{}, false
|
||||
// A cursor that has been scrolled away from is put back by its own lines rather
|
||||
// than by the anchor's, so that it comes out on the same line of the diff without
|
||||
// the view having to go there.
|
||||
var cursorCandidates []diffLineAnchor
|
||||
if showSelection {
|
||||
farEnd, hasFarEnd = self.selectionFarEndIdentity(view)
|
||||
if end, ok := visibleSelectionEnd(view); ok {
|
||||
anchorViewLine = end
|
||||
}
|
||||
if anchorViewLine != view.SelectedLineIdx() {
|
||||
cursorCandidates = self.nearbyDiffLines(view, view.SelectedLineIdx())
|
||||
}
|
||||
}
|
||||
|
||||
self.restoreDiffLinePositionOnRerender(view, self.nearbyDiffLines(view, anchorViewLine),
|
||||
func(anchor diffLineAnchor, viewLine int) {
|
||||
// Put the line back on the screen row it was on, clamped into the view for
|
||||
// the fallback lines, which can come from off screen.
|
||||
row := lo.Clamp(anchor.row, 0, max(0, view.InnerHeight()-1))
|
||||
view.SetOrigin(0, max(0, viewLine-row))
|
||||
if showSelection {
|
||||
// Put the far end back before the cursor, so that the selection covers
|
||||
// the same lines again; a selection whose far end didn't survive the
|
||||
// re-render is left as the single line we landed on. The origin is
|
||||
// already where it should be, so moving the cursor mustn't scroll.
|
||||
view.CancelRangeSelect()
|
||||
cursorViewLine := self.selectionLine(view, cursorCandidates, viewLine)
|
||||
if hasFarEnd {
|
||||
if farEndViewLine, ok := self.findDiffLine(view, farEnd); ok {
|
||||
cursorViewLine, farEndViewLine = coverWholeLines(view, cursorViewLine, farEndViewLine)
|
||||
view.SetRangeSelectStart(farEndViewLine)
|
||||
}
|
||||
}
|
||||
view.FocusPoint(0, cursorViewLine, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// coverWholeLines moves the two ends of a restored selection out to the edges of the
|
||||
// diff lines they are on, so that the selection covers those lines whole. Both ends
|
||||
// arrive on the first view line of their diff line, which is where looking one up by
|
||||
// identity lands, and the view draws a line it wraps as several — of which a
|
||||
// selection of that line means all.
|
||||
func coverWholeLines(view *gocui.View, cursorViewLine int, farEndViewLine int) (int, int) {
|
||||
if cursorViewLine <= farEndViewLine {
|
||||
return cursorViewLine, lastViewLineOfSameDiffLine(view, farEndViewLine)
|
||||
}
|
||||
return lastViewLineOfSameDiffLine(view, cursorViewLine), farEndViewLine
|
||||
}
|
||||
|
||||
// lastViewLineOfSameDiffLine returns the last view line showing the same line of the
|
||||
// diff as the given one, which is that line itself unless the view wrapped it.
|
||||
func lastViewLineOfSameDiffLine(view *gocui.View, viewLine int) int {
|
||||
bufferLine, ok := view.BufferLineForViewLine(viewLine)
|
||||
if !ok {
|
||||
return viewLine
|
||||
}
|
||||
if last, ok := view.LastViewLineForBufferLine(bufferLine); ok {
|
||||
return last
|
||||
}
|
||||
return viewLine
|
||||
}
|
||||
|
||||
// visibleSelectionEnd returns the end of the selection to keep in place across a
|
||||
// re-render: the selected line when it is on screen, and the range's other end when
|
||||
// that is and the selected line isn't — a range can be long enough for the user to be
|
||||
// looking at one end of it with the other far away. ok is false when the whole
|
||||
// selection is off screen, and there is nothing of it to keep in place.
|
||||
func visibleSelectionEnd(view *gocui.View) (int, bool) {
|
||||
if view.IsLineVisible(view.SelectedLineIdx()) {
|
||||
return view.SelectedLineIdx(), true
|
||||
}
|
||||
if farEnd, _, ok := selectionFarEndViewLine(view); ok && view.IsLineVisible(farEnd) {
|
||||
return farEnd, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// selectionLine returns the line to put the cursor on once a re-render is on screen:
|
||||
// the line the position anchor landed on, which is the selected one whenever it was
|
||||
// on screen, and otherwise the nearest surviving line to where the selection was —
|
||||
// found among its own candidates, since the anchor's are a search of the diff from
|
||||
// somewhere else entirely.
|
||||
func (self *DiffLineHelper) selectionLine(
|
||||
view *gocui.View, candidates []diffLineAnchor, anchorViewLine int,
|
||||
) int {
|
||||
if len(candidates) == 0 {
|
||||
return anchorViewLine
|
||||
}
|
||||
_, bufferLine := self.nearestSurvivingCandidate(view.DiffLineContents(), candidates)
|
||||
if bufferLine == -1 {
|
||||
return anchorViewLine
|
||||
}
|
||||
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
|
||||
return viewLine
|
||||
}
|
||||
return anchorViewLine
|
||||
}
|
||||
|
||||
// selectionFarEndIdentity returns the identity of the end of a range or hunk
|
||||
// selection the cursor isn't on, so that a re-render can put it back. ok is false for
|
||||
// a selection that is only a cursor, where restoring that is the whole job, and for
|
||||
// an end that resolves to no diff line.
|
||||
//
|
||||
// An end covers the whole of its row, so where the row shows more than one diff line
|
||||
// — a rendering that puts a modification's two halves side by side, or a word diff
|
||||
// that puts both on the one line it changed — the end takes the outermost of them:
|
||||
// the last for the range's lower end and the first for its upper one. Otherwise a
|
||||
// rendering that splits them apart again would get back only the half the row led
|
||||
// with, and half a change selected where a whole one was.
|
||||
func (self *DiffLineHelper) selectionFarEndIdentity(view *gocui.View) (types.DiffLineInfo, bool) {
|
||||
farEnd, isLowerEnd, ok := selectionFarEndViewLine(view)
|
||||
if !ok {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
identities, ok := self.diffLineIdentitiesAt(view, farEnd)
|
||||
if !ok {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
if isLowerEnd {
|
||||
return identities[len(identities)-1], true
|
||||
}
|
||||
return identities[0], true
|
||||
}
|
||||
|
||||
// selectionFarEndViewLine returns the view line of the end of a range or hunk
|
||||
// selection the cursor isn't on, and whether that is the lower of the two ends. ok
|
||||
// is false when there is no range at all, only a cursor.
|
||||
//
|
||||
// A range whose two ends are on the same view line still has one, and is not the
|
||||
// same thing as a cursor sitting there: it covers everything that row shows, which
|
||||
// may be two lines of the diff at once.
|
||||
func selectionFarEndViewLine(view *gocui.View) (int, bool, bool) {
|
||||
if !view.HasRangeSelect() {
|
||||
return 0, false, false
|
||||
}
|
||||
first, last := view.SelectedLineRange()
|
||||
if view.SelectedLineIdx() == first {
|
||||
return last, true, true
|
||||
}
|
||||
return first, false, true
|
||||
}
|
||||
|
||||
// findDiffLine returns the view line showing the given diff line in what view is
|
||||
// displaying now, for placing a remembered line once the re-render is on screen.
|
||||
func (self *DiffLineHelper) findDiffLine(view *gocui.View, identity types.DiffLineInfo) (int, bool) {
|
||||
bufferLine, ok := self.patchLineRows(view.DiffLineContents())[patchLineOf(identity)]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return view.ViewLineForBufferLine(bufferLine)
|
||||
}
|
||||
|
||||
// restoreDiffLinePositionOnRerender arranges for view's next re-render to land on the
|
||||
// first of the given candidate lines the new rendering still has, calling place with
|
||||
// that candidate and the view line it ended up on. The candidates are in priority
|
||||
// order (see nearbyDiffLines); if the rendering has none of them, place isn't called
|
||||
// and the view re-renders as it otherwise would.
|
||||
//
|
||||
// The nearest candidate is looked for as the content loads, so that the re-render can
|
||||
// be revealed at the right position as soon as that line and a screenful below it
|
||||
// have arrived. Only the nearest one, because the candidates aren't in load order: a
|
||||
// farther one can load first, and landing on it while a nearer one is still on its
|
||||
// way would be settling for worse. The rest are considered together once the whole
|
||||
// rendering is there.
|
||||
func (self *DiffLineHelper) restoreDiffLinePositionOnRerender(
|
||||
view *gocui.View, candidates []diffLineAnchor, place func(anchor diffLineAnchor, viewLine int),
|
||||
) {
|
||||
manager := self.c.GetViewBufferManagerForView(view)
|
||||
if manager == nil || len(candidates) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// The readiness check below runs on the task's own goroutine, where neither the
|
||||
// view's dimensions nor the repo we are in may be read — a repo switch replaces
|
||||
// the latter — so take both here, on the UI thread, for it to work from.
|
||||
viewHeight := view.InnerHeight()
|
||||
worktreePath := self.c.Git().RepoPaths.WorktreePath()
|
||||
|
||||
// What the search of the loading content has found, and how far it has looked, so
|
||||
// that each line is looked at once.
|
||||
found := diffLineAnchor{}
|
||||
foundLine := -1
|
||||
scanned := 0
|
||||
|
||||
manager.SetRestoreForNextTask(&tasks.RenderRestore{
|
||||
FirstPaintReady: func() bool {
|
||||
if foundLine == -1 {
|
||||
rows := view.OffscreenDiffLineContentsFrom(scanned)
|
||||
for i, row := range rows {
|
||||
if rowShowsDiffLine(row, worktreePath, candidates[0].identity) {
|
||||
found, foundLine = candidates[0], scanned+i
|
||||
break
|
||||
}
|
||||
}
|
||||
scanned += len(rows)
|
||||
if foundLine == -1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Wait for a screenful below the line as well, so that the re-render isn't
|
||||
// revealed with it stranded at the bottom of a half-filled view.
|
||||
return view.OffscreenLineCount() >= foundLine+viewHeight
|
||||
},
|
||||
Apply: func(swapIn func()) bool {
|
||||
anchor, bufferLine := found, foundLine
|
||||
if bufferLine == -1 {
|
||||
anchor, bufferLine = self.nearestSurvivingCandidate(view.OffscreenDiffLineContents(), candidates)
|
||||
}
|
||||
|
||||
swapIn()
|
||||
|
||||
if bufferLine == -1 {
|
||||
return false
|
||||
}
|
||||
viewLine, ok := view.ViewLineForBufferLine(bufferLine)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
place(anchor, viewLine)
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// nearbyDiffLines collects the lines of view's rendered diff as candidates for a
|
||||
// restore to land on, ordered by proximity to the anchor line — the anchor itself
|
||||
// first, then outward, preferring at-or-below on ties — each tagged with the screen
|
||||
// row it is on. A restore lands on the first of them its re-render still has, so this
|
||||
// order makes it land as near as possible to where the user was.
|
||||
//
|
||||
// The walk covers the whole diff rather than stopping at the change lines on either
|
||||
// side of the anchor, which a context-size change always keeps: ignoring whitespace
|
||||
// keeps nothing in particular, and can take a hunk or a whole file out of the diff,
|
||||
// leaving the nearest surviving line in a neighbouring file.
|
||||
func (self *DiffLineHelper) nearbyDiffLines(view *gocui.View, anchorViewLine int) []diffLineAnchor {
|
||||
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
if anchor >= len(resolved) {
|
||||
return nil
|
||||
}
|
||||
rows := screenRows(view, len(resolved))
|
||||
|
||||
candidates := make([]diffLineAnchor, 0, len(resolved))
|
||||
collect := func(bufferLine int) {
|
||||
if line := resolved[bufferLine]; line.ok {
|
||||
candidates = append(candidates, diffLineAnchor{identity: line.info, row: rows[bufferLine]})
|
||||
}
|
||||
}
|
||||
collect(anchor)
|
||||
for below, above := anchor+1, anchor-1; below < len(resolved) || above >= 0; below, above = below+1, above-1 {
|
||||
if below < len(resolved) {
|
||||
collect(below)
|
||||
}
|
||||
if above >= 0 {
|
||||
collect(above)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// screenRows maps each line of view's content to the screen row it is drawn on. The
|
||||
// lines above the visible ones get -1 and those below them the view's height, so that
|
||||
// putting one of them back where it was lands it at the top or bottom edge.
|
||||
func screenRows(view *gocui.View, bufferLineCount int) []int {
|
||||
height := view.InnerHeight()
|
||||
originY := view.OriginY()
|
||||
|
||||
rows := make([]int, bufferLineCount)
|
||||
for i := range rows {
|
||||
rows[i] = -1
|
||||
}
|
||||
lastVisible := -1
|
||||
for y := originY; y < min(originY+height, view.ViewLinesHeight()); y++ {
|
||||
bufferLine, ok := view.BufferLineForViewLine(y)
|
||||
if !ok || bufferLine >= bufferLineCount {
|
||||
continue
|
||||
}
|
||||
if rows[bufferLine] == -1 {
|
||||
rows[bufferLine] = y - originY
|
||||
}
|
||||
lastVisible = bufferLine
|
||||
}
|
||||
for i := lastVisible + 1; i < bufferLineCount; i++ {
|
||||
rows[i] = height
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// nearestSurvivingCandidate returns the first of the candidates that the given
|
||||
// rendering still shows, and the line of it that does. The rendering is indexed
|
||||
// first, rather than searched once per candidate: the candidate list is as long as
|
||||
// the diff, and so is the rendering.
|
||||
func (self *DiffLineHelper) nearestSurvivingCandidate(
|
||||
contents []gocui.DiffLineContent, candidates []diffLineAnchor,
|
||||
) (diffLineAnchor, int) {
|
||||
rows := self.patchLineRows(contents)
|
||||
for _, candidate := range candidates {
|
||||
if line, ok := rows[patchLineOf(candidate.identity)]; ok {
|
||||
return candidate, line
|
||||
}
|
||||
}
|
||||
return diffLineAnchor{}, -1
|
||||
}
|
||||
|
||||
// patchLineRows indexes a rendering by the diff lines it shows: for each of them, the
|
||||
// first of its rows that does. A row can show more than one, and each is then a way
|
||||
// of finding that row again.
|
||||
func (self *DiffLineHelper) patchLineRows(contents []gocui.DiffLineContent) map[patchLine]int {
|
||||
rows := map[patchLine]int{}
|
||||
for i, identities := range self.resolveDiffLineIdentities(contents) {
|
||||
for _, identity := range identities {
|
||||
if _, seen := rows[patchLineOf(identity)]; !seen {
|
||||
rows[patchLineOf(identity)] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// rowShowsDiffLine reports whether the given row of a rendering shows the given diff
|
||||
// line — among any others it shows, since a side-by-side rendering puts a deletion
|
||||
// beside the addition replacing it. It only knows what the renderer states about the
|
||||
// row, since the alternative, parsing the rendering as a diff, needs whole hunks and
|
||||
// this is asked of content that is still loading. It takes the repo's worktree path
|
||||
// rather than reading it, being asked off the UI thread.
|
||||
func rowShowsDiffLine(row gocui.DiffLineContent, worktreePath string, target types.DiffLineInfo) bool {
|
||||
return lo.SomeBy(row.Metadata, func(record string) bool {
|
||||
parsed, ok := parseDiffLineMetadata(record)
|
||||
return ok && patchLineOf(diffLineInfoIn(worktreePath, parsed)) == patchLineOf(target)
|
||||
})
|
||||
}
|
||||
|
||||
// patchLine records what stays the same about a diff line when the same diff is
|
||||
// rendered again differently: which file it belongs to, the line number that
|
||||
// identifies it on the side it belongs to, and what kind of line it is.
|
||||
type patchLine struct {
|
||||
path string
|
||||
// Every kind of content line collapses into DiffLineContext, since an addition
|
||||
// and the context line it turns into when whitespace stops counting are the same
|
||||
// line of the same file. The header rows keep their kind: a file's header and the
|
||||
// first line of the file it heads are not the same place.
|
||||
kind types.DiffLineType
|
||||
// The old file's line number for a deletion, since two consecutive deletions
|
||||
// share a new-file position and differ only here; the new file's otherwise.
|
||||
line int
|
||||
isDeletion bool
|
||||
}
|
||||
|
||||
func patchLineOf(info types.DiffLineInfo) patchLine {
|
||||
switch info.Type {
|
||||
case types.DiffLineFileHeader, types.DiffLineHunkHeader:
|
||||
return patchLine{path: info.Path, kind: info.Type, line: info.NewLine}
|
||||
case types.DiffLineDeleted:
|
||||
return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.OldLine, isDeletion: true}
|
||||
default:
|
||||
return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.NewLine}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type Helpers struct {
|
||||
Snake *SnakeHelper
|
||||
// lives in context package because our contexts need it to render to main
|
||||
Diff *DiffHelper
|
||||
DiffLine *DiffLineHelper
|
||||
Repos *ReposHelper
|
||||
RecordDirectory *RecordDirectoryHelper
|
||||
Update *UpdateHelper
|
||||
@@ -76,6 +77,7 @@ func NewStubHelpers() *Helpers {
|
||||
Commits: &CommitsHelper{},
|
||||
Snake: &SnakeHelper{},
|
||||
Diff: &DiffHelper{},
|
||||
DiffLine: &DiffLineHelper{},
|
||||
Repos: &ReposHelper{},
|
||||
RecordDirectory: &RecordDirectoryHelper{},
|
||||
Update: &UpdateHelper{},
|
||||
|
||||
@@ -94,6 +94,23 @@ func (self *InlineStatusHelper) WithInlineStatus(opts InlineStatusOpts, f func(g
|
||||
}
|
||||
}
|
||||
|
||||
// Runs f on a worker with all the given items shown as being in the given
|
||||
// operation while it runs
|
||||
func WithInlineStatusOnItems[T types.HasUrn](c *HelperCommon, items []T, operation types.ItemOperation, contextKey types.ContextKey, f func(gocui.Task) error) error {
|
||||
return c.WithInlineStatus(items[0], operation, contextKey, func(task gocui.Task) error {
|
||||
for _, item := range items[1:] {
|
||||
c.State().SetItemOperation(item, operation)
|
||||
}
|
||||
defer func() {
|
||||
for _, item := range items[1:] {
|
||||
c.State().ClearItemOperation(item)
|
||||
}
|
||||
}()
|
||||
|
||||
return f(task)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *InlineStatusHelper) start(opts InlineStatusOpts) {
|
||||
self.c.State().SetItemOperation(opts.Item, opts.Operation)
|
||||
|
||||
|
||||
@@ -525,7 +525,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e
|
||||
}
|
||||
|
||||
wantFastForward, wantNonFastForward := self.fastForwardMergeUserPreference()
|
||||
canFastForward := self.c.Git().Branch.CanDoFastForwardMerge(refName)
|
||||
canFastForward := self.c.Git().Branch.IsAncestor("HEAD", refName)
|
||||
|
||||
var firstRegularMergeItem *types.MenuItem
|
||||
var secondRegularMergeItem *types.MenuItem
|
||||
|
||||
@@ -1150,14 +1150,14 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode
|
||||
|
||||
// self.refreshStatus is called at the end of this because that's when we can
|
||||
// be sure there is a State.Model.Branches array to pick the current branch from
|
||||
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), waitForRemotes func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
|
||||
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), waitForRemotes func(), branchSelection types.BranchSelectionBehavior, loadExtraInfo bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
|
||||
loadSeq := self.branchLoadSeq.Add(1)
|
||||
|
||||
branches, err := env.git.Loaders.BranchLoader.Load(
|
||||
reflogCommits,
|
||||
captured.mainBranches,
|
||||
captured.oldBranches,
|
||||
loadBehindCounts,
|
||||
loadExtraInfo,
|
||||
func(f func() error) {
|
||||
self.onWorker(env.background, func(_ gocui.Task) error {
|
||||
err := f()
|
||||
|
||||
@@ -3,7 +3,9 @@ package controllers
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type MainViewController struct {
|
||||
@@ -12,6 +14,9 @@ type MainViewController struct {
|
||||
|
||||
context *context.MainContext
|
||||
otherContext *context.MainContext
|
||||
|
||||
dragAutoscroller *helpers.DragAutoscroller
|
||||
draggingWithMouse bool
|
||||
}
|
||||
|
||||
var _ types.IController = &MainViewController{}
|
||||
@@ -21,12 +26,19 @@ func NewMainViewController(
|
||||
context *context.MainContext,
|
||||
otherContext *context.MainContext,
|
||||
) *MainViewController {
|
||||
return &MainViewController{
|
||||
controller := &MainViewController{
|
||||
baseController: baseController{},
|
||||
c: c,
|
||||
context: context,
|
||||
otherContext: otherContext,
|
||||
}
|
||||
controller.dragAutoscroller = helpers.NewDragAutoscroller(
|
||||
c.HelperCommon,
|
||||
context,
|
||||
controller.canDragAutoscroll,
|
||||
controller.handleDragAutoscroll,
|
||||
)
|
||||
return controller
|
||||
}
|
||||
|
||||
func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
@@ -38,10 +50,68 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
Tooltip: self.c.Tr.ToggleStagingViewTooltip,
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk),
|
||||
Handler: self.toggleSelectHunk,
|
||||
DescriptionFunc: self.diffSelectionDescription(func() string {
|
||||
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
|
||||
return self.c.Tr.SelectLineByLine
|
||||
}
|
||||
return self.c.Tr.SelectHunk
|
||||
}),
|
||||
Description: self.c.Tr.ToggleSelectHunk,
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
Tooltip: self.c.Tr.ToggleSelectHunkTooltip,
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect),
|
||||
Handler: self.toggleRangeSelect,
|
||||
Description: self.c.Tr.ToggleRangeSelect,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.ToggleRangeSelect),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Edit),
|
||||
Handler: self.editLine,
|
||||
Description: self.c.Tr.EditFile,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.EditFile),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
Tooltip: self.c.Tr.EditFileTooltip,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.PrevHunk),
|
||||
Handler: self.prevChangeBlock,
|
||||
Description: self.c.Tr.PrevHunk,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevHunk),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.NextHunk),
|
||||
Handler: self.nextChangeBlock,
|
||||
Description: self.c.Tr.NextHunk,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextHunk),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.PrevFile),
|
||||
Handler: self.prevFile,
|
||||
Description: self.c.Tr.PrevFileInDiff,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevFileInDiff),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.NextFile),
|
||||
Handler: self.nextFile,
|
||||
Description: self.c.Tr.NextFileInDiff,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextFileInDiff),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Return),
|
||||
Handler: self.escape,
|
||||
Description: self.c.Tr.ExitFocusedMainView,
|
||||
DescriptionFunc: self.escapeDescription,
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
@@ -51,6 +121,28 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
Description: self.c.Tr.StartSearch,
|
||||
Tag: "navigation",
|
||||
},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine},
|
||||
{
|
||||
Tag: "navigation",
|
||||
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectUp),
|
||||
Handler: self.extendRangeUp,
|
||||
Description: self.c.Tr.RangeSelectUp,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectUp),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Tag: "navigation",
|
||||
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown),
|
||||
Handler: self.extendRangeDown,
|
||||
Description: self.c.Tr.RangeSelectDown,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectDown),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +160,19 @@ func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts)
|
||||
Handler: self.onClickInOtherViewOfMainViewPair,
|
||||
FocusedView: self.otherContext.GetViewName(),
|
||||
},
|
||||
{
|
||||
// Dragging after a click extends a range selection from the clicked line.
|
||||
ViewName: self.context.GetViewName(),
|
||||
Key: gocui.MouseLeft,
|
||||
Modifier: gocui.ModMotion,
|
||||
Handler: self.onDragInFocusedView,
|
||||
FocusedView: self.context.GetViewName(),
|
||||
},
|
||||
{
|
||||
ViewName: self.context.GetViewName(),
|
||||
Key: gocui.MouseRelease,
|
||||
Handler: self.onDragRelease,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,35 +181,691 @@ func (self *MainViewController) Context() types.Context {
|
||||
}
|
||||
|
||||
func (self *MainViewController) togglePanel() error {
|
||||
if self.otherContext.GetView().Visible {
|
||||
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
|
||||
if !self.otherContext.GetView().Visible {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Whether the pair holds a diff is decided by the side panel beneath, which
|
||||
// NextInStack only finds while our context is still the focused main view, so
|
||||
// read it before pushing the other pane.
|
||||
isDiff := self.isDiffView()
|
||||
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
|
||||
if isDiff {
|
||||
establishDiffSelection(self.c, self.otherContext, -1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// escape dismisses the selection a step at a time before leaving the view: a range
|
||||
// collapses to its cursor line, and hunk mode the user turned on goes back to
|
||||
// line-by-line. Hunk mode that is merely the configured default is not something to
|
||||
// escape from, so there escape leaves.
|
||||
func (self *MainViewController) escape() error {
|
||||
if self.selectingRange() || self.selectingHunkEnabledByUser() {
|
||||
self.context.ResetDiffSelectMode()
|
||||
return nil
|
||||
}
|
||||
|
||||
self.c.Context().Pop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
|
||||
sidePanelContext := self.c.Context().NextInStack(self.context)
|
||||
if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil {
|
||||
return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y)
|
||||
func (self *MainViewController) escapeDescription() string {
|
||||
if self.selectingRange() {
|
||||
return self.c.Tr.DismissRangeSelect
|
||||
}
|
||||
if self.selectingHunkEnabledByUser() {
|
||||
return self.c.Tr.SelectLineByLine
|
||||
}
|
||||
return self.c.Tr.ExitFocusedMainView
|
||||
}
|
||||
|
||||
// selectingHunkEnabledByUser reports whether we are in hunk mode because the user
|
||||
// asked for it, as opposed to it being the configured default.
|
||||
func (self *MainViewController) selectingHunkEnabledByUser() bool {
|
||||
return self.diffSelectState().Mode == types.DiffSelectModeHunk && self.diffSelectState().UserEnabledHunkMode
|
||||
}
|
||||
|
||||
// isDiffView reports whether the focused main view currently shows a diff, and so
|
||||
// shows a selection. See types.DiffMainViewContext.
|
||||
func (self *MainViewController) isDiffView() bool {
|
||||
return self.diffMainViewType() != types.DiffMainViewTypeNone
|
||||
}
|
||||
|
||||
// diffMainViewType reports what the diff in the focused main view belongs to, taken
|
||||
// from the side panel beneath it, or DiffMainViewTypeNone when this pane isn't on the
|
||||
// stack or has no diff panel beneath it. The IsInStack guard is essential:
|
||||
// NextInStack panics for a context that isn't in the stack, and GetKeybindings (which
|
||||
// leads here) also runs for off-stack panes — at startup and while generating the
|
||||
// cheatsheets, where the stack is empty.
|
||||
func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
|
||||
if !self.c.Context().IsInStack(self.context) {
|
||||
return types.DiffMainViewTypeNone
|
||||
}
|
||||
if diffContext, ok := self.c.Context().NextInStack(self.context).(types.DiffMainViewContext); ok {
|
||||
return diffContext.GetDiffMainViewType()
|
||||
}
|
||||
return types.DiffMainViewTypeNone
|
||||
}
|
||||
|
||||
// diffSelectState returns this pane's diff selection mode state.
|
||||
func (self *MainViewController) diffSelectState() *types.DiffSelectState {
|
||||
return self.context.DiffSelectState()
|
||||
}
|
||||
|
||||
// diffSelectionDescription qualifies the description of a command that acts on the
|
||||
// selection, so that it is listed only where it applies: the main view also shows
|
||||
// content with nothing to select in it — a branch's commit log, the status dashboard —
|
||||
// and a command with no description is left out of the keybindings menu.
|
||||
//
|
||||
// The static Description stays as it is: the cheatsheets are generated from that, and
|
||||
// they document what a key does rather than when it applies.
|
||||
func (self *MainViewController) diffSelectionDescription(describe func() string) func() string {
|
||||
return func() string {
|
||||
if !self.isDiffView() {
|
||||
return ""
|
||||
}
|
||||
return describe()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *MainViewController) diffSelectionDescriptionText(description string) func() string {
|
||||
return self.diffSelectionDescription(func() string { return description })
|
||||
}
|
||||
|
||||
// diffSelectionDisabledReason disables the commands that act on the selection while
|
||||
// there is none to act on: a diff view whose diff holds nothing selectable (a binary
|
||||
// file, an empty commit) or which is showing a placeholder message.
|
||||
func (self *MainViewController) diffSelectionDisabledReason() *types.DisabledReason {
|
||||
if !self.context.GetView().Highlight {
|
||||
return &types.DisabledReason{Text: self.c.Tr.NothingToSelectInDiff}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
|
||||
self.selectClickedDiffLine(opts.Y)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
|
||||
self.c.Context().Push(self.context, types.OnFocusOpts{
|
||||
ClickedWindowName: self.context.GetWindowName(),
|
||||
ClickedViewLineIdx: opts.Y,
|
||||
// Carry the select mode over from the pane we're leaving, so that clicking into
|
||||
// the other pane keeps hunk mode even the first time we enter it — its own mode
|
||||
// would otherwise still be the default single line until it had been focused at
|
||||
// least once. selectClickedDiffLine then keeps or collapses that mode depending on
|
||||
// where the click landed.
|
||||
*self.context.DiffSelectState() = *self.otherContext.DiffSelectState()
|
||||
self.c.Context().Push(self.context, types.OnFocusOpts{})
|
||||
self.selectClickedDiffLine(opts.Y)
|
||||
return nil
|
||||
}
|
||||
|
||||
// onDragInFocusedView extends a range selection as the mouse is dragged after a
|
||||
// click, anchored at the line the click landed on rather than wherever the click left
|
||||
// the selection — a click can select a whole hunk, whose far end would otherwise
|
||||
// become the anchor. Dragging turns hunk mode off: you get a plain range from the
|
||||
// clicked line to the line under the cursor, which gocui has already moved here.
|
||||
func (self *MainViewController) onDragInFocusedView(opts gocui.ViewMouseBindingOpts) error {
|
||||
view := self.context.GetView()
|
||||
if !self.isDiffView() || !view.Highlight {
|
||||
return nil
|
||||
}
|
||||
sel := self.diffSelectState()
|
||||
sel.Mode = types.DiffSelectModeRange
|
||||
sel.RangeIsSticky = false
|
||||
sel.UserEnabledHunkMode = false
|
||||
view.SetRangeSelectStart(self.context.DragAnchorViewLine())
|
||||
|
||||
// A drag that reaches the edge of the view keeps going: mouse capture means the
|
||||
// pointer can be dragged past the edge, and there is more diff down there than
|
||||
// fits on screen. opts.Y is where the pointer is in the content, which the
|
||||
// autoscroller wants relative to the viewport.
|
||||
self.draggingWithMouse = true
|
||||
originY, _ := self.context.GetViewTrait().ViewPortYBounds()
|
||||
self.dragAutoscroller.Update(opts.Y - originY)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) onDragRelease(gocui.ViewMouseBindingOpts) error {
|
||||
self.draggingWithMouse = false
|
||||
self.dragAutoscroller.Cancel()
|
||||
|
||||
// The drag moved the selection without going through showSelectionAtLine: gocui
|
||||
// moves the cursor for it. Let the search catch up with where it ended.
|
||||
self.context.GetView().SetNearestSearchPosition()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOnFocusLost stops an autoscroll that is still running when the view loses focus
|
||||
// mid-drag, e.g. because a popup appeared, and gives up the mouse capture with it —
|
||||
// otherwise the pointer would keep driving a view that no longer has focus.
|
||||
func (self *MainViewController) GetOnFocusLost() func(types.OnFocusLostOpts) {
|
||||
return func(types.OnFocusLostOpts) {
|
||||
self.dragAutoscroller.Cancel()
|
||||
if self.draggingWithMouse {
|
||||
self.draggingWithMouse = false
|
||||
self.c.GocuiGui().CancelMouseCapture()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// canDragAutoscroll reports whether the autoscroller should run: only while a drag is
|
||||
// actually extending a range in a diff. Scrolling down also has to keep the lazily
|
||||
// loaded content ahead of the scroll, or it would stop at the loaded edge.
|
||||
func (self *MainViewController) canDragAutoscroll(direction int) bool {
|
||||
if !self.draggingWithMouse || !self.isDiffView() {
|
||||
return false
|
||||
}
|
||||
view := self.context.GetView()
|
||||
if !view.Highlight || self.diffSelectState().Mode != types.DiffSelectModeRange {
|
||||
return false
|
||||
}
|
||||
if direction > 0 {
|
||||
self.c.ReadLinesToFillView(view)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleDragAutoscroll extends the selection to the line the pointer ends up over
|
||||
// after the autoscroller has scrolled, leaving the range anchored where the drag
|
||||
// started. It reports whether the autoscroll should carry on.
|
||||
//
|
||||
// The pointer is usually outside the view by now — that is what mouse capture is for —
|
||||
// so the line it is over is clamped to the visible ones, leaving the selection's far
|
||||
// end at the edge the scroll is moving towards.
|
||||
func (self *MainViewController) handleDragAutoscroll(viewLine int) bool {
|
||||
if !self.canDragAutoscroll(0) {
|
||||
return false
|
||||
}
|
||||
view := self.context.GetView()
|
||||
originY, viewportHeight := self.context.GetViewTrait().ViewPortYBounds()
|
||||
target := lo.Clamp(viewLine, 0, max(0, view.ViewLinesHeight()-1))
|
||||
view.SetCursorY(lo.Clamp(target-originY, 0, max(0, viewportHeight-1)))
|
||||
return true
|
||||
}
|
||||
|
||||
// selectClickedDiffLine sets the focused main view's selection from a click at the
|
||||
// given view line. In hunk mode, clicking inside the selected block collapses it to
|
||||
// that line; clicking a change line outside it keeps hunk mode and selects that block.
|
||||
// A click on context, or any click outside hunk mode, selects just that line too.
|
||||
func (self *MainViewController) selectClickedDiffLine(viewLine int) {
|
||||
if !self.isDiffView() {
|
||||
return
|
||||
}
|
||||
view := self.context.GetView()
|
||||
// Remember where the click landed so that a drag that follows anchors its range
|
||||
// there, even when this click selects a whole hunk.
|
||||
self.context.SetDragAnchorViewLine(viewLine)
|
||||
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
|
||||
if start, end, ok := self.c.Helpers().DiffLine.SelectedHunkBounds(view); ok &&
|
||||
viewLine >= start && viewLine <= end {
|
||||
self.context.ResetDiffSelectMode()
|
||||
showSelectionAtLine(view, viewLine, false)
|
||||
return
|
||||
}
|
||||
if self.c.Helpers().DiffLine.IsChangeLine(view, viewLine) {
|
||||
self.selectHunkAround(viewLine, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
self.context.ResetDiffSelectMode()
|
||||
showSelectionAtLine(view, viewLine, false)
|
||||
}
|
||||
|
||||
// establishDiffSelection turns on the focused main view's selection once the view has
|
||||
// been focused. clickedViewLine is the view line a click pointed at, or -1 for
|
||||
// keyboard focus, which points at no particular line and so starts at the first
|
||||
// change line on screen.
|
||||
//
|
||||
// Focusing never moves the view: you focus the diff you are reading in order to point
|
||||
// at something in it, so the selection goes where you are looking rather than the
|
||||
// view going where the selection would like to be. With no change line on screen at
|
||||
// all — a long stretch of context — it lands on the middle visible line, the likeliest
|
||||
// one to be the one being read.
|
||||
//
|
||||
// With hunk mode configured as the default the selection widens to the whole change
|
||||
// block: keyboard focus lands on the first block on screen, and a click on a change
|
||||
// line selects that line's block, ready to act on. A click on context still selects
|
||||
// just that line — the click points at it precisely, so it stays editable.
|
||||
func establishDiffSelection(c *ControllerCommon, mainContext *context.MainContext, clickedViewLine int) {
|
||||
mainContext.ResetDiffSelectMode()
|
||||
view := mainContext.GetView()
|
||||
|
||||
// The panel beneath renders a diff, but that diff may hold nothing to act on: a
|
||||
// binary file, or an empty commit. Rendering it worked that out, so the pane is
|
||||
// already showing no selection and there is nowhere to put one.
|
||||
if !c.Helpers().DiffLine.ViewHasChangeLines(view) {
|
||||
return
|
||||
}
|
||||
|
||||
if clickedViewLine >= 0 {
|
||||
// Remember where the click landed so that a drag that follows anchors its range
|
||||
// there, even when this click selects a whole hunk.
|
||||
mainContext.SetDragAnchorViewLine(clickedViewLine)
|
||||
if hunkModeApplies(c, view, clickedViewLine) &&
|
||||
c.Helpers().DiffLine.IsChangeLine(view, clickedViewLine) {
|
||||
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
|
||||
selectDiffHunk(c, mainContext, clickedViewLine, false)
|
||||
return
|
||||
}
|
||||
showSelectionAtLine(view, clickedViewLine, false)
|
||||
return
|
||||
}
|
||||
|
||||
target, ok := changeToSelectOnScreen(c, view)
|
||||
if !ok {
|
||||
showSelectionAtLine(view, view.MiddleVisibleLineIdx(), false)
|
||||
return
|
||||
}
|
||||
if hunkModeApplies(c, view, target) {
|
||||
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
|
||||
selectDiffHunk(c, mainContext, target, false)
|
||||
return
|
||||
}
|
||||
showSelectionAtLine(view, target, false)
|
||||
}
|
||||
|
||||
// changeToSelectOnScreen returns the change line keyboard focus establishes the
|
||||
// selection on. In hunk mode that is the first block that begins on screen, so that
|
||||
// the block being offered up is one the user can see the extent of, falling back to a
|
||||
// block that reaches into the view from above — a change longer than the screen, where
|
||||
// there is nothing else to offer. Line by line it is simply the first change line on
|
||||
// screen. ok is false when the viewport shows no change at all.
|
||||
func changeToSelectOnScreen(c *ControllerCommon, view *gocui.View) (int, bool) {
|
||||
if c.UserConfig().Gui.UseHunkModeInStagingView {
|
||||
return c.Helpers().DiffLine.FirstChangeBlockInView(view)
|
||||
}
|
||||
return c.Helpers().DiffLine.FirstChangeLineInView(view)
|
||||
}
|
||||
|
||||
// hunkModeApplies reports whether an established selection should start out as the
|
||||
// whole change block around the given change line. That's what the config asks for,
|
||||
// except over a file shown as one solid block of changes, where it would select the
|
||||
// whole file — see DiffLineHelper.IsSingleHunkForWholeFile.
|
||||
func hunkModeApplies(c *ControllerCommon, view *gocui.View, changeViewLine int) bool {
|
||||
return c.UserConfig().Gui.UseHunkModeInStagingView &&
|
||||
!c.Helpers().DiffLine.IsSingleHunkForWholeFile(view, changeViewLine)
|
||||
}
|
||||
|
||||
// showSelectionAtLine moves the focused main view's selection to the given view line,
|
||||
// clamped to the content. scrollIntoView scrolls the line into view when it's
|
||||
// off-screen, for navigating to it; a click leaves it false, the clicked line being on
|
||||
// screen already.
|
||||
func showSelectionAtLine(view *gocui.View, lineIdx int, scrollIntoView bool) {
|
||||
view.FocusPoint(0, lo.Clamp(lineIdx, 0, max(0, view.ViewLinesHeight()-1)), scrollIntoView)
|
||||
|
||||
// A search carries on from where the selection now is, so that stepping to the
|
||||
// next match goes to the one after it rather than the one after the match the
|
||||
// user last stepped to.
|
||||
view.SetNearestSearchPosition()
|
||||
}
|
||||
|
||||
func (self *MainViewController) selectHunkAround(changeViewLine int, scrollIntoView bool) {
|
||||
selectDiffHunk(self.c, self.context, changeViewLine, scrollIntoView)
|
||||
}
|
||||
|
||||
// selectDiffHunk selects the whole change block around the given change line, for
|
||||
// hunk mode: the cursor goes to the block's first line and the range anchor to its
|
||||
// last, so the native range highlight spans the block. With no block to be found —
|
||||
// a diff with no changes in it — it falls back to a single-line selection.
|
||||
//
|
||||
// scrollIntoView brings the block's first line on screen, for the commands that mean
|
||||
// to go there; a click leaves it false, so that the view doesn't move under the mouse
|
||||
// when the block the click landed in starts above the viewport.
|
||||
func selectDiffHunk(
|
||||
c *ControllerCommon, mainContext *context.MainContext, changeViewLine int, scrollIntoView bool,
|
||||
) {
|
||||
view := mainContext.GetView()
|
||||
start, end, ok := c.Helpers().DiffLine.ChangeBlockBounds(view, changeViewLine)
|
||||
if !ok {
|
||||
mainContext.DiffSelectState().Mode = types.DiffSelectModeLine
|
||||
view.CancelRangeSelect()
|
||||
showSelectionAtLine(view, changeViewLine, scrollIntoView)
|
||||
return
|
||||
}
|
||||
view.SetRangeSelectStart(end)
|
||||
showSelectionAtLine(view, start, scrollIntoView)
|
||||
}
|
||||
|
||||
// navigate moves the focused main view to the row find locates from the current
|
||||
// anchor — the selected line when a selection is showing, otherwise the top visible
|
||||
// line. With a selection we move it there and scroll it into view, re-selecting the
|
||||
// whole block in hunk mode; with none we stay in scroll mode, bringing the target
|
||||
// to the top without selecting anything.
|
||||
// alignTop says what a jump does with a target it has to scroll to: bring it to the
|
||||
// top of the view, or leave the scrolling to place it as it sees fit.
|
||||
func (self *MainViewController) navigate(find findDiffRowFn, forward bool, alignTop bool) {
|
||||
v := self.context.GetView()
|
||||
anchor := v.OriginY()
|
||||
if v.Highlight {
|
||||
anchor = v.SelectedLineIdx()
|
||||
}
|
||||
|
||||
if target, ok := find(v, anchor, forward); ok {
|
||||
self.placeNavigationTarget(target, alignTop)
|
||||
return
|
||||
}
|
||||
if !forward {
|
||||
// Everything above the anchor has loaded, so a backward target that wasn't
|
||||
// found doesn't exist.
|
||||
return
|
||||
}
|
||||
|
||||
// The diff loads lazily, so a target below the loaded portion isn't there to be
|
||||
// found yet. Read the rest of it in and look again before concluding there is none.
|
||||
manager := self.c.GetViewBufferManagerForView(v)
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
if target, ok := find(v, anchor, forward); ok {
|
||||
self.placeNavigationTarget(target, alignTop)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// findDiffRowFn locates a row of the rendered diff to navigate to, given the view,
|
||||
// the anchor view line to start from, and the direction.
|
||||
type findDiffRowFn func(view *gocui.View, anchorViewLine int, forward bool) (int, bool)
|
||||
|
||||
func (self *MainViewController) nextChangeBlock() error {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, true, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) prevChangeBlock() error {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, false, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextFile and prevFile bring the file they go to to the top of the view, since what
|
||||
// you are going there for is the file, and the more of it is on screen the better.
|
||||
func (self *MainViewController) nextFile() error {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, true, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) prevFile() error {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, false, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// placeNavigationTarget moves the selection to the row a jump found, bringing it on
|
||||
// screen if it isn't already.
|
||||
//
|
||||
// alignTop asks for the target to become the view's top line, so that everything that
|
||||
// begins there is on screen. It only applies to a target the view has to scroll to: a
|
||||
// jump to something already on screen leaves the view alone, there being nothing to
|
||||
// gain from moving what the user is looking at. In hunk mode what ends up selected is
|
||||
// the first change block at or below the target, which a large context size can put
|
||||
// further down than a screenful; the selection is then scrolled into view as any other
|
||||
// jump's is, and the alignment gives way to that.
|
||||
func (self *MainViewController) placeNavigationTarget(target int, alignTop bool) {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
v.SetOrigin(0, target)
|
||||
return
|
||||
}
|
||||
if alignTop {
|
||||
self.scrollTargetToTop(target)
|
||||
}
|
||||
// Jumping to another block or file moves the cursor without shift held, so a
|
||||
// range that grows only while shift is held collapses rather than stretching all
|
||||
// the way to the target. A sticky range stretches instead; this is the point of
|
||||
// being sticky.
|
||||
self.collapseNonStickyRange()
|
||||
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
|
||||
self.selectHunkAround(target, true)
|
||||
return
|
||||
}
|
||||
// Line mode leaves a single-line selection at the target; an active range extends
|
||||
// to it, the anchor being untouched.
|
||||
showSelectionAtLine(v, target, true)
|
||||
}
|
||||
|
||||
// scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving
|
||||
// the view where it is when that row is on screen already. The last screenful of the
|
||||
// diff is as far as it goes, so that the view doesn't scroll past the end of what it is
|
||||
// showing.
|
||||
func (self *MainViewController) scrollTargetToTop(target int) {
|
||||
view := self.context.GetView()
|
||||
originY, height := self.context.GetViewTrait().ViewPortYBounds()
|
||||
if target >= originY && target < originY+height {
|
||||
return
|
||||
}
|
||||
view.SetOriginY(min(target, max(0, view.ViewLinesHeight()-height)))
|
||||
}
|
||||
|
||||
// moveCursor moves the selection cursor by delta view lines (negative = up), with the
|
||||
// configured scroll-off margin, reading more content in first when moving down. The
|
||||
// range anchor is left untouched, so this extends or contracts a range and just moves
|
||||
// the selected line otherwise.
|
||||
func (self *MainViewController) moveCursor(delta int) {
|
||||
v := self.context.GetView()
|
||||
if delta > 0 {
|
||||
self.c.ReadLinesToFillView(v)
|
||||
}
|
||||
before := v.SelectedLineIdx()
|
||||
after := lo.Clamp(before+delta, 0, v.ViewLinesHeight()-1)
|
||||
if delta == -1 {
|
||||
checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
|
||||
} else if delta == 1 {
|
||||
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
|
||||
}
|
||||
showSelectionAtLine(v, after, true)
|
||||
}
|
||||
|
||||
// collapseForLineMove drops hunk mode, and a non-sticky range, back to a single-line
|
||||
// selection — what a plain (non-shift, non-hunk-step) move does before moving. A
|
||||
// sticky range is kept, so the move extends it.
|
||||
func (self *MainViewController) collapseForLineMove() {
|
||||
sel := self.diffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeHunk {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
self.context.GetView().CancelRangeSelect()
|
||||
return
|
||||
}
|
||||
self.collapseNonStickyRange()
|
||||
}
|
||||
|
||||
// collapseNonStickyRange drops a range that only grows while shift is held back to a
|
||||
// single line at the cursor.
|
||||
func (self *MainViewController) collapseNonStickyRange() {
|
||||
sel := self.diffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
self.context.GetView().CancelRangeSelect()
|
||||
}
|
||||
}
|
||||
|
||||
// adjustSelection moves the selection by delta view lines, for the plain up/down and
|
||||
// page keys. In hunk mode a single-line step jumps to the adjacent block, while a
|
||||
// larger page step drops out of hunk mode first. A non-sticky range collapses back to
|
||||
// a single line on a plain move. With no selection — non-diff content — it scrolls.
|
||||
func (self *MainViewController) adjustSelection(delta int) {
|
||||
if !self.context.GetView().Highlight {
|
||||
self.handleLineChange(delta)
|
||||
return
|
||||
}
|
||||
if self.diffSelectState().Mode == types.DiffSelectModeHunk && (delta == 1 || delta == -1) {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, delta > 0, false)
|
||||
return
|
||||
}
|
||||
self.collapseForLineMove()
|
||||
self.moveCursor(delta)
|
||||
}
|
||||
|
||||
// selectAbsoluteLine moves the selection to a specific view line — the top or bottom
|
||||
// of the diff — dropping hunk mode and a non-sticky range like a plain move does.
|
||||
func (self *MainViewController) selectAbsoluteLine(target int) {
|
||||
self.collapseForLineMove()
|
||||
showSelectionAtLine(self.context.GetView(), target, true)
|
||||
}
|
||||
|
||||
// selectingRange reports whether a range selection is currently active: we're in
|
||||
// range mode and either it's sticky or the anchor and cursor differ, i.e. a
|
||||
// non-sticky range that has actually been extended.
|
||||
func (self *MainViewController) selectingRange() bool {
|
||||
if self.diffSelectState().Mode != types.DiffSelectModeRange {
|
||||
return false
|
||||
}
|
||||
start, end := self.context.GetView().SelectedLineRange()
|
||||
return self.diffSelectState().RangeIsSticky || start != end
|
||||
}
|
||||
|
||||
// toggleSelectHunk switches between selecting the change block around the cursor and
|
||||
// a single line.
|
||||
func (self *MainViewController) toggleSelectHunk() error {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
return nil
|
||||
}
|
||||
sel := self.diffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeHunk {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
v.CancelRangeSelect()
|
||||
} else {
|
||||
sel.Mode = types.DiffSelectModeHunk
|
||||
sel.UserEnabledHunkMode = true
|
||||
self.selectHunkAround(v.SelectedLineIdx(), true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toggleRangeSelect starts or cancels a sticky range selection, which the plain
|
||||
// up/down keys extend.
|
||||
func (self *MainViewController) toggleRangeSelect() error {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
return nil
|
||||
}
|
||||
sel := self.diffSelectState()
|
||||
if self.selectingRange() {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
sel.RangeIsSticky = false
|
||||
v.CancelRangeSelect()
|
||||
} else {
|
||||
sel.Mode = types.DiffSelectModeRange
|
||||
sel.RangeIsSticky = true
|
||||
v.SetRangeSelectStart(v.SelectedLineIdx())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extendRange grows a non-sticky range selection by one line in response to
|
||||
// shift+up/down, starting one at the cursor if there isn't one yet.
|
||||
func (self *MainViewController) extendRange(forward bool) error {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
return nil
|
||||
}
|
||||
sel := self.diffSelectState()
|
||||
if !self.selectingRange() {
|
||||
sel.Mode = types.DiffSelectModeRange
|
||||
v.SetRangeSelectStart(v.SelectedLineIdx())
|
||||
}
|
||||
sel.RangeIsSticky = false
|
||||
if forward {
|
||||
self.moveCursor(1)
|
||||
} else {
|
||||
self.moveCursor(-1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) extendRangeUp() error {
|
||||
return self.extendRange(false)
|
||||
}
|
||||
|
||||
func (self *MainViewController) extendRangeDown() error {
|
||||
return self.extendRange(true)
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleLineChange(delta int) {
|
||||
v := self.context.GetView()
|
||||
if delta < 0 {
|
||||
v.ScrollUp(-delta)
|
||||
} else {
|
||||
v.ScrollDown(delta)
|
||||
self.c.ReadLinesToFillView(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *MainViewController) handlePrevLine() error {
|
||||
self.adjustSelection(-1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleNextLine() error {
|
||||
self.adjustSelection(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handlePrevPage() error {
|
||||
self.adjustSelection(-self.context.GetViewTrait().PageDelta())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleNextPage() error {
|
||||
self.adjustSelection(self.context.GetViewTrait().PageDelta())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleGotoTop() error {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
self.handleLineChange(-v.ViewLinesHeight())
|
||||
return nil
|
||||
}
|
||||
self.selectAbsoluteLine(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleGotoBottom() error {
|
||||
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
self.handleLineChange(v.ViewLinesHeight())
|
||||
return nil
|
||||
}
|
||||
self.selectAbsoluteLine(v.ViewLinesHeight() - 1)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) editLine() error {
|
||||
view := self.context.GetView()
|
||||
if !view.Highlight {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(view, view.SelectedLineIdx())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A file-header row points at the file as a whole rather than at a line in it, so
|
||||
// it opens the file without jumping anywhere — as pressing edit on a file in a side
|
||||
// panel does.
|
||||
if info.Type == types.DiffLineFileHeader {
|
||||
return self.c.Helpers().Files.EditFiles([]string{info.Path})
|
||||
}
|
||||
|
||||
// The diff may be of an older commit, whose line numbers aren't the file's current
|
||||
// ones, so they have to be carried forward before we can point an editor at them.
|
||||
lineNumber := self.c.Helpers().Diff.AdjustLineNumber(info.Path, info.NewLine, self.context.GetViewName())
|
||||
return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber)
|
||||
}
|
||||
|
||||
func (self *MainViewController) openSearch() error {
|
||||
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
|
||||
manager.ReadToEnd(func() {
|
||||
|
||||
@@ -47,7 +47,7 @@ func (self *ReflogCommitsController) GetOnRenderToMain() func() {
|
||||
} else {
|
||||
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit))
|
||||
|
||||
task = types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
task = types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
}
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user