Compare commits

..
Author SHA1 Message Date
Stefan HallerandClaude Opus 5 3889653d1b Add a script for re-recording every demo a page embeds
Changing demo/settings.tape, or anything about how lazygit looks, dates
every recording at once, and re-recording them one at a time means
running the recorder fifteen times and pasting fifteen new URLs.
Attachment URLs say nothing about where they came from, so there is also
nothing to tell you which demo a video in the README is of.

Name the demo in a comment above each video. GitHub drops the comment
when it renders the page, so it costs the reader nothing, and it gives
us a way back from a page to the demo that produced it. Then walk those
comments, re-record each demo and rewrite the URL below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 22:07:30 +02:00
Stefan HallerandClaude Opus 5 b4e70420cc amend! Record demos with vhs and upload them to GitHub's attachment store
Record demos with vhs and publish them to GitHub's attachment store

The demo gifs in the README start on their own, loop without telling the
reader where a run begins, and give no way to pause, seek or replay. A
video with the browser's own controls fixes all three, but GitHub plays
a video in a README only when it is served from its own attachment
store. If you commit one to the assets branch and link it the way we
link the images, GitHub drops the whole <video> element when it renders
the page.

Replace terminalizer with vhs. vhs records the demo straight to mp4
rather than going through a gif, and it takes the terminal size, font
and colours from demo/settings.tape. Then upload the result from the
endpoint that GitHub's own drag-and-drop upload posts to, and print the
tag to paste into the page.

Uploading alone is not enough. An attachment is readable only by people
who are signed in to GitHub until a posted comment in the repository
refers to it, and a README on a branch does not count. So post each
recording to a collecting issue and wait until the video can be fetched
without a token. Miss that step and the video plays for whoever recorded
it and 404s for every other reader.

Pad the bottom of the frame as well, because the browser draws its
playback controls over the video and they are tall enough to cover the
line where the demos put their captions.

Pass --no-upload while you are still working on how a demo looks. That
writes the video to demo/output and stops, so trying out a colour or a
font costs nothing but the recording itself.

The recording is sharper and smaller than the gif it replaces. It runs
at 1866x1230 and 25 fps for 306K, against 1140x828 and about 5 fps for
461K. It also costs the reader nothing until they press play, whereas
the gifs are fetched every time the README is opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 22:07:07 +02:00
Stefan Haller 7072461d8a fixup! Show the commit_and_push demo in the README as a video 2026-09-22 22:06:49 +02:00
Stefan HallerandClaude Opus 5 8c1852e074 amend! Record demos with vhs and upload them to GitHub's attachment store
Record demos with vhs and publish them to GitHub's attachment store

The demo gifs in the README start on their own, loop without telling the
reader where a run begins, and give no way to pause, seek or replay. A
video with the browser's own controls fixes all three, but GitHub plays
a video in a README only when it is served from its own attachment
store. If you commit one to the assets branch and link it the way we
link the images, GitHub drops the whole <video> element when it renders
the page.

Replace terminalizer with vhs. vhs records the demo straight to mp4
rather than going through a gif, and it takes the terminal size, font
and colours from demo/settings.tape. Then upload the result from the
endpoint that GitHub's own drag-and-drop upload posts to, and print the
tag to paste into the page.

Uploading alone is not enough. An attachment is readable only by people
who are signed in to GitHub until a posted comment in the repository
refers to it, and a README on a branch does not count. So post each
recording to a collecting issue and wait until the video can be fetched
without a token. Miss that step and the video plays for whoever recorded
it and 404s for every other reader.

Pad the bottom of the frame as well, because the browser draws its
playback controls over the video and they are tall enough to cover the
line where the demos put their captions.

The recording is sharper and smaller than the gif it replaces. It runs
at 1866x1290 and 25 fps for 304K, against 1140x828 and about 5 fps for
461K. It also costs the reader nothing until they press play, whereas
the gifs are fetched every time the README is opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:37:45 +02:00
Stefan Haller 84dd4b41d7 fixup! Show the commit_and_push demo in the README as a video 2026-09-22 20:48:56 +02:00
Stefan Haller 2fe4905817 fixup! Record demos with vhs and upload them to GitHub's attachment store 2026-09-22 20:48:56 +02:00
Stefan Haller bcde5e68cb fixup! Record demos with vhs and upload them to GitHub's attachment store 2026-09-22 20:35:30 +02:00
Stefan HallerandClaude Opus 5 1027c1cd92 Show the commit_and_push demo in the README as a video
Readers can pause, seek and replay it now, and it no longer starts on
its own.

The other demos stay as gifs until they are re-recorded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 20:32:07 +02:00
Stefan HallerandClaude Opus 5 efdf8b360c Record demos with vhs and upload them to GitHub's attachment store
The demo gifs in the README start on their own, loop without telling the
reader where a run begins, and give no way to pause, seek or replay. A
video with the browser's own controls fixes all three, but GitHub plays
a video in a README only when it is served from its own attachment
store. If you commit one to the assets branch and link it the way we
link the images, GitHub drops the whole <video> element when it renders
the page.

Replace terminalizer with vhs. vhs records the demo straight to mp4
rather than going through a gif, and it takes the terminal size, font
and colours from demo/settings.tape. Then upload the result from the
endpoint that GitHub's own drag-and-drop upload posts to, and print the
tag to paste into the page.

The recording is sharper and smaller than the gif it replaces. It runs
at 1866x1140 and 25 fps for 297K, against 1140x828 and about 5 fps for
461K. It also costs the reader nothing until they press play, whereas
the gifs are fetched every time the README is opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 20:32:07 +02:00
Stefan HallerandClaude Opus 5 2c74c8d6d3 Give the tests a visible inactive border colour
The integration test config asks for `black` inactive borders. In a
recording that comes out as a mid grey, because the recording theme
remaps the terminal's black to #7a7a7a. In an ordinary terminal it is
real black, so when you watch a test with `just e2e-cli` the inactive
frames all but disappear against the background.

Name the grey directly instead (but a little bit brighter than the #7a
we had before), so that the frames look the same either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 20:32:07 +02:00
403 changed files with 6496 additions and 23776 deletions
+6 -34
View File
@@ -63,9 +63,7 @@ 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. A
`fixup!` is not such a follow-up; see "Iterate with `fixup!` commits" for
what one may leave broken until it is folded in.
commits that leave the tree broken and rely on a follow-up to fix it.
- **Every commit must be `gofumpt`-formatted.** Run `just format` before
committing.
- **Every commit must be lint-clean.** Run `just lint` before committing —
@@ -136,32 +134,6 @@ 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
@@ -208,7 +180,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.
@@ -348,7 +320,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):
@@ -434,7 +406,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
@@ -450,7 +422,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.
@@ -503,7 +475,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
+4
View File
@@ -68,6 +68,10 @@ 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
+4 -3
View File
@@ -47,7 +47,8 @@ A simple terminal UI for git commands
[![GitHub Releases](https://img.shields.io/github/downloads/jesseduffield/lazygit/total)](https://github.com/jesseduffield/lazygit/releases) [![Go Report Card](https://goreportcard.com/badge/github.com/jesseduffield/lazygit)](https://goreportcard.com/report/github.com/jesseduffield/lazygit) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/f46416b715d74622895657935fcada21)](https://app.codacy.com/gh/jesseduffield/lazygit/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/f46416b715d74622895657935fcada21)](https://app.codacy.com/gh/jesseduffield/lazygit/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage) [![golangci-lint](https://img.shields.io/badge/linted%20by-golangci--lint-brightgreen)](https://golangci-lint.run/) [![GitHub tag](https://img.shields.io/github/v/tag/jesseduffield/lazygit?color=blue)](https://github.com/jesseduffield/lazygit/releases/latest) [![homebrew](https://img.shields.io/homebrew/v/lazygit?color=blue)](https://formulae.brew.sh/formula/lazygit)
![commit_and_push](../assets/demo/commit_and_push-compressed.gif)
<!-- demo: commit_and_push -->
<video src="https://github.com/user-attachments/assets/a1f28c26-7545-4ba8-ae79-b37f848b1b8e" controls></video>
</div>
@@ -135,7 +136,7 @@ Lazygit is not my fulltime job but it is a hefty part time job so if you want to
### Stage individual lines
Press `<enter>` on a changed file to focus its diff in the main view. Press `<space>` on the selected line to stage it, or press `v` to start selecting a range of lines. You can also press `a` to switch to hunk selection mode. When a file has both staged and unstaged changes, use `<tab>` to move between the two diff panes; the same actions stage or unstage the selection depending on the pane.
Press space on the selected line to stage it, or press `v` to start selecting a range of lines. You can also press `a` to select the entirety of the current hunk.
![stage_lines](../assets/demo/stage_lines-compressed.gif)
@@ -195,7 +196,7 @@ You can create worktrees to have multiple branches going at once without the nee
You can build a custom patch from an old commit and then remove the patch from the commit, split out a new commit, apply the patch in reverse to the index, and more.
In this example we have a redundant comment that we want to remove from an old commit. We hit `<enter>` on the commit to view its files, then `<enter>` on a file to focus its diff. From there, `<space>` adds the selected comment line to the custom patch and `ctrl+p` opens the custom patch options, where we choose to remove the patch from the original commit.
In this example we have a redundant comment that we want to remove from an old commit. We hit `<enter>` on the commit to view its files, then `<enter>` on a file to focus the patch, then `<space>` to add the comment line to our custom patch, and then `ctrl+p` to view the custom patch options; selecting to remove the patch from the current commit.
Learn more in the [Rebase magic Youtube tutorial](https://youtu.be/4XaToVut_hs).
-142
View File
@@ -1,142 +0,0 @@
// 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
View File
@@ -1,112 +0,0 @@
# 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"
+172 -46
View File
@@ -2,55 +2,72 @@
set -e
TYPE=$1
TEST=$2
# 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
usage() {
echo "Usage: $0 [gif|mp4] <test path>"
echo "e.g. using full path: $0 gif pkg/integration/tests/demo/nuke_working_tree.go"
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."
exit 1
}
if [ "$#" -ne 2 ]
UPLOAD=true
if [ "$1" = "--no-upload" ]
then
UPLOAD=false
shift
fi
TEST=$1
if [ "$#" -ne 1 ]
then
usage
fi
if [ "$TYPE" != "gif" ] && [ "$TYPE" != "mp4" ]
TOOLS="vhs ttyd ffmpeg"
if [ "$UPLOAD" = true ]
then
usage
exit 1
TOOLS="$TOOLS gh"
fi
if [ -z "$TEST" ]
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 ]
then
usage
fi
WORKTREE_PATH=$(git worktree list | grep assets | awk '{print $1}')
WORKTREE_PATH=$(git worktree list | grep assets | awk '{print $1}')
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
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
OUTPUT_DIR="$WORKTREE_PATH/demo"
else
OUTPUT_DIR=demo/output
fi
# Get last part of the test path and set that as the output name
@@ -63,19 +80,128 @@ go generate pkg/integration/tests/tests.go
mkdir -p "$OUTPUT_DIR"
# 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"
SCRATCH=$(mktemp -d)
trap 'rm -rf "$SCRATCH"' EXIT
# Then we convert it to either an mp4 or gif based on the command line argument
if [ "$TYPE" = "mp4" ]
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
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"
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
fi
echo "Demo recorded to $COMPRESSED_PATH"
# 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>"
+113
View File
@@ -0,0 +1,113 @@
#!/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
+46
View File
@@ -0,0 +1,46 @@
# 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"
}
+32 -100
View File
@@ -37,6 +37,12 @@ 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:
@@ -72,7 +78,7 @@ gui:
# If true, do not show a warning when amending a commit.
skipAmendWarning: false
# If true, do not show a warning when discarding changes from a focused diff.
# If true, do not show a warning when discarding changes in the staging view.
skipDiscardChangeWarning: false
# If true, do not show warning when applying/popping the stash
@@ -142,13 +148,14 @@ gui:
# - 'top': split the window vertically (side panel on top, main view below)
enlargedSideViewLocation: left
# If true, wrap lines in focused diffs to the width of the view. This makes it
# much easier to work with diffs that have long lines, e.g. paragraphs of
# If true, wrap lines in the staging view to the width of the view. This makes
# it much easier to work with diffs that have long lines, e.g. paragraphs of
# markdown text.
wrapLinesInDiffView: true
wrapLinesInStagingView: true
# If true, hunk selection mode will be enabled by default when focusing a diff.
useHunkModeInDiffView: true
# If true, hunk selection mode will be enabled by default when entering the
# staging view.
useHunkModeInStagingView: true
# One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko'
# | 'ru' | 'pt'
@@ -162,14 +169,6 @@ 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:
@@ -180,7 +179,7 @@ gui:
# Border color of non-focused windows
inactiveBorderColor:
- dim
- default
# Border color of focused window when searching in that window
searchingActiveBorderColor:
@@ -191,22 +190,14 @@ 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: []
selectedLineBgColor:
- blue
# Background color of selected line when view doesn't have focus.
# Default: a suitable RGB grey computed from the terminal's background color.
inactiveViewSelectedLineBgColor: []
inactiveViewSelectedLineBgColor:
- bold
# Foreground color of copied commit
cherryPickedCommitFgColor:
@@ -232,22 +223,6 @@ 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
@@ -468,9 +443,7 @@ 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). 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.
# (as opposed to diverged).
# Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
autoForwardBranches: onlyMainBranches
@@ -844,8 +817,6 @@ keybinding:
main:
prevHunk: [<left>, h]
nextHunk: [<right>, l]
prevFile: "N"
nextFile: "n"
toggleSelectHunk: a
pickBothHunks: b
editSelectHunk: E
@@ -936,9 +907,7 @@ It is used, for example, when pasting a commit message into the commit message p
## Configuring File Editing
There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file when you invoke it from a focused diff.
You can also open a line in your editor with the mouse: alt-click or shift-click it. Both modifiers do the same thing, because some terminals only support one or the other. The click leaves the focus and the selection where they are, so it works while you are reading a diff from another panel, or while a popup is open.
There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file if you invoke it from the staging panel, for example.
To tell lazygit which editor to use for the `e` command, the easiest way to do that is to provide an editPreset config, e.g.
@@ -1001,7 +970,7 @@ When the selected line gets close to the bottom of the window and you hit down-a
That's the behavior when `gui.scrollOffBehavior` is set to "margin" (the default). If you set `gui.scrollOffBehavior` to "jump", then upon reaching the last line of a view and hitting down-arrow the view will scroll by half a page so that the selection ends up in the middle of the view. This may feel a little jarring because the cursor jumps around when continuously moving down, but it has the advantage that the view doesn't scroll as often.
This setting applies both to all list views (e.g. commits and branches etc), and to focused diffs.
This setting applies both to all list views (e.g. commits and branches etc), and to the staging view.
## Filtering
@@ -1030,7 +999,6 @@ The available attributes are:
- bold
- default
- dim # faint text; not supported by every terminal
- reverse # useful for high-contrast
- underline
- strikethrough
@@ -1055,61 +1023,28 @@ 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:
theme:
authorColors:
'John Smith': 'red' # use red for John Smith
'Alan Smithee': '#00ff00' # use green for Alan Smithee
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:
theme:
authorColors:
# use red for John Smith
'John Smith': 'red'
# use blue for other authors
'*': '#0000ff'
authorColors:
# use red for John Smith
'John Smith': 'red'
# use blue for other authors
'*': '#0000ff'
```
## Custom Branch Color
@@ -1118,16 +1053,13 @@ You can customize the color of branches based on branch patterns (regular expres
```yaml
gui:
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>'
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:
+6 -15
View File
@@ -23,29 +23,22 @@ 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 --{{colorScheme}} --paging=never
- command: delta --dark --paging=never
- command: ydiff -p cat
colorArg: never
- type: extDiff
command: difft --color=always --background={{colorScheme}} --context={{diffContext}}
command: difft --color=always --context={{diffContext}}
- type: rawGit
args: [--color-words]
name: color-words
@@ -58,14 +51,12 @@ git:
```yaml
git:
diffRenderers:
- command: delta --{{colorScheme}} --paging=never
- command: delta --dark --paging=never
```
![](https://i.imgur.com/QJpQkF3.png)
`--{{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.
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.
Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons.
-33
View File
@@ -16,36 +16,3 @@ 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.
+1
View File
@@ -31,6 +31,7 @@
* `pkg/gui/keybindings`: Contains code for mapping between keybindings and their labels
* `pkg/gui/mergeconflicts`: Contains code relating to the handling of merge conflicts
* `pkg/gui/modes`: Contains code relating to the state of different modes e.g. cherry picking mode, rebase mode.
* `pkg/gui/patch_exploring`: Contains code relating to the state of patch-oriented views like the staging view.
* `pkg/gui/popup`: Contains code that lets you easily raise popups
* `pkg/gui/presentation`: Contains presentation code i.e. code concerned with rendering content inside views
* `pkg/gui/services/custom_commands`: Contains code related to user-defined custom commands.
+74 -25
View File
@@ -8,17 +8,18 @@ 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
npm i -g terminalizer
# for gif compression
npm i -g gifsicle
# for mp4 conversion
brew install ffmpeg
# 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
# font with icons
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
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
```
## Creating a demo
@@ -49,34 +50,82 @@ The scripts and demo definitions live in the code branches but the output lives
git worktree add .worktrees/assets assets
```
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)
The mp4 of the recording will be stored in `.worktrees/assets/demo/`.
### Recording the demo
Once you're happy with your demo you can record it using:
```sh
scripts/record_demo.sh [gif|mp4] <path>
scripts/record_demo.sh <path>
# e.g.
scripts/record_demo.sh gif pkg/integration/tests/demo/interactive_rebase.go
scripts/record_demo.sh pkg/integration/tests/demo/interactive_rebase.go
```
~~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).~~
The terminal size, font and colours live in `demo/settings.tape`, which the
script sources into the tape it generates for the demo.
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.
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
```
### Including demos in README/docs
If you've followed the above steps you'll end up with your output in your assets worktree.
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:
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
![Nuke working tree](../assets/demo/interactive_rebase-compressed.gif)
```html
<video src="https://github.com/user-attachments/assets/<uuid>" controls></video>
```
This means we can update assets without needing to update the docs that embed them.
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.
+40 -34
View File
@@ -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. 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. |
| `` 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. |
| `` ) `` | 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'. |
@@ -65,7 +65,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open external diff tool (git difftool) | |
| `` <space> `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Enter file / Toggle directory collapsed | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` ` `` | Toggle file tree view | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -149,7 +149,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | View stash options | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | Stage all | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Focus file diff / Collapse directory | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Stage lines / Collapse directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` d `` | Discard | View options for discarding changes to the selected file. |
| `` g `` | View upstream reset options | |
| `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). |
@@ -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. 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). |
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. |
| `` T `` | New tag | |
| `` s `` | Sort order | |
| `` g `` | Reset | |
@@ -222,20 +222,42 @@ _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 diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Search the current view by text | |
## Main panel (patch building)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | Exit back to side panel | |
| `` v `` | Toggle range select | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` o `` | Open file | Open file in default application. |
| `` e `` | Edit file | Open file in external editor. |
| `` <space> `` | Toggle lines in patch | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Exit custom patch builder | |
| `` / `` | Search the current view by text | |
## Main panel (staging)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` v `` | Toggle range select | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | Open file | Open file in default application. |
| `` e `` | Edit file | Open file in external editor. |
| `` <esc> `` | Return to files panel | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` c `` | Commit | Commit staged changes. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Commit changes using git editor | |
@@ -305,24 +327,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Commit staged changes. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Commit changes using git editor | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Search the current view by text | |
## Stash
+41 -35
View File
@@ -114,7 +114,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | 外部差分ツールを開く(git difftool) | |
| `` <space> `` | パッチに含めるファイルを切り替え | ファイルがカスタムパッチに含まれるかどうかを切り替えます。https://github.com/jesseduffield/lazygit#rebase-magic-custom-patchesを参照してください。 |
| `` a `` | すべてのファイルを切り替え | コミットのすべてのファイルをカスタムパッチに追加/削除します。https://github.com/jesseduffield/lazygit#rebase-magic-custom-patchesを参照してください。 |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | ファイルに入る / ディレクトリの折りたたみを切り替える | ファイルが選択されている場合、そのファイルに入ってカスタムパッチに個々の行を追加/削除できます。ディレクトリが選択されている場合、ディレクトリを切り替えます。 |
| `` ` `` | ファイルツリービューを切り替え | ファイル表示をフラット表示とツリー表示で切り替えます。フラット表示はすべてのファイルパスを一覧で表示し、ツリー表示はディレクトリごとにファイルをグループ化します。<br><br>デフォルトは設定ファイル内の 'gui.showFileTree' キーで変更できます。 |
| `` - `` | すべてのファイルを折りたたむ | ファイルツリー内のすべてのディレクトリを折りたたみます |
| `` = `` | すべてのファイルを展開 | ファイルツリー内のすべてのディレクトリを展開します |
@@ -191,24 +191,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## タグ
@@ -260,6 +244,44 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` 0 `` | メインビューにフォーカス | |
| `` / `` | 現在のビューをテキストでフィルタリング | |
## メインパネル(ステージング)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` v `` | 範囲選択を切り替え | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <esc> `` | ファイルパネルに戻る | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## メインパネル(パッチ作成)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` v `` | 範囲選択を切り替え | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | パッチ内の行を切り替え | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | カスタムパッチビルダーを終了 | |
| `` / `` | 現在のビューをテキストで検索 | |
## メインパネル(マージ中)
| Key | Action | Info |
@@ -282,24 +304,8 @@ _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 diff pane | Switch to the other focused diff pane. |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## メニュー
+40 -34
View File
@@ -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. 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. |
| `` 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. |
| `` ) `` | 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'. |
@@ -83,24 +83,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 검색 시작 | |
## Stash
@@ -177,20 +161,42 @@ _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 diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 검색 시작 | |
## 메인 패널 (Patch Building)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | Exit back to side panel | |
| `` v `` | 드래그 선택 전환 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` o `` | 파일 닫기 | Open file in default application. |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <space> `` | Line(s)을 패치에 추가/삭제 | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Exit custom patch builder | |
| `` / `` | 검색 시작 | |
## 메인 패널 (Staging)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` v `` | 드래그 선택 전환 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | 파일 닫기 | Open file in default application. |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <esc> `` | 파일 목록으로 돌아가기 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | |
@@ -217,7 +223,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. 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). |
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. |
| `` T `` | 태그를 생성 | |
| `` s `` | Sort order | |
| `` g `` | View reset options | |
@@ -339,7 +345,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open external diff tool (git difftool) | |
| `` <space> `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Toggle all files included in patch | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Enter file to add selected lines to the patch (or toggle directory collapsed) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` ` `` | 파일 트리뷰로 전환 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -389,7 +395,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | Stash 옵션 보기 | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | 모든 변경을 Staged/unstaged으로 전환 | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Stage individual hunks/lines for file, or collapse/expand for directory | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Stage individual hunks/lines for file, or collapse/expand for directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` d `` | View 'discard changes' options | View options for discarding changes to the selected file. |
| `` g `` | View upstream reset options | |
| `` D `` | 초기화 | View reset options for working tree (e.g. nuking the working tree). |
+37 -31
View File
@@ -72,7 +72,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | Bekijk stash opties | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | Toggle staged alle | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Stage individuele hunks/lijnen | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Stage individuele hunks/lijnen | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` d `` | Bekijk 'veranderingen ongedaan maken' opties | View options for discarding changes to the selected file. |
| `` g `` | Bekijk upstream reset opties | |
| `` D `` | Resetten | View reset options for working tree (e.g. nuking the working tree). |
@@ -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. 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). |
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. |
| `` T `` | Creëer tag | |
| `` s `` | Sort order | |
| `` g `` | Bekijk reset opties | |
@@ -144,7 +144,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open externe diff applicatie (git difftool) | |
| `` <space> `` | Toggle bestand inbegrepen in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Enter bestand om geselecteerde regels toe te voegen aan de patch | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Vouw alle bestanden uit | Vouw alle mappen in de bestandsstructuur uit |
@@ -230,24 +230,24 @@ _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> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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. |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
## Patch bouwen
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. |
| `` w `` | Commit veranderingen zonder pre-commit hook | |
| `` C `` | Commit veranderingen met de git editor | |
| `` <ctrl+f> `` | Find base commit for fixup | Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` v `` | Toggle drag selecteer | |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` o `` | Open bestand | Open bestand in standaardapplicatie. |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <space> `` | Voeg toe/verwijder lijn(en) in patch | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Sluit lijn-bij-lijn modus | |
| `` / `` | Start met zoeken | |
## Reflog
@@ -305,20 +305,26 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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. |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
## Staging
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | Exit back to side panel | |
| `` v `` | Toggle drag selecteer | |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | Open bestand | Open bestand in standaardapplicatie. |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <esc> `` | Ga terug naar het bestanden paneel | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. |
| `` w `` | Commit veranderingen zonder pre-commit hook | |
| `` C `` | Commit veranderingen met de git editor | |
+41 -35
View File
@@ -98,24 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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. |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
| `` C `` | Zatwierdź zmiany używając edytora git | |
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Drzewa pracy
@@ -148,6 +132,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <enter> `` | Pokaż commity | |
| `` / `` | Filtruj bieżący widok po tekście | |
## Główny panel (budowanie łatki)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <space> `` | Przełącz linie w łatce | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Wyjdź z budowniczego niestandardowej łatki | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Input prompt
| Key | Action | Info |
@@ -200,24 +200,8 @@ _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> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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. |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
| `` C `` | Zatwierdź zmiany używając edytora git | |
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Panel główny (scalanie)
@@ -236,6 +220,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | Wróć do panelu plików | |
## Panel główny (zatwierdzanie)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <esc> `` | Wróć do panelu plików | |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. |
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
| `` C `` | Zatwierdź zmiany używając edytora git | |
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Panel potwierdzenia
| Key | Action | Info |
@@ -290,7 +296,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | |
| `` <space> `` | Przełącz plik włączony w łatkę | Przełącz, czy plik jest włączony w niestandardową łatkę. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Przełącz wszystkie pliki | Dodaj/usuń wszystkie pliki commita do niestandardowej łatki. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Wejdź do pliku / Przełącz zwiń katalog | Jeśli plik jest wybrany, wejdź do pliku, aby móc dodawać/usuwać poszczególne linie do niestandardowej łatki. Jeśli wybrany jest katalog, przełącz katalog. |
| `` ` `` | Przełącz widok drzewa plików | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
+36 -30
View File
@@ -148,7 +148,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Abrir ferramenta de diff externa (git difftool) | |
| `` <space> `` | Alternar entre o arquivo incluído no patch | Alternar se o arquivo está incluído no patch personalizado. Veja https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Alternar todos os arquivos | Adicionar/remover todos os arquivos de commit para atualização personalizada. Consulte https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Insira o arquivo / Alternar diretório recolhido | Se um arquivo estiver selecionado, insira o arquivo para que você possa adicionar/remover linhas individuais no patch personalizado. Se um diretório for selecionado, ative o diretório. |
| `` ` `` | Alternar exibição de árvore de arquivo | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos |
| `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo |
@@ -234,20 +234,26 @@ _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> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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. |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` E `` | Editar hunk | Editar o local selecionado no editor externo. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Pesquisar na visualização atual por texto | |
## Painel Principal (preparação)
| Key | Action | Info |
|-----|--------|-------------|
| `` <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 | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | Exit back to side panel | |
| `` v `` | Toggle range select | |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <esc> `` | Retornar ao painel de arquivos | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` E `` | Editar hunk | Editar o local selecionado no editor externo. |
| `` c `` | Commit | Submeter mudanças em staging |
| `` w `` | Fazer commit de alterações sem pré-commit | |
| `` C `` | Enviar alteração usando um editor Git | |
@@ -278,6 +284,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | Retornar ao painel de arquivos | |
## Painel principal (patch build)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` v `` | Toggle range select | |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <space> `` | Alternar linhas no caminho | |
| `` d `` | Remover linhas do commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Sair do construtor de patch personalizado | |
| `` / `` | Pesquisar na visualização atual por texto | |
## Reflog
| Key | Action | Info |
@@ -314,24 +336,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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. |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` E `` | Editar hunk | Editar o local selecionado no editor externo. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Submeter mudanças em staging |
| `` w `` | Fazer commit de alterações sem pré-commit | |
| `` C `` | Enviar alteração usando um editor Git | |
| `` <ctrl+f> `` | Encontrar commit da base para corrigir | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado<br>Veja a documentação:<br><https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Pesquisar na visualização atual por texto | |
## Stash
+40 -34
View File
@@ -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. 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. |
| `` 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. |
| `` ) `` | 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'. |
@@ -73,20 +73,26 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` E `` | Изменить эту часть | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Найти | |
## Главная панель (Индексирование)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | Exit back to side panel | |
| `` v `` | Переключить выборку перетаскивания | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | Открыть файл | Open file in default application. |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <esc> `` | Вернуться к панели файлов | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` E `` | Изменить эту часть | Edit selected hunk in external editor. |
| `` c `` | Сохранить изменения | Commit staged changes. |
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
| `` C `` | Сохранить изменения с помощью редактора git | |
@@ -99,24 +105,8 @@ _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 diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` E `` | Изменить эту часть | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Сохранить изменения | Commit staged changes. |
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
| `` C `` | Сохранить изменения с помощью редактора git | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Найти | |
## Главная панель (Слияние)
@@ -135,6 +125,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | Вернуться к панели файлов | |
## Главная панель (сборка патчей)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` v `` | Переключить выборку перетаскивания | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` o `` | Открыть файл | Open file in default application. |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <space> `` | Добавить/удалить строку(и) для патча | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Выйти из сборщика пользовательских патчей | |
| `` / `` | Найти | |
## Журнал ссылок (Reflog)
| Key | Action | Info |
@@ -217,7 +223,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. 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). |
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. |
| `` T `` | Создать тег | |
| `` s `` | Порядок сортировки | |
| `` g `` | Просмотреть параметры сброса | |
@@ -298,7 +304,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open external diff tool (git difftool) | |
| `` <space> `` | Переключить файлы включённые в патч | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Переключить все файлы, включённые в патч | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Введите файл, чтобы добавить выбранные строки в патч (или свернуть каталог переключения) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` ` `` | Переключить вид дерева файлов | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -383,7 +389,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | Просмотреть параметры хранилища | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | Все проиндексированные/непроиндексированные | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Проиндексировать отдельные части/строки для файла или свернуть/развернуть для каталога | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Проиндексировать отдельные части/строки для файла или свернуть/развернуть для каталога | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` d `` | Просмотреть параметры «отмены изменении» | View options for discarding changes to the selected file. |
| `` g `` | Просмотреть параметры сброса upstream-ветки | |
| `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). |
+41 -35
View File
@@ -178,7 +178,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | 使用外部差异比较工具(git difftool) | |
| `` <space> `` | 补丁中包含的切换文件 | 切换文件是否包含在自定义补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` a `` | 操作所有文件 | 添加或删除所有提交中的文件到自定义的补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | 输入文件以将所选行添加到补丁中(或切换目录折叠) | 如果已选择一个文件,则Enter进入该文件,以便您可以向自定义补丁添加/删除单独的行。如果选择了目录,则切换目录。 |
| `` ` `` | 切换文件树视图 | 在平面布局和树布局之间切换文件视图。平面布局在单个列表中显示所有文件路径,树布局按目录分组文件。<br><br>可以在配置文件中使用 'gui.showFileTree' 键更改默认设置。 |
| `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 |
| `` = `` | 展开全部文件 | 展开文件树中的全部目录 |
@@ -249,6 +249,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <enter> `` | 查看提交 | |
| `` / `` | 通过文本过滤当前视图 | |
## 构建补丁中
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` v `` | 切换拖动选择 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` o `` | 打开文件 | 使用默认程序打开该文件 |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 添加/移除 行到补丁 | |
| `` d `` | 从提交中移除行 | 从本次提交中移除所选行。此操作会在后台运行交互式变基,因此如果后续提交也修改了这些行,您可能会遇到合并冲突。 |
| `` <esc> `` | 退出逐行模式 | |
| `` / `` | 开始搜索 | |
## 标签
| Key | Action | Info |
@@ -269,24 +285,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 正在合并
@@ -305,30 +305,36 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | 查看合并冲突选项 | 查看用于解决合并冲突的选项。 |
| `` <esc> `` | 返回文件面板 | |
## 正在暂存
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` v `` | 切换拖动选择 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` o `` | 打开文件 | 使用默认程序打开该文件 |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <esc> `` | 返回文件面板 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 正常
| Key | Action | Info |
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 向下滚动 | |
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 状态
+41 -35
View File
@@ -52,30 +52,30 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` ] `` | 下一個索引標籤 | |
| `` [ `` | 上一個索引標籤 | |
## 主面板 (補丁生成)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 向 (或從) 補丁中添加/刪除行 | |
| `` d `` | 從提交中移除行 | 從本次提交中移除所選行。此操作會在背景執行互動式變基,因此如果後續提交也修改了這些行,您可能會遇到合併衝突。 |
| `` <esc> `` | 退出自訂補丁建立器 | |
| `` / `` | 搜尋 | |
## 主面板(一般)
| Key | Action | Info |
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` <esc> `` | 退出回到側邊面板 | |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 主面板(合併)
@@ -94,6 +94,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
| `` <esc> `` | 返回檔案面板 | |
## 主面板(預存)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <esc> `` | 返回檔案面板 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 功能表
| Key | Action | Info |
@@ -208,7 +230,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` <space> `` | 切換檔案是否包含在補丁中 | 切換檔案是否包含在自定義補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` a `` | 切換所有檔案是否包含在補丁中 | 新增或刪除所有提交中的檔案到自定義的補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | 如果已選擇一個檔案,則Enter進入該檔案,以便您可以向自定義補丁新增/刪除單獨的行。如果選擇了目錄,則切換目錄。 |
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
@@ -333,24 +355,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` <esc> `` | 退出回到側邊面板 | |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 狀態
+4
View File
@@ -74,5 +74,9 @@ 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
+3
View File
@@ -152,6 +152,9 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes
if integrationTest != nil {
integrationTest.SetupConfig(appConfig)
// Set this to true so that integration tests don't have to explicitly deal with the hunk
// staging hint:
appConfig.GetAppState().DidShowHunkStagingHint = true
// Preserve the changes that the test setup just made to the config, so
// they don't get lost when we reload the config while running the test
+7
View File
@@ -120,7 +120,9 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string {
"prompt": tr.PromptTitle,
"information": tr.InformationTitle,
"main": tr.NormalTitle,
"patchBuilding": tr.PatchBuildingTitle,
"mergeConflicts": tr.MergingTitle,
"staging": tr.StagingTitle,
"menu": tr.MenuTitle,
"search": tr.SearchTitle,
"secondary": tr.SecondaryTitle,
@@ -139,7 +141,12 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string {
}
func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*bindingSection {
excludedViews := []string{"stagingSecondary", "patchBuildingSecondary"}
bindingsToDisplay := lo.Filter(bindings, func(binding *types.Binding, _ int) bool {
if lo.Contains(excludedViews, binding.ViewName) {
return false
}
return (binding.Description != "" || binding.Alternative != "") && len(binding.Keys) > 0
})
+3 -10
View File
@@ -135,15 +135,8 @@ func NewGitCommandAux(
rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands)
stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands)
patchBuilder := patch.NewPatchBuilder(cmn.Log,
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
// A patch is built from git's own diff: what a diff renderer would make of it
// is a picture of it, not something that can be applied.
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, git_commands.DiffModePlain)
},
func() (string, error) {
// Under lazygit's own temp dir, so that it honours the configured location
// and is cleaned up with everything else when we exit.
return os.MkdirTemp(osCommand.GetTempDir(), "custom-patch-")
func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, plain)
})
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
bisectCommands := git_commands.NewBisectCommands(gitCommon)
@@ -152,7 +145,7 @@ func NewGitCommandAux(
gitHubCommands := git_commands.NewGitHubCommands(gitCommon)
hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon)
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, branchCommands.HasLocalOnlyCommits, configCommands)
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands)
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
commitLoader := git_commands.NewCommitLoader(cmn, cmd, statusCommands.WorkingTreeState, gitCommon)
reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd)
+4 -95
View File
@@ -285,26 +285,11 @@ func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error
return self.cmd.New(cmdArgs).Run()
}
// 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 {
// Returns whether refName can be fast-forward merged into the current branch
func (self *BranchCommands) CanDoFastForwardMerge(refName string) bool {
cmdArgs := NewGitCmd("merge-base").
Arg("--is-ancestor").
Arg(ancestorRefName, refName).
Arg("HEAD", refName).
ToArgv()
err := self.cmd.New(cmdArgs).DontLog().Run()
return err == nil
@@ -368,85 +353,9 @@ func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches *
return stdout == "", nil
}
// 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 {
func (self *BranchCommands) UpdateBranchRefs(updateCommands string) error {
cmdArgs := NewGitCmd("update-ref").
Arg("--stdin").
Arg("-m", reflogMessage).
ToArgv()
return self.cmd.New(cmdArgs).SetStdin(updateCommands).Run()
+8 -74
View File
@@ -44,7 +44,6 @@ type BranchLoader struct {
*GitCommon
cmd oscommands.ICmdObjBuilder
getCurrentBranchInfo func() (BranchInfo, error)
hasLocalOnlyCommits func(*models.Branch) (bool, error)
config BranchLoaderConfigCommands
}
@@ -53,7 +52,6 @@ func NewBranchLoader(
gitCommon *GitCommon,
cmd oscommands.ICmdObjBuilder,
getCurrentBranchInfo func() (BranchInfo, error),
hasLocalOnlyCommits func(*models.Branch) (bool, error),
config BranchLoaderConfigCommands,
) *BranchLoader {
return &BranchLoader{
@@ -61,7 +59,6 @@ func NewBranchLoader(
GitCommon: gitCommon,
cmd: cmd,
getCurrentBranchInfo: getCurrentBranchInfo,
hasLocalOnlyCommits: hasLocalOnlyCommits,
config: config,
}
}
@@ -70,7 +67,7 @@ func NewBranchLoader(
func (self *BranchLoader) Load(reflogCommits []*models.Commit,
mainBranches *MainBranches,
oldBranches []*models.Branch,
loadExtraInfo bool,
loadBehindCounts bool,
onWorker func(func() error),
renderFunc func(),
) ([]*models.Branch, error) {
@@ -138,63 +135,24 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit,
branch.UpstreamBranch = match.Merge
}
// If the branch already existed, take over the values that are
// determined in the background, to reduce flicker
// If the branch already existed, take over its BehindBaseBranch value
// 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 loadExtraInfo {
if self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
onWorker(func() error {
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
})
}
if loadBehindCounts && self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
onWorker(func() error {
return self.checkForRewrittenUpstreams(branches, renderFunc)
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, 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,
@@ -391,7 +349,6 @@ var branchFields = []string{
"upstream:short",
"upstream:track",
"push:track",
"push",
"subject",
"objectname",
"committerdate:unix",
@@ -404,15 +361,13 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch
upstreamName := split[2]
track := split[3]
pushTrack := split[4]
pushRef := split[5]
subject := split[6]
commitHash := split[7]
commitDate := split[8]
subject := split[5]
commitHash := split[6]
commitDate := split[7]
name := strings.TrimPrefix(fullName, "heads/")
aheadForPull, behindForPull, gone := parseUpstreamInfo(upstreamName, track)
aheadForPush, behindForPush, _ := parseUpstreamInfo(upstreamName, pushTrack)
pushRemote, pushBranch := parsePushDestination(pushRef)
recency := ""
if storeCommitDateAsRecency {
@@ -428,8 +383,6 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch
BehindForPull: behindForPull,
AheadForPush: aheadForPush,
BehindForPush: behindForPush,
PushRemote: pushRemote,
PushBranch: pushBranch,
UpstreamGone: gone,
Head: headMarker == "*",
Subject: subject,
@@ -457,25 +410,6 @@ 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 -103
View File
@@ -6,10 +6,8 @@ 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"
)
@@ -28,7 +26,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",
@@ -43,7 +41,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",
@@ -58,7 +56,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",
@@ -73,7 +71,7 @@ func TestObtainBranch(t *testing.T) {
},
{
testName: "IsBehindAndAhead",
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},
input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -81,40 +79,6 @@ 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",
@@ -122,7 +86,7 @@ func TestObtainBranch(t *testing.T) {
},
{
testName: "RemoteBranchIsGone",
input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp},
input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -131,8 +95,6 @@ func TestObtainBranch(t *testing.T) {
BehindForPull: "?",
AheadForPush: "?",
BehindForPush: "?",
PushRemote: "a_remote",
PushBranch: "a_branch",
Head: false,
Subject: "subject",
CommitHash: "123",
@@ -140,7 +102,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",
@@ -293,62 +255,3 @@ 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")
}
-109
View File
@@ -4,7 +4,6 @@ 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"
@@ -318,111 +317,3 @@ 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()
})
}
}
+3 -3
View File
@@ -240,12 +240,12 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj {
return self.cmd.New(cmdArgs)
}
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string, mode DiffMode) *oscommands.CmdObj {
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string) *oscommands.CmdObj {
cmdArgs := NewGitCmd("show").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
Arg("--submodule").
Arg("--color=" + mode.colorArg(self.diffRendererConfigManager)).
Arg("--color=" + self.diffRendererConfigManager.GetColorArg()).
Arg("--stat").
Arg("--decorate").
Arg("-p").
+2 -2
View File
@@ -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.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.external=difft --color=always", "-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",
@@ -341,7 +341,7 @@ func TestCommitShowCmdObj(t *testing.T) {
}
instance := buildCommitCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, runner: runner, repoPaths: &repoPaths})
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths, DiffModeRendered).Run())
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths).Run())
runner.CheckForMissingCalls()
})
}
+3 -150
View File
@@ -2,135 +2,10 @@ package git_commands
import (
"fmt"
"os"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/mgutz/str"
)
// metadataHandshake is the record a diff renderer that speaks the OSC 1717 protocol
// emits before anything else, to announce that it does: a version-only record, with
// none of the fields a line's record has. See ProbeDiffRendererEmitsMetadata, and
// gocui's escape interpreter for how it is kept off the screen on a real render.
const metadataHandshake = "\x1b]1717"
// probeWidth is the width a probe asks the renderer to lay out to. A probe has
// nothing to render, so any width does, as long as it is one a renderer will
// accept: a renderer that lays its rendering out in columns may well refuse a
// width of zero, and would then never get as far as announcing itself.
const probeWidth = 80
// ProbeDiffRendererEmitsMetadata reports whether the configured diff renderer states
// which line of which file it is rendering, by running it on empty input and looking
// for the handshake. The answer decides whether a diff the renderer produced can be
// acted on at all, or has to be replaced by git's own when the user wants to act on it
// (see DiffLineHelper.MainViewDiffMode).
//
// Asking rather than watching a real render: the handshake is the renderer's first
// output whatever the diff, so the answer is a property of the renderer, known before
// we render anything — where watching would have to see a diff go by first, and would
// be fooled by a diff with no lines to describe.
//
// No terminal is needed. git only invokes a stdin filter when it thinks it is talking
// to one, but the renderer itself doesn't care: it announces itself whenever OSC1717 is
// set, so it can be run directly with empty input.
func (self *DiffCommands) ProbeDiffRendererEmitsMetadata() bool {
manager := self.diffRendererConfigManager
values := config.DiffRendererValues{Width: probeWidth, DiffContext: 3}
switch manager.GetDiffRendererType() {
case config.DiffRendererType_StdinFilter:
if command, err := manager.GetStdinFilterCommand(values); err == nil && command != "" {
return self.probeEmitsMetadata(self.cmd.NewShell(command, ""))
}
case config.DiffRendererType_ExtDiff:
// An empty command means git's own diff.external config, which picks a driver
// per file through .gitattributes: there is no one renderer to ask, and a single
// diff can be produced by several, so we take it that it says nothing.
if command, err := manager.GetExternalDiffCommand(values); err == nil && command != "" {
return self.externalDiffEmitsMetadata(command)
}
case config.DiffRendererType_RawGit:
// git describes only the formats whose output can't be read back as a diff, and
// asked with the renderer's own arguments it answers for exactly the format
// those select: a handshake for a word diff, silence for a unified one. With no
// arguments there is nothing to fall back to anyway, since this already is git's
// own diff.
if args := manager.GetRawGitArgs(); len(args) > 0 {
return self.rawGitEmitsMetadata(args)
}
}
return false
}
// rawGitEmitsMetadata asks git itself, run with the diff renderer's own arguments.
func (self *DiffCommands) rawGitEmitsMetadata(rawGitArgs []string) bool {
oldPath, newPath, cleanup, ok := self.probeFiles()
if !ok {
return false
}
defer cleanup()
return self.probeEmitsMetadata(self.cmd.New(
NewGitCmd("diff").
Arg("--no-index").
Arg(rawGitArgs...).
Arg(oldPath, newPath).
ToArgv(),
))
}
// externalDiffEmitsMetadata asks an external diff command, invoking it the way git
// invokes one — with the seven positional arguments of git's diff.external convention —
// over two empty files, so that it announces itself without having a diff to render.
func (self *DiffCommands) externalDiffEmitsMetadata(externalDiffCommand string) bool {
oldPath, newPath, cleanup, ok := self.probeFiles()
if !ok {
return false
}
defer cleanup()
args := append(str.ToArgv(externalDiffCommand),
"probe", oldPath, "0000000", "100644", newPath, "0000000", "100644")
return self.probeEmitsMetadata(self.cmd.New(args))
}
// probeFiles makes the two empty files a probe stands a diff up from, and the cleanup
// that removes them. Empty, because what the probe wants is for the renderer to announce
// itself, not for it to have anything to say.
func (self *DiffCommands) probeFiles() (string, string, func(), bool) {
tempDir := self.os.GetTempDir()
oldFile, err := os.CreateTemp(tempDir, "lazygit-probe-old-*")
if err != nil {
return "", "", nil, false
}
oldFile.Close()
newFile, err := os.CreateTemp(tempDir, "lazygit-probe-new-*")
if err != nil {
os.Remove(oldFile.Name())
return "", "", nil, false
}
newFile.Close()
return oldFile.Name(), newFile.Name(), func() {
os.Remove(oldFile.Name())
os.Remove(newFile.Name())
}, true
}
func (self *DiffCommands) probeEmitsMetadata(cmdObj *oscommands.CmdObj) bool {
cmdObj.AddEnvVars("OSC1717=V1")
// A renderer may well object to being handed nothing to render. We want to know
// whatever it said before objecting, and that is captured either way.
output, _ := cmdObj.RunWithOutput()
return strings.Contains(output, metadataHandshake)
}
type DiffCommands struct {
*GitCommon
}
@@ -143,41 +18,19 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
// This is for generating diffs to be shown in the UI (e.g. rendering a range
// diff to the main view). It uses a custom diff renderer if one is configured.
func (self *DiffCommands) DiffCmdObj(diffArgs []string, mode DiffMode) *oscommands.CmdObj {
func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg(diffArgs...).
Dir(self.repoPaths.worktreePath).
ToArgv(),
)
}
// CustomPatchDiffCmdObj is the command that renders the custom patch being built: a diff
// of the two trees the patch was materialized into (PatchCommands.WriteCustomPatchDiffTrees),
// under the directory holding them. It goes through the same wiring as any other diff we
// show, so the patch is rendered by whatever renders the rest of them, and git works out
// how much context to give it.
//
// git's own path prefixes are suppressed because the trees are named a and b themselves,
// which leaves the paths reading like an ordinary diff's over the repo's own paths.
func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, mode DiffMode) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
NoLineEndingConversion().
Arg("--no-index").
Arg("--no-prefix").
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg("a", "b").
Dir(dir).
ToArgv(),
)
}
// This is a basic generic diff command that can be used for any diff operation
// (e.g. copying a diff to the clipboard). It will not use a custom diff renderer,
// and does not use user configs such as ignore whitespace.
-35
View File
@@ -1,35 +0,0 @@
package git_commands
import (
"github.com/jesseduffield/lazygit/pkg/config"
)
// DiffMode says what a diff command's output is for. This decides whether the
// configured diff renderer produces it, and whether it is coloured.
type DiffMode int
const (
// DiffModeRendered is the diff as the user has arranged for it to look: through the
// diff renderer, with the renderer's own arguments and its preference about colour.
DiffModeRendered DiffMode = iota
// DiffModeRaw is git's own coloured diff, for showing a diff whose rendered form
// couldn't be acted on.
DiffModeRaw
// DiffModePlain is git's own uncoloured diff, for building patches from and copying
// text out of rather than for looking at.
DiffModePlain
)
// colorArg returns the value to pass to git's --color for this mode. Rendered output is
// coloured however the renderer wants its input; a raw diff gets git's own colour, which
// is the point of it; a plain one is for reading as text, not for looking at.
func (self DiffMode) colorArg(diffRendererConfigManager *config.DiffRendererConfigManager) string {
switch self {
case DiffModeRendered:
return diffRendererConfigManager.GetColorArg()
case DiffModeRaw:
return "always"
default:
return "never"
}
}
@@ -123,42 +123,18 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand
return self
}
// NoLineEndingConversion keeps git's line-ending machinery away from files that are not
// a working tree's. The trees the custom patch is materialized into hold the bytes git
// states the patch in, so a command over them has to read and write those bytes as they
// are. On a machine that checks files out with CRLF, `git apply` writes the after tree
// in that form while the before tree keeps the LF it was written with. git's own diff
// converts both back, but it warns about a round trip through a working tree these
// files never belong to, and an external diff renderer is handed the two files as they
// stand, one line ending apart in every line.
func (self *GitCommandBuilder) NoLineEndingConversion() *GitCommandBuilder {
return self.
// The setting that converts on most machines, and the one Git for Windows
// installs itself with.
Config("core.autocrlf=false").
// An attributes file outside the repo can still mark the files as text. The
// form to keep them in is then the form they are written in.
Config("core.eol=lf").
// An attribute naming CRLF outright overrides that, and git converts after all.
// The warning it gives is about a checkout these files never have.
Config("core.safecrlf=false")
}
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, mode DiffMode) *GitCommandBuilder {
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder {
contextSize := userConfig.Git.DiffContextSize
useExtDiff := mode == DiffModeRendered && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
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)).
// Ignoring whitespace is about what the user wants to see, so it holds for a raw
// diff as much as for a rendered one. Patches are built from a plain diff,
// where a diff that leaves changes out would apply to nothing.
ArgIf(mode != DiffModePlain && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
Arg(fmt.Sprintf("--find-renames=%d%%", userConfig.Git.RenameSimilarityThreshold)).
// The renderer's own arguments to git — a word diff, say — are part of the
// rendering, so they go with it.
ArgIf(mode == DiffModeRendered, diffRendererConfigManager.GetRawGitArgs()...)
ArgIf(forUI, diffRendererConfigManager.GetRawGitArgs()...)
}
func (self *GitCommandBuilder) ToArgv() []string {
-89
View File
@@ -2,16 +2,13 @@ package git_commands
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/app/daemon"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo"
)
@@ -23,10 +20,6 @@ type PatchCommands struct {
stash *StashCommands
PatchBuilder *patch.PatchBuilder
// The version of the patch the diff trees were last written for, so that they are
// written again when, and only when, the patch has changed since.
treesWrittenForGeneration int
}
func NewPatchCommands(
@@ -47,88 +40,6 @@ func NewPatchCommands(
}
}
// EnsureCustomPatchDiffTrees writes the custom patch's diff trees if what is there no
// longer describes the patch. Call it before rendering the patch, which is often — every
// time the panel showing it re-renders — while the patch itself changes rarely.
func (self *PatchCommands) EnsureCustomPatchDiffTrees() error {
if self.PatchBuilder.Generation() == self.treesWrittenForGeneration {
return nil
}
if err := self.WriteCustomPatchDiffTrees(); err != nil {
return err
}
self.treesWrittenForGeneration = self.PatchBuilder.Generation()
return nil
}
// WriteCustomPatchDiffTrees materializes the custom patch as two file trees under the
// directory the patch builder keeps for it: `a` holds each of the patch's files as it is
// before the patch, `b` as it is after. Diffing those two trees against each other
// (DiffCommands.CustomPatchDiffCmdObj) turns the patch into a diff of real files, which
// can then be rendered exactly as any other diff is — through a diff renderer of any
// kind, and with git's own idea of how much context to show.
//
// The trees are named a and b so that the diff's paths, with git's own prefixes
// suppressed, come out reading like the a/ and b/ of an ordinary diff, over the real
// repo-relative paths.
func (self *PatchCommands) WriteCustomPatchDiffTrees() error {
dir := self.PatchBuilder.TempDir()
if dir == "" {
return nil
}
before := filepath.Join(dir, "a")
after := filepath.Join(dir, "b")
for _, tree := range []string{before, after} {
if err := os.RemoveAll(tree); err != nil {
return err
}
if err := os.MkdirAll(tree, 0o700); err != nil {
return err
}
}
for _, file := range self.PatchBuilder.FilesInPatch() {
content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, file.ContentPath).RunWithOutput()
// A file the patch adds has no content on the before side, so git has nothing to
// show for it.
added := err != nil
// The before side holds an added file as an empty file rather than not at all, so
// that the diff pairs the two sides up and states the file's real path, instead of
// reporting a file that only one of the trees has.
if err := self.os.CreateFileWithContent(filepath.Join(before, file.Path),
lo.Ternary(added, "", content)); err != nil {
return err
}
// The after side is seeded with the same content, for the patch to change; a file
// the patch adds is left absent, for the patch to create.
if !added {
if err := self.os.CreateFileWithContent(filepath.Join(after, file.Path), content); err != nil {
return err
}
}
}
// Write added files as creations rather than as diffs against an empty file: the
// patch is applied in one go, so a file it expects to be there already would make
// the whole of it fail.
patchText := self.PatchBuilder.PatchToApply(false, false)
if strings.TrimSpace(patchText) == "" {
// Nothing in the patch, so the two trees are alike and the diff is empty.
return nil
}
patchFilePath, err := self.SaveTemporaryPatch(patchText)
if err != nil {
return err
}
return self.cmd.New(NewGitCmd("apply").
NoLineEndingConversion().
Arg(patchFilePath).
Dir(after).
ToArgv()).Run()
}
type ApplyPatchOpts struct {
ThreeWay bool
Cached bool
+2 -3
View File
@@ -59,10 +59,9 @@ 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, tagNames []string) error {
func (self *RemoteCommands) DeleteRemoteTag(task gocui.Task, remoteName string, tagName string) error {
cmdArgs := NewGitCmd("push").
Arg(remoteName, "--delete").
Arg(lo.Map(tagNames, func(t string, _ int) string { return "refs/tags/" + t })...).
Arg(remoteName, "--delete", "refs/tags/"+tagName).
ToArgv()
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
+3 -3
View File
@@ -80,14 +80,14 @@ func (self *StashCommands) Hash(index int) (string, error) {
return strings.Trim(hash, "\r\n"), err
}
func (self *StashCommands) ShowStashEntryCmdObj(index int, mode DiffMode) *oscommands.CmdObj {
func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj {
// "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason
cmdArgs := NewGitCmd("stash").Arg("show").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
Arg("-p").
Arg("--stat").
Arg("-u").
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg(fmt.Sprintf("refs/stash@{%d}", index)).
Dir(self.repoPaths.worktreePath).
ToArgv()
+2 -2
View File
@@ -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", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
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}"},
},
{
testName: "Show diff using git's external diff config",
@@ -174,7 +174,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
}
instance := buildStashCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
cmdStr := instance.ShowStashEntryCmdObj(s.index, DiffModeRendered).Args()
cmdStr := instance.ShowStashEntryCmdObj(s.index).Args()
assert.Equal(t, s.expected, cmdStr)
})
}
+11 -25
View File
@@ -6,7 +6,6 @@ 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 {
@@ -19,21 +18,18 @@ 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 len(opts.Refspecs) > 0 && opts.Remote == "" {
if opts.UpstreamBranch != "" && opts.UpstreamRemote == "" {
return nil, errors.New(self.Tr.MustSpecifyOriginError)
}
@@ -41,8 +37,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.Remote != "", opts.Remote).
Arg(opts.Refspecs...).
ArgIf(opts.UpstreamRemote != "", opts.UpstreamRemote).
ArgIf(opts.UpstreamBranch != "", fmt.Sprintf("refs/heads/%s:%s", opts.CurrentBranch, opts.UpstreamBranch)).
ToArgv()
cmdObj := self.cmd.New(cmdArgs).PromptOnCredentialRequest(task)
@@ -114,25 +110,15 @@ func (self *SyncCommands) Pull(task gocui.Task, opts PullOptions) error {
return self.cmd.New(cmdArgs).AddEnvVars("GIT_SEQUENCE_EDITOR=:").PromptOnCredentialRequest(task).Run()
}
// 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(
func (self *SyncCommands) FastForward(
task gocui.Task,
branchName string,
remoteName string,
remoteBranchNames []string,
remoteBranchName 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(refspecs...).
Arg("refs/heads/" + remoteBranchName + ":" + branchName).
ToArgv()
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
+13 -22
View File
@@ -41,11 +41,12 @@ func TestSyncPush(t *testing.T) {
},
},
{
testName: "Push with force disabled, refspec supplied",
testName: "Push with force disabled, upstream supplied",
opts: PushOpts{
ForceWithLease: false,
Remote: "origin",
Refspecs: []string{"refs/heads/master:master"},
CurrentBranch: "master",
UpstreamRemote: "origin",
UpstreamBranch: "master",
},
test: func(cmdObj *oscommands.CmdObj, err error) {
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "origin", "refs/heads/master:master"})
@@ -56,8 +57,9 @@ func TestSyncPush(t *testing.T) {
testName: "Push with force disabled, setting upstream",
opts: PushOpts{
ForceWithLease: false,
Remote: "origin",
Refspecs: []string{"refs/heads/master-local:master"},
CurrentBranch: "master-local",
UpstreamRemote: "origin",
UpstreamBranch: "master",
SetUpstream: true,
},
test: func(cmdObj *oscommands.CmdObj, err error) {
@@ -69,8 +71,9 @@ func TestSyncPush(t *testing.T) {
testName: "Push with force-with-lease enabled, setting upstream",
opts: PushOpts{
ForceWithLease: true,
Remote: "origin",
Refspecs: []string{"refs/heads/master:master"},
CurrentBranch: "master",
UpstreamRemote: "origin",
UpstreamBranch: "master",
SetUpstream: true,
},
test: func(cmdObj *oscommands.CmdObj, err error) {
@@ -79,23 +82,11 @@ func TestSyncPush(t *testing.T) {
},
},
{
testName: "Push several refspecs",
testName: "Push with remote branch but no origin",
opts: PushOpts{
ForceWithLease: true,
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"},
UpstreamRemote: "",
UpstreamBranch: "master",
SetUpstream: true,
},
test: func(cmdObj *oscommands.CmdObj, err error) {
+4 -6
View File
@@ -5,7 +5,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/samber/lo"
)
type TagCommands struct {
@@ -47,16 +46,15 @@ func (self *TagCommands) HasTag(tagName string) bool {
return self.cmd.New(cmdArgs).DontLog().Run() == nil
}
func (self *TagCommands) LocalDelete(tagNames []string) error {
cmdArgs := NewGitCmd("tag").Arg("-d").Arg(tagNames...).
func (self *TagCommands) LocalDelete(tagName string) error {
cmdArgs := NewGitCmd("tag").Arg("-d", tagName).
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
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} })...).
func (self *TagCommands) Push(task gocui.Task, remoteName string, tagName string) error {
cmdArgs := NewGitCmd("push").Arg(remoteName, "tag", tagName).
ToArgv()
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
+20 -46
View File
@@ -383,9 +383,9 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
}
// WorktreeFileDiff returns the diff of a file
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, mode DiffMode, cached bool) string {
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
// for now we assume an error means the file was deleted
s, _ := self.WorktreeFileDiffCmdObj(file, mode, cached, file.Names()).RunWithOutput()
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, file.Names()).RunWithOutput()
return s
}
@@ -393,13 +393,18 @@ func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, mode DiffMo
// in the working tree. node is the item they belong to; all it decides is
// whether git has to compare against /dev/null, which is the case for a file
// that isn't in the index yet.
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, mode DiffMode, cached bool, paths []string) *oscommands.CmdObj {
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, paths []string) *oscommands.CmdObj {
colorArg := self.diffRendererConfigManager.GetColorArg()
if plain {
colorArg = "never"
}
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
cmdArgs := NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(fmt.Sprintf("--color=%s", colorArg)).
ArgIf(cached, "--cached").
ArgIf(noIndex, "--no-index").
Arg("--").
@@ -415,20 +420,25 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, mode
// but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode.
// For a renamed file, previousPath is the path it was renamed from (empty otherwise);
// both paths must be passed to git for the rename to be detected.
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, mode DiffMode) (string, error) {
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, plain bool) (string, error) {
fileNames := []string{fileName}
if previousPath != "" {
fileNames = append(fileNames, previousPath)
}
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, mode).RunWithOutput()
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain).RunWithOutput()
}
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, mode DiffMode) *oscommands.CmdObj {
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj {
colorArg := self.diffRendererConfigManager.GetColorArg()
if plain {
colorArg = "never"
}
cmdArgs := NewGitCmd("diff").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(fmt.Sprintf("--color=%s", colorArg)).
Arg(from).
Arg(to).
ArgIf(reverse, "-R").
@@ -514,42 +524,6 @@ 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()
+15 -15
View File
@@ -197,7 +197,7 @@ func TestWorkingTreeDiff(t *testing.T) {
type scenario struct {
testName string
file *models.File
mode DiffMode
plain bool
cached bool
ignoreWhitespace bool
contextSize uint64
@@ -215,7 +215,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
mode: DiffModeRendered,
plain: false,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -230,7 +230,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
mode: DiffModeRendered,
plain: false,
cached: true,
ignoreWhitespace: false,
contextSize: 3,
@@ -245,7 +245,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
mode: DiffModePlain,
plain: true,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -260,7 +260,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: false,
},
mode: DiffModeRendered,
plain: false,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -275,7 +275,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
mode: DiffModeRendered,
plain: false,
cached: false,
ignoreWhitespace: true,
contextSize: 3,
@@ -290,7 +290,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
mode: DiffModeRendered,
plain: false,
cached: false,
ignoreWhitespace: false,
contextSize: 17,
@@ -305,7 +305,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
mode: DiffModeRendered,
plain: false,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -326,7 +326,7 @@ func TestWorkingTreeDiff(t *testing.T) {
}
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
result := instance.WorktreeFileDiff(s.file, s.mode, s.cached)
result := instance.WorktreeFileDiff(s.file, s.plain, s.cached)
assert.Equal(t, expectedResult, result)
s.runner.CheckForMissingCalls()
})
@@ -341,7 +341,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
reverse bool
fileName string
previousPath string
mode DiffMode
plain bool
ignoreWhitespace bool
contextSize uint64
runner *oscommands.FakeCmdObjRunner
@@ -356,7 +356,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
to: "0987654321",
reverse: false,
fileName: "test.txt",
mode: DiffModeRendered,
plain: false,
ignoreWhitespace: false,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
@@ -368,7 +368,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
to: "0987654321",
reverse: false,
fileName: "test.txt",
mode: DiffModeRendered,
plain: false,
ignoreWhitespace: false,
contextSize: 123,
runner: oscommands.NewFakeRunner(t).
@@ -380,7 +380,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
to: "0987654321",
reverse: false,
fileName: "test.txt",
mode: DiffModeRendered,
plain: false,
ignoreWhitespace: true,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
@@ -393,7 +393,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
reverse: false,
fileName: "new.txt",
previousPath: "old.txt",
mode: DiffModeRendered,
plain: false,
ignoreWhitespace: false,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
@@ -412,7 +412,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.mode)
result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.plain)
assert.NoError(t, err)
assert.Equal(t, expectedResult, result)
s.runner.CheckForMissingCalls()
@@ -140,14 +140,12 @@ 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
}
}
-21
View File
@@ -31,13 +31,6 @@ 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
@@ -47,13 +40,6 @@ 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 {
@@ -133,13 +119,6 @@ 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 != ""
-3
View File
@@ -19,9 +19,6 @@ 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
-8
View File
@@ -108,14 +108,6 @@ 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,16 +33,6 @@ 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:]...)
+43 -5
View File
@@ -4,10 +4,12 @@ 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"
@@ -201,25 +203,61 @@ 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 {
c.logPipeline(cmdObjs)
cmds := lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) *exec.Cmd {
return cmdObj.GetCmd()
})
cmds, parentEnds, err := wirePipeline(cmdObjs)
if err != nil {
return err
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
}
// 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]
}
started, startErr := startPipeline(cmds, parentEnds)
// 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++
}
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] {
-180
View File
@@ -1,180 +0,0 @@
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()
}
}
-150
View File
@@ -1,150 +0,0 @@
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")
}
}
+3 -1
View File
@@ -144,7 +144,9 @@ 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.
// 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).
//
// The pseudoconsole close gets its own goroutine because the kill must not
// wait for it: on builds where ClosePseudoConsole blocks until the console
-5
View File
@@ -16,11 +16,6 @@ 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
}
+11 -26
View File
@@ -7,9 +7,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
)
// 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+))? @@(.*)$`)
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
func Parse(patchStr string) *Patch {
// ignore trailing newline.
@@ -21,15 +19,13 @@ func Parse(patchStr string) *Patch {
var currentHunk *Hunk
for _, line := range lines {
if strings.HasPrefix(line, "@@") {
oldStart, oldLength, newStart, newLength, headerContext := headerInfo(line)
oldStart, newStart, headerContext := headerInfo(line)
currentHunk = &Hunk{
oldStart: oldStart,
newStart: newStart,
declaredOldLength: oldLength,
declaredNewLength: newLength,
headerContext: headerContext,
bodyLines: []*PatchLine{},
oldStart: oldStart,
newStart: newStart,
headerContext: headerContext,
bodyLines: []*PatchLine{},
}
hunks = append(hunks, currentHunk)
} else if currentHunk != nil {
@@ -45,25 +41,14 @@ func Parse(patchStr string) *Patch {
}
}
func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLength int, headerContext string) {
func headerInfo(header string) (int, int, string) {
match := hunkHeaderRegexp.FindStringSubmatch(header)
oldStart = utils.MustConvertToInt(match[1])
oldLength = declaredLength(match[2])
newStart = utils.MustConvertToInt(match[3])
newLength = declaredLength(match[4])
headerContext = match[5]
oldStart := utils.MustConvertToInt(match[1])
newStart := utils.MustConvertToInt(match[2])
headerContext := match[3]
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)
return oldStart, newStart, headerContext
}
func newHunkLine(line string) *PatchLine {
+14 -69
View File
@@ -79,44 +79,6 @@ 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()
@@ -152,37 +114,6 @@ 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 {
@@ -265,3 +196,17 @@ func (self *Patch) AdjustLineNumber(lineNumber int) int {
return adjustedLineNumber
}
func (self *Patch) IsSingleHunkForWholeFile() bool {
if len(self.hunks) != 1 {
return false
}
// We consider a patch to be a single hunk for the whole file if it has only additions or
// deletions but not both, and no context lines. This not quite correct, because it will also
// return true for a block of added or deleted lines if the diff context size is 0, but in this
// case you wouldn't be able to stage things anyway, so it doesn't matter.
bodyLines := self.hunks[0].bodyLines
return nLinesWithKind(bodyLines, []PatchLineKind{DELETION, CONTEXT}) == 0 ||
nLinesWithKind(bodyLines, []PatchLineKind{ADDITION, CONTEXT}) == 0
}
+3 -217
View File
@@ -1,12 +1,10 @@
package patch
import (
"os"
"sort"
"strings"
"github.com/jesseduffield/generics/maps"
"github.com/jesseduffield/generics/set"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
@@ -35,7 +33,7 @@ type fileInfo struct {
}
type (
loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string) (string, error)
loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error)
)
// PatchBuilder manages the building of a patch for a commit to be applied to another commit (or the working tree, or removed from the current commit). We also support building patches from things like stashes, for which there is less flexibility
@@ -62,27 +60,12 @@ type PatchBuilder struct {
// loadFileDiff loads the diff of a file, for a given to (typically a commit hash)
loadFileDiff loadFileDiffFunc
// newTempDir makes a directory for the current patch to be materialized into, as
// two file trees that can be diffed against each other and so rendered like any
// other diff (see PatchCommands.WriteCustomPatchDiffTrees). Its lifetime is the
// patch's: made when one is started, removed when it is given up.
newTempDir func() (string, error)
tempDir string
// generation counts the changes made to the patch, so that whoever materializes it
// can tell whether what they last built still describes it — and rebuild only then,
// rather than on every render of it.
generation int
}
func NewPatchBuilder(
log *logrus.Entry, loadFileDiff loadFileDiffFunc, newTempDir func() (string, error),
) *PatchBuilder {
func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBuilder {
return &PatchBuilder{
Log: log,
loadFileDiff: loadFileDiff,
newTempDir: newTempDir,
}
}
@@ -90,9 +73,6 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.generation++
p.makeTempDir()
p.To = to
p.From = from
p.reverse = reverse
@@ -111,89 +91,6 @@ func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo {
return p.fileInfoMap
}
// TempDir is the directory the patch is materialized into for rendering, and "" when
// there is none — no patch, or a directory we failed to make.
func (p *PatchBuilder) TempDir() string {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.tempDir
}
// Generation says which version of the patch this is; see the field.
func (p *PatchBuilder) Generation() int {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.generation
}
// makeTempDir replaces the directory the patch is materialized into with a fresh one.
// Only call this with the lock held.
func (p *PatchBuilder) makeTempDir() {
p.removeTempDir()
if p.newTempDir == nil {
return
}
dir, err := p.newTempDir()
if err != nil {
p.Log.Error(err)
return
}
p.tempDir = dir
}
// removeTempDir takes the patch's materialized form away with the patch. Only call this
// with the lock held.
func (p *PatchBuilder) removeTempDir() {
if p.tempDir == "" {
return
}
if err := os.RemoveAll(p.tempDir); err != nil {
p.Log.Error(err)
}
p.tempDir = ""
}
// PatchFile records what materializing the patch needs to know about one of its files:
// where the patch expects to find it, and where its content before the patch comes from.
type PatchFile struct {
// Path is the name the patch knows the file by: for a renamed file, the name it had
// before where the patch carries the rename, and the name it was renamed to where the
// patch keeps only a content change and leaves the rename behind.
Path string
// ContentPath is where the file's content before the patch is to be found in the
// commit the patch is built from — for a renamed file always the name it had there,
// whatever the patch calls it.
ContentPath string
}
// FilesInPatch says which files the patch touches, in a stable order, and where each of
// them comes from.
func (p *PatchBuilder) FilesInPatch() []PatchFile {
fileInfoMap := p.snapshotFileInfoMap()
filenames := maps.Keys(fileInfoMap)
sort.Strings(filenames)
files := make([]PatchFile, 0, len(filenames))
for _, filename := range filenames {
info := fileInfoMap[filename]
if info.mode == UNSELECTED {
continue
}
file := PatchFile{Path: filename, ContentPath: filename}
if info.previousPath != "" {
file.ContentPath = info.previousPath
if info.mode == WHOLE {
file.Path = info.previousPath
}
}
files = append(files, file)
}
return files
}
func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string {
var patch strings.Builder
@@ -238,7 +135,6 @@ func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error
return err
}
p.generation++
p.addFileWhole(info)
return nil
@@ -250,7 +146,6 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
return err
}
p.generation++
p.removeFile(info)
return nil
@@ -267,7 +162,7 @@ func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileI
return info, nil
}
diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath)
diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath, true)
if err != nil {
return nil, err
}
@@ -287,7 +182,6 @@ func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, li
if err != nil {
return err
}
p.generation++
info.mode = PART
info.includedLineIndices = lo.Union(info.includedLineIndices, lineIndices)
@@ -299,7 +193,6 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string,
if err != nil {
return err
}
p.generation++
info.mode = PART
info.includedLineIndices, _ = lo.Difference(info.includedLineIndices, lineIndices)
if len(info.includedLineIndices) == 0 {
@@ -398,110 +291,6 @@ func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus
return info.mode
}
// LineIdentity says which change line of a file is meant — the line number it has on
// the side it belongs to, and whether it is a deletion — without reference to where
// that line sits in the file's parsed diff.
//
// It is how a diff shown in the main view speaks about its lines: what a rendered row
// resolves to is a line of a file, while the index of that line in the diff depends on
// how much of the diff is being shown and in what order a renderer laid it out.
type LineIdentity struct {
LineNumber int
IsDeletion bool
}
// ChangeLineIndexByIdentity indexes a parsed diff's change lines by their identity. An
// addition is numbered in the new file and a deletion in the old one. Two consecutive
// deletions share the one new-file position between them, and numbering them in the
// old file keeps them apart.
func ChangeLineIndexByIdentity(parsed *Patch) map[LineIdentity]int {
byIdentity := map[LineIdentity]int{}
for idx, line := range parsed.Lines() {
switch {
case line.IsAddition():
byIdentity[LineIdentity{parsed.LineNumberOfLine(idx), false}] = idx
case line.IsDeletion():
byIdentity[LineIdentity{parsed.OldLineNumberOfLine(idx), true}] = idx
}
}
return byIdentity
}
// ChangeLineIndicesForLines maps the given change lines of a parsed diff to their
// indices in it. A line that names no change line of the diff — a context line, or a
// line that isn't in the diff at all — contributes nothing.
func ChangeLineIndicesForLines(parsed *Patch, lines []LineIdentity) []int {
byIdentity := ChangeLineIndexByIdentity(parsed)
indices := make([]int, 0, len(lines))
for _, line := range lines {
if idx, ok := byIdentity[line]; ok {
indices = append(indices, idx)
}
}
return indices
}
// PatchLineIndicesForLines maps change lines of filename to their indices in that
// file's diff; the patch is built in terms of those indices. everyChange reports
// whether the given lines cover all of the file's changes; this distinguishes acting
// on some of a file's lines from acting on the file itself.
func (p *PatchBuilder) PatchLineIndicesForLines(
filename string, previousPath string, lines []LineIdentity,
) (indices []int, everyChange bool, err error) {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
return nil, false, err
}
parsed := Parse(info.diff)
selected := set.NewFromSlice(lines)
everyChange = lo.EveryBy(maps.Keys(ChangeLineIndexByIdentity(parsed)),
func(identity LineIdentity) bool { return selected.Includes(identity) })
return ChangeLineIndicesForLines(parsed, lines), everyChange, nil
}
// IncludedLineIdentities says which change lines of filename are in the patch, as the
// identities a diff of that file shown anywhere can be compared against. Empty for a
// file that is no part of the patch.
func (p *PatchBuilder) IncludedLineIdentities(filename string) []LineIdentity {
info, ok := p.snapshotFileInfoMap()[filename]
if !ok || info.mode == UNSELECTED {
return nil
}
included := set.NewFromSlice(info.includedLineIndices)
identities := []LineIdentity{}
for identity, idx := range ChangeLineIndexByIdentity(Parse(info.diff)) {
if included.Includes(idx) {
identities = append(identities, identity)
}
}
return identities
}
// IncludedChangeLineIndices says which of filename's change lines are in the patch, as
// their indices in the file's diff and in the order the file has them.
//
// It is how a line of the patch as it is shown names the line of the diff it came from:
// all that can be said about a line of the patch is which of the file's changes it is,
// its line numbers being the patch's own — a patch that leaves an earlier addition out
// numbers everything after it differently from the diff it was built from.
func (p *PatchBuilder) IncludedChangeLineIndices(filename string) []int {
info, ok := p.snapshotFileInfoMap()[filename]
if !ok || info.mode == UNSELECTED {
return nil
}
included := set.NewFromSlice(info.includedLineIndices)
indices := []int{}
for idx, line := range Parse(info.diff).Lines() {
if (line.IsAddition() || line.IsDeletion()) && included.Includes(idx) {
indices = append(indices, idx)
}
}
return indices
}
func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath string) ([]int, error) {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
@@ -515,9 +304,6 @@ func (p *PatchBuilder) Reset() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.generation++
p.removeTempDir()
p.To = ""
p.fileInfoMap = map[string]*fileInfo{}
}
-140
View File
@@ -1,140 +0,0 @@
package patch
import (
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
// newTestPatchBuilder returns a patch builder started for a dummy commit, in which
// every file's diff is the given one.
func newTestPatchBuilder(diff string) *PatchBuilder {
patchBuilder := NewPatchBuilder(logrus.New().WithField("test", "test"),
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
return diff, nil
},
// Nothing here renders the patch, so it needs no directory to be
// materialized into.
nil)
patchBuilder.Start("from", "to", false, true)
return patchBuilder
}
// In simpleDiff the deletion "-orange" is line index 6 of the parsed diff (line 2 of
// the old file) and the addition "+grape" is index 7 (line 2 of the new file).
func TestPatchLineIndicesForLines(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
indices, everyChange, err := patchBuilder.PatchLineIndicesForLines("filename", "", []LineIdentity{
{LineNumber: 2, IsDeletion: true}, // -orange
{LineNumber: 2, IsDeletion: false}, // +grape
{LineNumber: 1, IsDeletion: false}, // " apple", a context line
})
assert.NoError(t, err)
assert.Equal(t, []int{6, 7}, indices, "the context line names no change line")
assert.True(t, everyChange, "the two changes are all the diff has")
}
// A renamed file's rename header makes its change lines sit further down the diff, and
// its old-file line numbers are of the file under its previous name.
func TestPatchLineIndicesForLinesOfARenamedFile(t *testing.T) {
patchBuilder := newTestPatchBuilder(renameWithModificationDiff)
indices, everyChange, err := patchBuilder.PatchLineIndicesForLines("newname", "oldname", []LineIdentity{
{LineNumber: 2, IsDeletion: true}, // -orange
{LineNumber: 2, IsDeletion: false}, // +grape
})
assert.NoError(t, err)
assert.Equal(t, []int{9, 10}, indices)
assert.True(t, everyChange)
}
// everyChange is about the diff alone: whether anything the file changes was left out
// of the selection. What that then means for the patch is the caller's question.
func TestPatchLineIndicesForLinesEveryChange(t *testing.T) {
patchBuilder := newTestPatchBuilder(newFile)
_, everyChange, err := patchBuilder.PatchLineIndicesForLines("newfile", "", []LineIdentity{
{LineNumber: 1},
{LineNumber: 2},
})
assert.NoError(t, err)
assert.False(t, everyChange, "the file's third added line is left out")
_, everyChange, err = patchBuilder.PatchLineIndicesForLines("newfile", "", []LineIdentity{
{LineNumber: 1},
{LineNumber: 2},
{LineNumber: 3},
})
assert.NoError(t, err)
assert.True(t, everyChange)
// A line the diff doesn't have doesn't stand in for one it does.
patchBuilder = newTestPatchBuilder(deletedFile)
_, everyChange, err = patchBuilder.PatchLineIndicesForLines("newfile", "", []LineIdentity{
{LineNumber: 1, IsDeletion: true},
{LineNumber: 2, IsDeletion: true},
{LineNumber: 4, IsDeletion: true},
})
assert.NoError(t, err)
assert.False(t, everyChange)
}
func TestIncludedLineIdentities(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
// A file no part of the patch has nothing included.
assert.Empty(t, patchBuilder.IncludedLineIdentities("filename"))
// With only the deletion in, only its identity comes back.
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6}))
assert.Equal(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}},
patchBuilder.IncludedLineIdentities("filename"))
// With the addition in as well, both do.
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{7}))
assert.ElementsMatch(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
patchBuilder.IncludedLineIdentities("filename"))
}
func TestFilesInPatch(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
// A file no part of the patch is no part of what the patch is materialized from.
assert.Empty(t, patchBuilder.FilesInPatch())
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6}))
assert.Equal(t,
[]PatchFile{{Path: "filename", ContentPath: "filename"}},
patchBuilder.FilesInPatch())
}
// A renamed file's content is under the name it had before whatever the patch calls the
// file, and the patch calls it by the name it had before only where it carries the
// rename — a partial selection has the rename stripped and names the file by the new one.
func TestFilesInPatchOfARenamedFile(t *testing.T) {
patchBuilder := newTestPatchBuilder(renameWithModificationDiff)
assert.NoError(t, patchBuilder.AddFileLineRange("newname", "oldname", []int{9}))
assert.Equal(t,
[]PatchFile{{Path: "newname", ContentPath: "oldname"}},
patchBuilder.FilesInPatch())
assert.NoError(t, patchBuilder.AddFileWhole("newname", "oldname"))
assert.Equal(t,
[]PatchFile{{Path: "oldname", ContentPath: "oldname"}},
patchBuilder.FilesInPatch())
}
// A file taken into the patch whole has every one of its change lines in it.
func TestIncludedLineIdentitiesOfAWholeFile(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
assert.NoError(t, patchBuilder.AddFileWhole("filename", ""))
assert.ElementsMatch(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
patchBuilder.IncludedLineIdentities("filename"))
}
+61 -156
View File
@@ -120,20 +120,6 @@ 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
@@ -696,148 +682,6 @@ 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
@@ -921,3 +765,64 @@ func TestAdjustLineNumber(t *testing.T) {
})
}
}
func TestIsSingleHunkForWholeFile(t *testing.T) {
scenarios := []struct {
testName string
patchStr string
expectedResult bool
}{
{
testName: "simpleDiff",
patchStr: simpleDiff,
expectedResult: false,
},
{
testName: "addNewlineToEndOfFile",
patchStr: addNewlineToEndOfFile,
expectedResult: false,
},
{
testName: "removeNewlinefromEndOfFile",
patchStr: removeNewlinefromEndOfFile,
expectedResult: false,
},
{
testName: "twoHunks",
patchStr: twoHunks,
expectedResult: false,
},
{
testName: "twoChangesInOneHunk",
patchStr: twoChangesInOneHunk,
expectedResult: false,
},
{
testName: "newFile",
patchStr: newFile,
expectedResult: true,
},
{
testName: "deletedFile",
patchStr: deletedFile,
expectedResult: true,
},
{
testName: "addNewlineToPreviouslyEmptyFile",
patchStr: addNewlineToPreviouslyEmptyFile,
expectedResult: true,
},
{
testName: "exampleHunk",
patchStr: exampleHunk,
expectedResult: false,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
patch := Parse(s.patchStr)
assert.Equal(t, s.expectedResult, patch.IsSingleHunkForWholeFile())
})
}
}
+5 -59
View File
@@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"reflect"
"regexp"
"runtime"
"strings"
"time"
@@ -297,8 +296,6 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
{[]string{"keybinding", "universal", "cyclePagers"}, "cycleDiffRenderers"},
{[]string{"keybinding", "universal", "cyclePagersReverse"}, "cycleDiffRenderersReverse"},
{[]string{"gui", "windowSize"}, "screenMode"},
{[]string{"gui", "wrapLinesInStagingView"}, "wrapLinesInDiffView"},
{[]string{"gui", "useHunkModeInStagingView"}, "useHunkModeInDiffView"},
{[]string{"keybinding", "files", "openMergeTool"}, "openMergeOptions"},
}
@@ -312,13 +309,6 @@ 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
@@ -327,14 +317,6 @@ 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 {
@@ -648,43 +630,6 @@ 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 != ""
}
@@ -900,10 +845,11 @@ func (c *AppConfig) SaveGlobalUserConfig() {
// AppState stores data between runs of the app like when the last update check
// was performed and which other repos have been checked out
type AppState struct {
LastUpdateCheck int64
RecentRepos []string
StartupPopupVersion int
LastVersion string // this is the last version the user was using, for the purpose of showing release notes
LastUpdateCheck int64
RecentRepos []string
StartupPopupVersion int
DidShowHunkStagingHint bool
LastVersion string // this is the last version the user was using, for the purpose of showing release notes
// these are for shell commands typed in directly, not for custom commands in the lazygit config.
// For backwards compatibility we keep the old name in yaml files.
-158
View File
@@ -108,22 +108,6 @@ func TestMigrationOfRenamedKeys(t *testing.T) {
"Renamed 'gui.windowSize' to 'screenMode'",
},
},
{
name: "Rename staging view options",
input: `gui:
wrapLinesInStagingView: false
useHunkModeInStagingView: true
`,
expected: `gui:
wrapLinesInDiffView: false
useHunkModeInDiffView: true
`,
expectedDidChange: true,
expectedChanges: []string{
"Renamed 'gui.wrapLinesInStagingView' to 'wrapLinesInDiffView'",
"Renamed 'gui.useHunkModeInStagingView' to 'useHunkModeInDiffView'",
},
},
}
for _, s := range scenarios {
@@ -191,32 +175,6 @@ 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 {
@@ -863,119 +821,3 @@ 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())
})
}
}
-69
View File
@@ -1,69 +0,0 @@
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 })
}))
}
-64
View File
@@ -1,64 +0,0 @@
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)
}
+14 -49
View File
@@ -1,9 +1,8 @@
package config
import (
"fmt"
"strconv"
"strings"
"text/template"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
@@ -61,23 +60,18 @@ func (self *DiffRendererConfigManager) GetDiffRendererType() DiffRendererType {
return currentDiffRendererConfig.getType()
}
// 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) {
func (self *DiffRendererConfigManager) GetStdinFilterCommand(width int) string {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_StdinFilter {
return "", nil
return ""
}
return currentDiffRendererConfig.resolveCommand(values)
templateValues := map[string]string{
"columnWidth": strconv.Itoa(width/2 - 6),
}
commandTemplate := string(currentDiffRendererConfig.Command)
return utils.ResolvePlaceholderString(commandTemplate, templateValues)
}
func (self *DiffRendererConfigManager) GetColorArg() string {
@@ -93,46 +87,17 @@ func (self *DiffRendererConfigManager) GetColorArg() string {
return colorArg
}
func (self *DiffRendererConfigManager) GetExternalDiffCommand(values DiffRendererValues) (string, error) {
func (self *DiffRendererConfigManager) GetExternalDiffCommand(diffContext uint64) string {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff {
return "", nil
return ""
}
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
templateValues := map[string]string{
"diffContext": strconv.Itoa(int(diffContext)),
}
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
return utils.ResolvePlaceholderString(string(currentDiffRendererConfig.Command), templateValues)
}
func (self *DiffRendererConfigManager) GetRawGitArgs() []string {
@@ -63,158 +63,6 @@ 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{} })
-105
View File
@@ -1,105 +0,0 @@
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]))
}
}
-127
View File
@@ -1,127 +0,0 @@
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
}
+27 -43
View File
@@ -68,6 +68,13 @@ 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"`
@@ -87,7 +94,7 @@ type GuiConfig struct {
MouseEvents bool `yaml:"mouseEvents"`
// If true, do not show a warning when amending a commit.
SkipAmendWarning bool `yaml:"skipAmendWarning"`
// If true, do not show a warning when discarding changes from a focused diff.
// If true, do not show a warning when discarding changes in the staging view.
SkipDiscardChangeWarning bool `yaml:"skipDiscardChangeWarning"`
// If true, do not show warning when applying/popping the stash
SkipStashWarning bool `yaml:"skipStashWarning"`
@@ -122,10 +129,10 @@ type GuiConfig struct {
// - 'left': split the window horizontally (side panel on the left, main view on the right)
// - 'top': split the window vertically (side panel on top, main view below)
EnlargedSideViewLocation string `yaml:"enlargedSideViewLocation"`
// If true, wrap lines in focused diffs to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.
WrapLinesInDiffView bool `yaml:"wrapLinesInDiffView"`
// If true, hunk selection mode will be enabled by default when focusing a diff.
UseHunkModeInDiffView bool `yaml:"useHunkModeInDiffView"`
// If true, wrap lines in the staging view to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.
WrapLinesInStagingView bool `yaml:"wrapLinesInStagingView"`
// If true, hunk selection mode will be enabled by default when entering the staging view.
UseHunkModeInStagingView bool `yaml:"useHunkModeInStagingView"`
// One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru' | 'pt'
Language string `yaml:"language" jsonschema:"enum=auto,enum=en,enum=zh-TW,enum=zh-CN,enum=pl,enum=nl,enum=ja,enum=ko,enum=ru"`
// Format used when displaying time e.g. commit time.
@@ -134,19 +141,9 @@ 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
@@ -240,15 +237,10 @@ 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"`
@@ -262,10 +254,6 @@ 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 {
@@ -328,7 +316,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). 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.
// 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).
// 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
@@ -672,8 +660,6 @@ 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"`
@@ -890,24 +876,24 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
},
MainPanelSplitMode: "flexible",
EnlargedSideViewLocation: "left",
WrapLinesInDiffView: true,
UseHunkModeInDiffView: true,
WrapLinesInStagingView: true,
UseHunkModeInStagingView: true,
Language: "auto",
TimeFormat: "02 Jan 06",
ShortTimeFormat: time.Kitchen,
ColorScheme: "auto",
Theme: ThemeConfig{
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"},
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"},
},
CommitLength: CommitLengthConfig{Show: true},
SkipNoStagedFilesWarning: false,
@@ -1184,8 +1170,6 @@ 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"},
+4 -18
View File
@@ -18,10 +18,6 @@ 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
@@ -124,16 +120,10 @@ 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'.")
@@ -145,14 +135,6 @@ 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
@@ -228,6 +210,10 @@ var ValidCustomCommandContexts = []string{
"stash",
"normal",
"normalSecondary",
"staging",
"stagingSecondary",
"patchBuilding",
"patchBuildingSecondary",
"mergeConflicts",
"menu",
"confirmation",
@@ -242,10 +242,6 @@ func TestUserConfigValidate_enums(t *testing.T) {
{value: "commits,subCommits", valid: true},
{value: "invalid_value", valid: false},
{value: "commits, invalid_value", valid: false},
// The staging and patch-building panels are gone, so a config that
// still names their contexts is reported rather than fatal.
{value: "staging", valid: false},
{value: "patchBuilding", valid: false},
},
},
{
@@ -413,14 +409,9 @@ 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},
+5 -49
View File
@@ -50,22 +50,6 @@ func setupKeyRecorder(t *testing.T, g *Gui) (GocuiEvent, *[]int) {
return GocuiEvent{Type: eventKey, Key: key}, &fired
}
// runQueuedWork runs what the gui has queued for the following passes of the
// event loop, which is where EndBlockingEvents leaves the replay of the keys it
// buffered.
func runQueuedWork(t *testing.T, g *Gui) {
t.Helper()
for {
ev, ok := g.userEvents.dequeue()
if !ok {
return
}
assert.NoError(t, ev.f(g))
ev.task.Done()
}
}
func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) {
g := newTestGui(t)
keyEvent, fired := setupKeyRecorder(t, g)
@@ -80,37 +64,12 @@ func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) {
assert.NoError(t, g.handleEvent(&keyEvent))
assert.Len(t, *fired, 1, "buffered keys must not dispatch while blocking")
// Unblocking queues the replay rather than dispatching from here.
g.EndBlockingEvents()
assert.Len(t, *fired, 1, "the replay must wait for the event loop")
runQueuedWork(t, g)
// Unblocking replays the buffered keys.
assert.NoError(t, g.EndBlockingEvents())
assert.Len(t, *fired, 3, "both buffered keys should replay on unblock")
assert.Empty(t, g.bufferedKeyEvents)
}
func TestBlockingEvents_KeysArrivingBeforeTheReplayGoBehindIt(t *testing.T) {
g := newTestGui(t)
keyEvent, fired := setupKeyRecorder(t, g)
other := GocuiEvent{Type: eventKey, Key: NewKeyRune('y')}
g.SetKeybinding("main", other.Key, func(*Gui, *View) error {
*fired = append(*fired, 0)
return nil
})
g.BeginBlockingEvents()
assert.NoError(t, g.handleEvent(&keyEvent))
g.EndBlockingEvents()
// A key pressed while the replay is still queued joins the end of the buffer:
// dispatching it now would put it ahead of the keys buffered before it.
assert.NoError(t, g.handleEvent(&other))
assert.Empty(t, *fired)
runQueuedWork(t, g)
assert.Equal(t, []int{1, 0}, *fired, "the keys should arrive in the order they were pressed")
}
func TestBlockingEvents_NestsWithCounter(t *testing.T) {
g := newTestGui(t)
keyEvent, fired := setupKeyRecorder(t, g)
@@ -120,13 +79,11 @@ func TestBlockingEvents_NestsWithCounter(t *testing.T) {
assert.NoError(t, g.handleEvent(&keyEvent))
// The inner block ending still leaves us blocked: no replay yet.
g.EndBlockingEvents()
runQueuedWork(t, g)
assert.NoError(t, g.EndBlockingEvents())
assert.Empty(t, *fired)
// Only the outermost block ending replays.
g.EndBlockingEvents()
runQueuedWork(t, g)
assert.NoError(t, g.EndBlockingEvents())
assert.Len(t, *fired, 1)
}
@@ -137,6 +94,5 @@ func TestBlockingEvents_MouseClicksDroppedNotBuffered(t *testing.T) {
click := GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)}
assert.NoError(t, g.handleEvent(&click))
assert.Empty(t, g.bufferedKeyEvents, "mouse clicks must be dropped, not buffered")
g.EndBlockingEvents()
runQueuedWork(t, g)
assert.NoError(t, g.EndBlockingEvents())
}
-497
View File
@@ -1,497 +0,0 @@
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
}
-402
View File
@@ -1,402 +0,0 @@
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)
}
-34
View File
@@ -32,37 +32,3 @@ func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) {
assert.Equal(t, []bool{false, true}, doubleClicks)
}
func TestASwallowedClickIsNoHalfOfADoubleClick(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
g := newTestGui(t)
view, _ := g.SetView("list", 0, 0, 20, 10, 0)
doubleClicks := []bool{}
g.SetViewClickBinding(&ViewMouseBinding{
ViewName: "list",
Key: MouseLeft,
Handler: func(opts ViewMouseBindingOpts) error {
doubleClicks = append(doubleClicks, opts.IsDoubleClick)
return nil
},
})
press := gocuiEventFromTcellEvent(
tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone))
release := gocuiEventFromTcellEvent(
tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonNone, tcell.ModNone))
// A click the client rejects, as it does for one landing behind a popup panel.
g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return false }
assert.NoError(t, g.onKey(&press))
assert.NoError(t, g.onKey(&release))
assert.Empty(t, doubleClicks)
// The same spot clicked again once clicks are accepted. It is a click of its
// own, not the second half of the one that nothing acted on.
g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return true }
assert.NoError(t, g.onKey(&press))
assert.Equal(t, []bool{false}, doubleClicks)
}
+20 -97
View File
@@ -20,25 +20,6 @@ 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
@@ -101,9 +82,9 @@ const (
stateParams
stateCSIDiscard
stateOSC
stateOSCWaitForParams
stateOSCParams
stateOSCHyperlink
stateOSCMetadata
stateOSCEndEscape
stateOSCSkipUnknown
@@ -446,42 +427,27 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
}
return true, nil
case stateOSC:
// 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
}
if characterEquals(ch, '8') {
ei.state = stateOSCWaitForParams
ei.hyperlink.Reset()
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
@@ -497,18 +463,6 @@ 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
@@ -524,37 +478,6 @@ 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; {
-1
View File
@@ -167,7 +167,6 @@ 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
}
+5 -22
View File
@@ -64,8 +64,8 @@ func TestFlushContentOnly_SkipsUntaintedViews(t *testing.T) {
assert.True(t, status.IsTainted(), "status view should be tainted after SetContent")
assert.False(t, main.IsTainted(), "main view should not be tainted (was not modified)")
// flushContentOnly should clear status tainted flag
g.flushContentOnly(g.views)
// flushContentOnly should succeed and clear status tainted flag
assert.NoError(t, g.flushContentOnly(g.views))
assert.False(t, status.IsTainted(), "status view should not be tainted after flushContentOnly")
assert.False(t, main.IsTainted(), "main view should not be tainted after flushContentOnly")
@@ -76,28 +76,11 @@ func TestFlushContentOnly_WritesCorrectContent(t *testing.T) {
status, _ := setupViews(t, g)
status.SetContent("Fetching |")
g.flushContentOnly(g.views)
assert.NoError(t, g.flushContentOnly(g.views))
assert.Equal(t, "Fetching |", status.Buffer())
}
func TestForceFlushViewsContentOnlyDrawsLineFlash(t *testing.T) {
g := newTestGui(t)
_, main := setupViews(t, g)
main.Highlight = true
main.SelBgColor = ColorBlue
main.SelectedLineColorWidth = 2
main.FocusPoint(0, 0, false)
main.SetLineFlash(0)
g.ForceFlushViewsContentOnly(g.Views())
for x := main.x0 + 1; x <= main.x0+2; x++ {
_, style, _ := Screen.Get(x, main.y0+1)
assert.True(t, style.HasReverse(), "selection-bar cell at x=%d should flash", x)
}
}
func TestProcessEvent_ContentOnlyEvent_SkipsTaintedCheck(t *testing.T) {
g := newTestGui(t)
status, main := setupViews(t, g)
@@ -248,7 +231,7 @@ func TestFlushContentOnly_DoesNotOverdrawHigherZViews(t *testing.T) {
assert.False(t, popup.IsTainted(), "popup should not be tainted")
// flushContentOnly is what spinner ticks ultimately invoke.
g.flushContentOnly(g.views)
assert.NoError(t, g.flushContentOnly(g.views))
assert.Equal(t, "P", cellAt(21, 9),
"popup region must still show popup content after flushContentOnly; "+
@@ -296,7 +279,7 @@ func TestFlushContentOnly_RedrawsTransitivelyOverlappingViews(t *testing.T) {
assert.False(t, b.IsTainted())
assert.False(t, c.IsTainted())
g.flushContentOnly(g.views)
assert.NoError(t, g.flushContentOnly(g.views))
// a redrawn (direct).
assert.Equal(t, "X", cellAt(5, 5), "a should be redrawn (tainted)")
+117 -197
View File
@@ -91,14 +91,6 @@ type ViewMouseBinding struct {
// must be a mouse key
Key KeyName
// If true, this binding is dispatched before ShouldHandleMouseEvent is
// consulted, so it fires even when a popup panel is focused and the click
// lands on a view other than that panel (which is normally swallowed). This
// is the same early phase that hyperlink clicks are handled in; use it for
// clicks that must stay live behind a popup, e.g. opening a diff line in the
// editor from the main view behind the commit-message panel.
HandleWhenPopupPanelFocused bool
}
type ViewMouseBindingOpts struct {
@@ -235,22 +227,13 @@ 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
// dropped outright. It's a counter so blocking can nest. replayPending says
// that the replay is queued but hasn't run yet, and input is withheld until
// it has. All three fields are only touched on the UI thread. See
// BeginBlockingEvents.
// dropped outright. It's a counter so blocking can nest. Both fields are
// only touched on the UI thread. See BeginBlockingEvents.
blockInputCount int
bufferedKeyEvents []GocuiEvent
replayPending bool
}
type NewGuiOpts struct {
@@ -334,18 +317,6 @@ 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
}
@@ -403,7 +374,6 @@ func (g *Gui) WaitUntilIdle() {
// initialization and when gocui is not needed anymore.
func (g *Gui) Close() {
close(g.stop)
g.waitForColorSchemeReplies()
Screen.Fini()
}
@@ -421,12 +391,13 @@ func (g *Gui) Size() (x, y int) {
// corner of the terminal. It checks if the position is valid and applies
// the given colors.
// Should only be used if you know that the given rune is not part of a grapheme cluster.
func (g *Gui) SetRune(x, y int, ch rune, fgColor, bgColor Attribute) {
func (g *Gui) SetRune(x, y int, ch rune, fgColor, bgColor Attribute) error {
if x < 0 || y < 0 || x >= g.maxX || y >= g.maxY {
// swallowing error because it's not that big of a deal
return
return nil
}
tcellSetCell(x, y, string(ch), fgColor, bgColor, g.outputMode)
return nil
}
// SetView creates a new view with its top-left corner at (x0, y0)
@@ -448,7 +419,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er
v.y1 = y1
if sizeChanged {
v.RewrapContent()
v.ClearViewLines()
if v.Editable {
cursorX, cursorY := v.TextArea.GetCursorXY()
@@ -737,29 +708,6 @@ 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
}
@@ -940,33 +888,12 @@ func (g *Gui) BeginBlockingEvents() {
// normal dispatch path, so they act on the now-current context (a key whose
// binding no longer exists is simply ignored, just as if it had been pressed
// now). Must be called on the UI thread.
//
// The replay is queued rather than run here, so that the buffered keys arrive on
// a later pass of the event loop, as they would have if the user had pressed them
// then. Running them here dispatches them from the middle of whatever the caller
// was doing. If a caller ends the block partway through updating the screen, a
// handler then acts on state the caller has yet to finish writing.
func (g *Gui) EndBlockingEvents() {
func (g *Gui) EndBlockingEvents() error {
g.blockInputCount--
if g.blockInputCount > 0 {
return
return nil
}
// Input stays withheld until the replay has run. Gui events are dispatched in
// preference to queued work (see processRemainingEvents), so a key pressed
// before the replay gets its turn would otherwise be handled ahead of the keys
// buffered before it.
g.replayPending = true
g.Update(func(*Gui) error { return g.replayBufferedKeys() })
}
// replayBufferedKeys dispatches the keys withheld while input was blocked, and
// lets input through again. One of their handlers may block input afresh, and
// then the keys after it are withheld in their turn, to be replayed when that
// block ends.
func (g *Gui) replayBufferedKeys() error {
g.replayPending = false
buffered := g.bufferedKeyEvents
g.bufferedKeyEvents = nil
for i := range buffered {
@@ -1196,8 +1123,7 @@ func (g *Gui) processEvent() error {
contentOnly = contentOnly && remainingContentOnly
if contentOnly {
g.flushContentOnly(g.views)
return nil
return g.flushContentOnly(g.views)
}
return g.flush()
}
@@ -1238,7 +1164,7 @@ func (g *Gui) processRemainingEvents() (bool, error) {
// handleEvent handles an event, based on its type (key-press, error,
// etc.)
func (g *Gui) handleEvent(ev *GocuiEvent) error {
if g.withholdingInput() && eventWithheldWhileBlocking(ev) {
if g.blockInputCount > 0 && eventWithheldWhileBlocking(ev) {
if ev.Type == eventKey {
// Buffer keys so they replay against fresh state on unblock.
g.bufferedKeyEvents = append(g.bufferedKeyEvents, *ev)
@@ -1267,13 +1193,6 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error {
}
}
// withholdingInput reports whether events are being kept from the handlers. They
// are while a block is in force, and on until the keys it buffered have been
// replayed.
func (g *Gui) withholdingInput() bool {
return g.blockInputCount > 0 || g.replayPending
}
// eventWithheldWhileBlocking reports whether an event must not reach the
// handlers while input is blocked (see BeginBlockingEvents). Key events are
// withheld (buffered for replay); mouse clicks and hover are withheld (dropped).
@@ -1298,7 +1217,7 @@ func (g *Gui) onResize() {
}
// drawFrameEdges draws the horizontal and vertical edges of a view.
func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) {
func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) error {
runeH, runeV := '─', '│'
if len(v.FrameRunes) >= 2 {
runeH, runeV = v.FrameRunes[0], v.FrameRunes[1]
@@ -1309,10 +1228,14 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) {
continue
}
if v.y0 > -1 && v.y0 < g.maxY {
g.SetRune(x, v.y0, runeH, fgColor, bgColor)
if err := g.SetRune(x, v.y0, runeH, fgColor, bgColor); err != nil {
return err
}
}
if v.y1 > -1 && v.y1 < g.maxY {
g.SetRune(x, v.y1, runeH, fgColor, bgColor)
if err := g.SetRune(x, v.y1, runeH, fgColor, bgColor); err != nil {
return err
}
}
}
@@ -1322,14 +1245,19 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) {
continue
}
if v.x0 > -1 && v.x0 < g.maxX {
g.SetRune(v.x0, y, runeV, fgColor, bgColor)
if err := g.SetRune(v.x0, y, runeV, fgColor, bgColor); err != nil {
return err
}
}
if v.x1 > -1 && v.x1 < g.maxX {
runeToPrint := calcScrollbarRune(showScrollbar, realScrollbarStart, realScrollbarEnd, y, runeV)
g.SetRune(v.x1, y, runeToPrint, fgColor, bgColor)
if err := g.SetRune(v.x1, y, runeToPrint, fgColor, bgColor); err != nil {
return err
}
}
}
return nil
}
func calcScrollbarRune(
@@ -1429,13 +1357,17 @@ func corner(v *View, directions byte) rune {
}
// drawFrameCorners draws the corners of the view.
func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) {
func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) error {
if v.y0 == v.y1 {
if !g.SupportOverlaps && v.x0 >= 0 && v.x1 >= 0 && v.y0 >= 0 && v.x0 < g.maxX && v.x1 < g.maxX && v.y0 < g.maxY {
g.SetRune(v.x0, v.y0, '╶', fgColor, bgColor)
g.SetRune(v.x1, v.y0, '╴', fgColor, bgColor)
if err := g.SetRune(v.x0, v.y0, '╶', fgColor, bgColor); err != nil {
return err
}
if err := g.SetRune(v.x1, v.y0, '╴', fgColor, bgColor); err != nil {
return err
}
}
return
return nil
}
runeTL, runeTR, runeBL, runeBR := '┌', '┐', '└', '┘'
@@ -1456,15 +1388,18 @@ func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) {
for _, c := range corners {
if c.x >= 0 && c.y >= 0 && c.x < g.maxX && c.y < g.maxY {
g.SetRune(c.x, c.y, c.ch, fgColor, bgColor)
if err := g.SetRune(c.x, c.y, c.ch, fgColor, bgColor); err != nil {
return err
}
}
}
return nil
}
// drawTitle draws the title of the view.
func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) {
func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error {
if v.y0 < 0 || v.y0 >= g.maxY {
return
return nil
}
tabs := v.Tabs
@@ -1500,7 +1435,9 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) {
x := v.x0 + 2
for _, ch := range prefix {
g.SetRune(x, v.y0, ch, fgColor, bgColor)
if err := g.SetRune(x, v.y0, ch, fgColor, bgColor); err != nil {
return err
}
x += uniseg.StringWidth(string(ch))
}
for i, ch := range str {
@@ -1523,55 +1460,64 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) {
currentFgColor &= ^AttrBold
}
}
g.SetRune(x, v.y0, ch, currentFgColor, currentBgColor)
if err := g.SetRune(x, v.y0, ch, currentFgColor, currentBgColor); err != nil {
return err
}
x += uniseg.StringWidth(string(ch))
}
return nil
}
// drawSubtitle draws the subtitle of the view.
func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) {
func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error {
if v.y0 < 0 || v.y0 >= g.maxY {
return
return nil
}
start := v.x1 - 5 - uniseg.StringWidth(v.Subtitle)
if start < v.x0 {
return
return nil
}
x := start
for _, ch := range v.Subtitle {
if x >= v.x1 {
break
}
g.SetRune(x, v.y0, ch, fgColor, bgColor)
if err := g.SetRune(x, v.y0, ch, fgColor, bgColor); err != nil {
return err
}
x += uniseg.StringWidth(string(ch))
}
return nil
}
// drawListFooter draws the footer of a list view, showing something like '1 of 10'
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) {
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
if len(v.buf.lines) == 0 {
return
return nil
}
message := v.Footer
if v.y1 < 0 || v.y1 >= g.maxY {
return
return nil
}
start := v.x1 - 1 - uniseg.StringWidth(message)
if start < v.x0 {
return
return nil
}
x := start
for _, ch := range message {
if x >= v.x1 {
break
}
g.SetRune(x, v.y1, ch, fgColor, bgColor)
if err := g.SetRune(x, v.y1, ch, fgColor, bgColor); err != nil {
return err
}
x += uniseg.StringWidth(string(ch))
}
return nil
}
// flush updates the gui, re-drawing frames and buffers.
@@ -1588,7 +1534,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.RewrapContent()
v.ClearViewLines()
}
}
g.maxX, g.maxY = maxX, maxY
@@ -1599,35 +1545,40 @@ func (g *Gui) flush() error {
}
}
for _, v := range g.views {
g.draw(v)
if err := g.draw(v); err != nil {
return err
}
}
Screen.Show()
return nil
}
// Redraws only dirty views and skips the layout pass.
// Redraws only tainted views and skips the layout pass.
// tcell's cell-level dirty tracking ensures only
// actually-changed cells are emitted to the terminal.
// Will also redraw any views that overlap dirty views.
func (g *Gui) flushContentOnly(views []*View) {
// Will also redraw any views that overlap tainted views
func (g *Gui) flushContentOnly(views []*View) error {
// The screen must not be touched while suspended (see Suspend).
if g.isSuspended() {
return
return nil
}
for _, v := range viewsToRedrawContentOnly(views) {
g.draw(v)
if err := g.draw(v); err != nil {
return err
}
}
Screen.Show()
return nil
}
func viewsToRedrawContentOnly(views []*View) []*View {
redrawIndexes := set.New[int]()
for i, v := range views {
if !v.NeedsRedraw() && !redrawIndexes.Includes(i) {
if !v.IsTainted() && !redrawIndexes.Includes(i) {
continue
}
@@ -1657,11 +1608,11 @@ func (g *Gui) ForceLayoutAndRedraw() error {
return g.flush()
}
// Redraws only dirty views outside of the normal main
// Redraws only tainted views outside of the normal main
// loop, without a layout pass. Useful during longer operations that block the
// main thread, e.g. to update a spinner in a status view.
func (g *Gui) ForceFlushViewsContentOnly(views []*View) {
g.flushContentOnly(views)
func (g *Gui) ForceFlushViewsContentOnly(views []*View) error {
return g.flushContentOnly(views)
}
// hasFocus reports whether a view is drawn as focused. Views that are embedded
@@ -1679,9 +1630,9 @@ func outermostView(v *View) *View {
}
// draw manages the cursor and calls the draw function of a view.
func (g *Gui) draw(v *View) {
func (g *Gui) draw(v *View) error {
if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 {
return
return nil
}
if g.Cursor {
@@ -1720,18 +1671,30 @@ func (g *Gui) draw(v *View) {
}
}
g.drawFrameEdges(v, frameColor, bgColor)
g.drawFrameCorners(v, frameColor, bgColor)
if err := g.drawFrameEdges(v, frameColor, bgColor); err != nil {
return err
}
if err := g.drawFrameCorners(v, frameColor, bgColor); err != nil {
return err
}
if v.Title != "" || len(v.Tabs) > 0 {
g.drawTitle(v, fgColor, bgColor)
if err := g.drawTitle(v, fgColor, bgColor); err != nil {
return err
}
}
if v.Subtitle != "" {
g.drawSubtitle(v, fgColor, bgColor)
if err := g.drawSubtitle(v, fgColor, bgColor); err != nil {
return err
}
}
if v.Footer != "" && g.ShowListFooter {
g.drawListFooter(v, fgColor, bgColor)
if err := g.drawListFooter(v, fgColor, bgColor); err != nil {
return err
}
}
}
return nil
}
// onKey manages key-press events. A keybinding handler is called when
@@ -1815,25 +1778,6 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
}
}
var mouseOpts ViewMouseBindingOpts
if IsMouseKey(ev.Key) {
mouseOpts = ViewMouseBindingOpts{
X: newX, Y: newY, Key: ev.Key.KeyName(),
IsDoubleClick: g.isDoubleClick(newX, newY, ev.Key.KeyName(), v),
}
// Dispatch bindings that opt into firing while a popup panel is focused
// before the gate below gets a chance to reject the click.
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, true)
if err != nil {
return err
}
if matched {
g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
return nil
}
}
if g.ShouldHandleMouseEvent != nil {
if !g.ShouldHandleMouseEvent(v, ev.Key.KeyName()) {
// Give clients a chance to reject clicks, for example clicks in inactive views
@@ -1841,21 +1785,11 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
break
}
}
// Bindings match modifiers exactly, so a gesture with a keyboard modifier held
// is a gesture of its own, and the bindings that act on a plain click pass it
// by. It therefore has to leave the view as it found it: the cursor stays where
// the selection is (a list view draws its selection at the cursor), and no
// mouse capture begins for a drag that no binding will extend. ModMotion comes
// from the mouse rather than the keyboard, and every drag carries it, so it is
// masked out here.
gestureIsModified := ev.Key.Mod()&^ModMotion != ModNone
if ev.Key.KeyName() == MouseLeft && ev.Key.Mod() == ModNone {
if ev.Key.KeyName() == MouseLeft && ev.Key.Mod()&ModMotion == 0 {
g.captureMouse(v)
}
if !IsMouseScrollKey(ev.Key.KeyName()) && ev.Key.KeyName() != MouseRelease &&
!gestureIsModified {
if !IsMouseScrollKey(ev.Key.KeyName()) && ev.Key.KeyName() != MouseRelease {
cursorX, cursorY := newCx, newCy
// A captured drag can report positions outside the view; keep the
// view cursor inside its bounds in that case. Handlers still get
@@ -1893,8 +1827,9 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
}
if IsMouseKey(ev.Key) {
g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, false)
isDoubleClick := g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
opts := ViewMouseBindingOpts{X: newX, Y: newY, Key: ev.Key.KeyName(), IsDoubleClick: isDoubleClick}
matched, err := g.execMouseKeybindings(v, ev, opts)
if err != nil {
return err
}
@@ -1926,49 +1861,43 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
return nil
}
// isDoubleClick reports whether this click follows one just like it, closely
// enough in time to count as a double click.
func (g *Gui) isDoubleClick(x, y int, key KeyName, v *View) bool {
return g.lastClick != nil &&
!IsMouseScrollKey(key) &&
key != MouseRelease &&
x == g.lastClick.x &&
y == g.lastClick.y &&
key == g.lastClick.key &&
v.Name() == g.lastClick.viewName &&
time.Now().Before(g.lastClick.time.Add(DOUBLE_CLICK_THRESHOLD))
}
// recordClickInfo remembers this click as the one a following click is compared
// against. Only the clicks that reach a binding are recorded, so a click the
// client rejects leaves double-click detection where it was.
func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) {
// remember the information for this click, and return true if it was a double click
func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) bool {
if IsMouseScrollKey(key) {
g.lastClick = nil
return
return false
}
// A release ends a gesture but is not a click of its own; it must leave
// the click info of the press that started it alone, or no double click
// could ever be detected.
if key == MouseRelease {
return
return false
}
g.lastClick = &clickInfo{
clickInfo := &clickInfo{
x: x,
y: y,
key: key,
viewName: v.Name(),
time: time.Now(),
}
isDoubleClick := g.lastClick != nil &&
clickInfo.x == g.lastClick.x &&
clickInfo.y == g.lastClick.y &&
clickInfo.key == g.lastClick.key &&
clickInfo.viewName == g.lastClick.viewName &&
clickInfo.time.Before(g.lastClick.time.Add(DOUBLE_CLICK_THRESHOLD))
g.lastClick = clickInfo
return isDoubleClick
}
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts, handleWhenPopupPanelFocused bool) (bool, error) {
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts) (bool, error) {
isMatch := func(binding *ViewMouseBinding) bool {
return binding.ViewName == view.Name() &&
ev.Key.KeyName() == binding.Key &&
ev.Key.Mod() == binding.Modifier &&
binding.HandleWhenPopupPanelFocused == handleWhenPopupPanelFocused
ev.Key.Mod() == binding.Modifier
}
// first pass looks for ones that match the focused view
@@ -2114,10 +2043,6 @@ 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)
}
@@ -2134,8 +2059,6 @@ func (g *Gui) onFocus(ev *GocuiEvent) error {
// after re-engaging.
func (g *Gui) Suspend() error {
g.waitForColorSchemeReplies()
g.suspendedMutex.Lock()
defer g.suspendedMutex.Unlock()
@@ -2143,9 +2066,6 @@ func (g *Gui) Suspend() error {
return errors.New("Already suspended")
}
for _, view := range g.views {
view.ClearLineFlash()
}
g.suspended = true
if err := g.screen.Suspend(); err != nil {
-46
View File
@@ -1,46 +0,0 @@
package gocui
import (
"testing"
"github.com/gdamore/tcell/v3"
"github.com/stretchr/testify/assert"
)
func TestAModifiedClickNoBindingWantsLeavesTheViewAlone(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
g := newTestGui(t)
view, _ := g.SetView("list", 0, 0, 20, 10, 0)
clicks := 0
g.SetViewClickBinding(&ViewMouseBinding{
ViewName: "list",
Key: MouseLeft,
Handler: func(ViewMouseBindingOpts) error {
clicks++
return nil
},
})
click := func(y int, modifier tcell.ModMask) {
for _, button := range []tcell.ButtonMask{tcell.ButtonPrimary, tcell.ButtonNone} {
event := gocuiEventFromTcellEvent(
tcell.NewEventMouse(view.x0+1, y, button, modifier))
assert.NoError(t, g.onKey(&event))
}
}
// A plain click is the binding's, and moves the cursor it acts on.
click(view.y0+4, tcell.ModNone)
assert.Equal(t, 1, clicks)
assert.Equal(t, 3, view.CursorY())
// An alt-click is nobody's here, bindings matching modifiers exactly. It has to
// leave the cursor where the selection is, or a list view would draw its
// selection on a line its owner never selected.
click(view.y0+8, tcell.ModAlt)
assert.Equal(t, 1, clicks)
assert.Equal(t, 3, view.CursorY())
assert.Nil(t, g.mouseCapture, "and no drag begins for it either")
}
-49
View File
@@ -1,49 +0,0 @@
package gocui
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestOnlyBindingsThatOptedInFireBehindAFocusedPopup(t *testing.T) {
g := newTestGui(t)
_, _ = g.SetView("main", 0, 0, 20, 10, 0)
fired := []string{}
g.SetViewClickBinding(&ViewMouseBinding{
ViewName: "main",
Key: MouseLeft,
Modifier: ModAlt,
HandleWhenPopupPanelFocused: true,
Handler: func(ViewMouseBindingOpts) error {
fired = append(fired, "opted in")
return nil
},
})
g.SetViewClickBinding(&ViewMouseBinding{
ViewName: "main",
Key: MouseLeft,
Handler: func(ViewMouseBindingOpts) error {
fired = append(fired, "ordinary")
return nil
},
})
// This is how a client reports that a popup panel has the focus and the click
// landed on a view behind it.
g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return false }
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse, MouseX: 3, MouseY: 4,
Key: NewKey(MouseLeft, "", ModAlt),
}))
assert.Equal(t, []string{"opted in"}, fired)
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse, MouseX: 3, MouseY: 4,
Key: NewKey(MouseLeft, "", ModNone),
}))
assert.Equal(t, []string{"opted in"}, fired,
"a binding that didn't opt in must still be swallowed")
}
+1 -12
View File
@@ -18,7 +18,7 @@ func TestFlushIsNoOpWhileSuspended(t *testing.T) {
flush func(g *Gui) error
}{
{"flush", func(g *Gui) error { return g.flush() }},
{"flushContentOnly", func(g *Gui) error { g.flushContentOnly(g.views); return nil }},
{"flushContentOnly", func(g *Gui) error { return g.flushContentOnly(g.views) }},
}
for _, tc := range tests {
@@ -67,14 +67,3 @@ func TestResumeSchedulesRedraw(t *testing.T) {
assert.Equal(t, eventResize, ev.Type,
"resuming must schedule a redraw; without one the screen stays blank until the next event arrives")
}
func TestSuspendClearsLineFlashes(t *testing.T) {
g := newTestGui(t)
v, err := g.SetView("main", 0, 0, 20, 10, 0)
assert.ErrorIs(t, err, ErrUnknownView)
v.SetLineFlash(3)
assert.NoError(t, g.Suspend())
assert.Equal(t, -1, v.lineFlashY)
assert.NoError(t, g.Resume())
}
+4 -19
View File
@@ -56,13 +56,7 @@ var runeReplacements = map[rune]string{
func (g *Gui) tcellInit(runeReplacements map[rune]string) error {
tcell.SetEncodingFallback(tcell.EncodingFallbackASCII)
tty, e := tcell.NewDevTty()
if e != nil {
return e
}
colorSchemeTty := newColorSchemeTty(tty)
s, e := tcell.NewTerminfoScreenFromTty(colorSchemeTty)
s, e := tcell.NewScreen()
if e != nil {
return e
}
@@ -74,7 +68,6 @@ func (g *Gui) tcellInit(runeReplacements map[rune]string) error {
registerRuneFallbacks(s, runeReplacements)
g.screen = s
g.colorSchemeTty = colorSchemeTty
Screen = s
return nil
}
@@ -209,7 +202,6 @@ const (
var (
lastMouseKey tcell.ButtonMask = tcell.ButtonNone
lastMouseMod tcell.ModMask = tcell.ModNone
dragState = NOT_DRAGGING
lastX = 0
lastY = 0
@@ -378,12 +370,6 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
if button != tcell.ButtonNone && lastMouseKey == tcell.ButtonNone {
newButtonPress = true
lastMouseKey = button
// The keyboard modifiers held at press time apply to the whole gesture:
// the press, every drag event, and the release. Snapshotting them here
// keeps a modified press from producing events that match unmodified
// bindings, and ignores modifier changes while the button is held.
lastMouseMod = tev.Modifiers()
mouseMod = Modifier(lastMouseMod)
switch button {
case tcell.ButtonPrimary:
mouseKey = MouseLeft
@@ -409,8 +395,7 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
case tcell.ButtonMiddle:
default:
}
mouseMod = Modifier(lastMouseMod)
lastMouseMod = tcell.ModNone
mouseMod = ModNone
lastMouseKey = tcell.ButtonNone
}
default:
@@ -441,10 +426,10 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
// reaches drag bindings instead of being delivered with the
// default MouseRelease key.
dragState = DRAGGING
mouseMod = Modifier(lastMouseMod) | ModMotion
mouseMod = ModMotion
mouseKey = MouseLeft
case DRAGGING:
mouseMod = Modifier(lastMouseMod) | ModMotion
mouseMod = ModMotion
mouseKey = MouseLeft
}
}
+3 -37
View File
@@ -36,55 +36,21 @@ func TestMouseReleaseAfterDragIsMouseEvent(t *testing.T) {
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
}
func TestWholeGestureCarriesPressModifiers(t *testing.T) {
func TestMouseReleaseDoesNotKeepPressModifiers(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
assert.Equal(t, eventMouse, pressEvent.Type)
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
assert.Equal(t, ModAlt, pressEvent.Key.Mod())
assert.Equal(t, eventMouse, dragEvent.Type)
assert.Equal(t, MouseLeft, dragEvent.Key.KeyName())
assert.Equal(t, ModAlt|ModMotion, dragEvent.Key.Mod())
assert.Equal(t, eventMouse, releaseEvent.Type)
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
assert.Equal(t, ModAlt, releaseEvent.Key.Mod())
}
func TestModifierChangesWhileButtonHeldAreIgnored(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone))
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
assert.Equal(t, ModMotion, dragEvent.Key.Mod())
assert.Equal(t, ModNone, releaseEvent.Key.Mod())
}
func TestModifiedClickWithoutDragCarriesModifierOnPressAndRelease(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModShift))
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonNone, tcell.ModShift))
assert.Equal(t, eventMouse, pressEvent.Type)
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
assert.Equal(t, ModShift, pressEvent.Key.Mod())
assert.Equal(t, eventMouse, releaseEvent.Type)
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
assert.Equal(t, ModShift, releaseEvent.Key.Mod())
}
func resetMouseState() {
lastMouseKey = tcell.ButtonNone
lastMouseMod = tcell.ModNone
dragState = NOT_DRAGGING
lastX = 0
lastY = 0
+66 -577
View File
@@ -5,7 +5,6 @@
package gocui
import (
"bytes"
"fmt"
"io"
"slices"
@@ -80,27 +79,18 @@ type View struct {
// a user starts a range select and then moves the cursor up.
rangeSelectStartY int
// The view line whose selection-width bar is temporarily reversed. A value
// of -1 means that no line is flashing.
lineFlashY int
// readBuffer is used for storing unread bytes
readBuffer []byte
// tained is true if the viewLines must be updated
tainted bool
// needsRedraw is true if the view's current state has not been drawn to the
// screen yet. A tainted view always needs a redraw, but draw-only state can
// require one without invalidating viewLines.
needsRedraw bool
// firstDirtyLine is the index of the lowest line in `lines` that has been
// written to since viewLines was last refreshed, and whose cached wrapping
// (lineType.wrappedCells) may therefore be stale. Lines below it are
// unchanged and can reuse their cached wrapping instead of being
// re-wrapped, which keeps refreshViewLinesIfNeeded cheap while scrolling
// appends new lines to a long buffer.
// written to or highlighted since viewLines was last refreshed, and whose
// cached wrapping (lineType.wrappedCells) may therefore be stale. Lines
// below it are unchanged and can reuse their cached wrapping instead of
// being re-wrapped, which keeps refreshViewLinesIfNeeded cheap while
// scrolling appends new lines to a long buffer.
firstDirtyLine int
// the last position that the mouse was hovering over; nil if the mouse is outside of
@@ -148,12 +138,6 @@ 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
@@ -173,23 +157,6 @@ 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
// InclusionGutterMarker is the glyph the inclusion gutter draws on a marked line
// (see SetInclusionGutter), and InclusionGutterMarkerColor its color. Both are
// set once, when the view is created.
InclusionGutterMarker string
InclusionGutterMarkerColor Attribute
// showInclusionGutter reserves the gutter's columns at the left of every line,
// and inclusionGutterMarks, indexed by line of the content, says which lines get
// the marker. Set together, via SetInclusionGutter.
showInclusionGutter bool
inclusionGutterMarks []bool
// If Frame is true, a border will be drawn around the view.
Frame bool
@@ -278,145 +245,23 @@ type pos struct {
x, y int
}
// 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.
// 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
func (v *View) clearViewLines() {
v.markViewLinesDirty()
v.tainted = true
v.viewLines = nil
v.clearHover()
}
// markViewLinesDirty records that the cached viewLines no longer represent the
// view's buffer or wrapping, so both rebuilding and redrawing are required.
func (v *View) markViewLinesDirty() {
v.tainted = true
v.needsRedraw = true
}
// 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() {
// 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() {
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 {
@@ -654,23 +499,10 @@ 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
@@ -740,23 +572,6 @@ 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
@@ -773,9 +588,6 @@ 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
@@ -809,19 +621,17 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
Frame: true,
Editor: DefaultEditor,
tainted: true,
needsRedraw: true,
outMode: mode,
buf: &viewBuffer{ei: newEscapeInterpreter(mode)},
searcher: &searcher{},
TextArea: &TextArea{},
rangeSelectStartY: -1,
lineFlashY: -1,
TabWidth: 4,
}
v.FgColor, v.BgColor = ColorDefault, ColorDefault
v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault
v.InactiveViewSelBgColor, v.SelTextColor = ColorDefault, ColorDefault
v.InactiveViewSelBgColor = ColorDefault
v.TitleColor, v.FrameColor = ColorDefault, ColorDefault
v.buf.ei.screenColMax = v.InnerWidth()
return v
@@ -889,38 +699,6 @@ func (v *View) Name() string {
return v.name
}
// SetInclusionGutter shows or hides a column reserved at the left of every line, in
// which marks — indexed by line of the content — say which lines get
// InclusionGutterMarker drawn, on every segment of a line the view wrapped. The
// content is drawn shifted past it.
//
// It is drawn over the content rather than written into it, so the content itself —
// and with it what each line of the view means, where a click lands, and how the
// lines wrap — is untouched but for the width the gutter takes.
func (v *View) SetInclusionGutter(show bool, marks []bool) {
v.writeMutex.Lock()
changed := v.showInclusionGutter != show
v.showInclusionGutter = show
v.inclusionGutterMarks = marks
v.writeMutex.Unlock()
if changed {
// The gutter takes its columns from the content, so what is left of it wraps
// differently, and everything pointing into it has to come along.
v.RewrapContent()
}
}
// inclusionGutterWidth is how many columns the inclusion gutter takes while it is
// shown — the marker plus a column of space before the content — and 0 while it is
// not. Only call this with a lock on writeMutex.
func (v *View) inclusionGutterWidth() int {
if !v.showInclusionGutter {
return 0
}
return uniseg.StringWidth(v.InclusionGutterMarker) + 1
}
// setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies
// the specified colors, taking into account if the cell must be highlighted. Also, it checks if the
// position is valid.
@@ -943,9 +721,13 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
rangeSelectEnd = max(relativeRangeSelectStart, v.cy)
}
colorWidth := v.SelectedLineColorWidth
if y >= rangeSelectStart && y <= rangeSelectEnd && (colorWidth == 0 || x < colorWidth) {
fgColor = applySelTextColor(fgColor, v.SelTextColor)
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
if v.HighlightInactive || !isWindowFocused {
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
} else {
@@ -967,10 +749,6 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
fgColor |= AttrUnderline
}
if v.lineFlashY == v.oy+y && (v.SelectedLineColorWidth == 0 || x < v.SelectedLineColorWidth) {
fgColor ^= AttrReverse
}
// Don't display empty characters
if ch == "" {
ch = " "
@@ -1056,7 +834,8 @@ func (v *View) SetWritePos(x, y int) {
y = 0
}
v.buf.seekWrite(x, y)
v.buf.wx = x
v.buf.wy = y
// Changing the write position makes a pending newline obsolete
v.buf.pendingNewline = false
@@ -1140,35 +919,6 @@ 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
@@ -1191,7 +941,7 @@ func (v *View) write(p []byte) {
return
}
v.markViewLinesDirty()
v.tainted = true
// write only ever touches lines from v.buf.wy onwards, so any cached wrapping
// below that stays valid.
v.firstDirtyLine = min(v.firstDirtyLine, v.buf.wy)
@@ -1212,30 +962,14 @@ 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.seekWrite(0, b.wy+1)
b.wx = 0
b.wy++
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 {
@@ -1265,10 +999,6 @@ 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:
@@ -1384,18 +1114,8 @@ 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 {
characters := b.ei.characters()
for _, chr := range characters {
for _, chr := range b.ei.characters() {
c := cell{
fgColor: v.FgColor,
bgColor: v.BgColor,
@@ -1404,7 +1124,6 @@ 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
@@ -1421,7 +1140,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, cells
return truncateLine, []cell{}
} 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
@@ -1430,19 +1149,10 @@ 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 {
// 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
// do not output anything
return truncateLine, nil
} 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 {
@@ -1451,20 +1161,14 @@ 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)
}
@@ -1549,9 +1253,9 @@ func (v *View) CopyContent(from *View) {
// A background task may be streaming output into the source view's buffer
// via Write, so read it under its own lock. The source is always a
// different view than the destination — its callers hand content from one
// view to another — and no other code holds two view write locks at once, so
// this can't deadlock.
// different view than the destination (see the sole caller,
// moveMainContextToTop), and no other code holds two view write locks at
// once, so this can't deadlock.
from.writeMutex.Lock()
defer from.writeMutex.Unlock()
@@ -1626,7 +1330,7 @@ func (v *View) SwapInOffscreenRender() {
}
v.buf = v.offscreen
v.offscreen = nil
v.markViewLinesDirty()
v.tainted = true
v.clearHover()
}
@@ -1790,12 +1494,6 @@ func (v *View) IsTainted() bool {
return v.tainted
}
func (v *View) NeedsRedraw() bool {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return v.needsRedraw
}
// draw re-draws the view's contents.
func (v *View) draw(isWindowFocused bool) {
v.writeMutex.Lock()
@@ -1804,7 +1502,6 @@ func (v *View) draw(isWindowFocused bool) {
if !v.Visible {
return
}
defer func() { v.needsRedraw = false }()
v.clearRunes()
@@ -1836,8 +1533,6 @@ func (v *View) draw(isWindowFocused bool) {
emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault}
gutterWidth := v.inclusionGutterWidth()
for y, vline := range v.viewLines[start:] {
if y >= maxY {
break
@@ -1852,20 +1547,10 @@ func (v *View) draw(isWindowFocused bool) {
trailingCell.bgColor = attrs.bg
}
// The inclusion gutter is blank but for the marker on a marked line, and the
// content begins after it. The blanks go through setCharacter like everything
// else, so that a selection reaching the left edge covers the gutter too.
for gx := range gutterWidth {
v.setCharacter(gx, y, " ", v.FgColor, v.BgColor, isWindowFocused)
}
if gutterWidth > 0 && vline.linesY < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[vline.linesY] {
v.setCharacter(0, y, v.InclusionGutterMarker, v.InclusionGutterMarkerColor, v.BgColor, isWindowFocused)
}
// x tracks the current x position in the view, and cellIdx tracks the
// index of the cell. If we print a double-sized rune, we increment cellIdx
// by one but x by two.
x := gutterWidth - v.ox
x := -v.ox
cellIdx := 0
var c cell
@@ -1879,7 +1564,7 @@ func (v *View) draw(isWindowFocused bool) {
// no more characters to write so we're only going to be printing empty cells
// past this point
x = gutterWidth
x = 0
}
// if we're out of cells to write, we'll just print empty cells.
@@ -1914,11 +1599,10 @@ func (v *View) refreshViewLinesIfNeeded() {
return
}
maxX := v.InnerWidth()
wrap := 0
if v.Wrap {
// The inclusion gutter, while it is shown, takes its columns out of the width
// the content has to wrap in.
wrap = max(0, v.InnerWidth()-v.inclusionGutterWidth())
wrap = maxX
}
lineIdx := 0
@@ -1929,8 +1613,8 @@ func (v *View) refreshViewLinesIfNeeded() {
// Reuse the previously wrapped result for lines that haven't changed
// since the last refresh (i.e. below firstDirtyLine) and were wrapped at
// the current width. Wrapping is expensive and this loop runs on every
// scroll event, so only the lines that were actually just read should
// be wrapped afresh.
// scroll event, so only the lines that were actually just read (or
// re-highlighted) should be wrapped afresh.
if line.wrappedCells == nil || line.wrappedColumns != wrap || i >= v.firstDirtyLine {
line.wrappedCells = lineWrap(line.cells, wrap)
line.wrappedColumns = wrap
@@ -2060,159 +1744,6 @@ func (v *View) BufferLines() []string {
return lines
}
// MarkedLines returns the lines of the view's content that the inclusion gutter is
// marking (see SetInclusionGutter), in the order they appear. Empty while the gutter
// is hidden.
func (v *View) MarkedLines() []string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if !v.showInclusionGutter {
return nil
}
lines := []string{}
for i, line := range v.buf.lines {
if i < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[i] {
lines = append(lines, line.cells.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 {
@@ -2239,9 +1770,6 @@ 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)
}
@@ -2315,31 +1843,28 @@ 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
// SetHighlight toggles highlighting of separate lines, for custom lists
// or multiple selection in views.
func (v *View) SetHighlight(y int, on bool) {
if y < 0 || y >= len(v.buf.lines) {
return
}
return fgColor | selTextColor&AttrStyleBits
}
// SetLineFlash temporarily marks a view line without moving or changing the
// selection. The caller owns the lifetime and clears it with ClearLineFlash.
func (v *View) SetLineFlash(viewLine int) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.lineFlashY = viewLine
v.needsRedraw = true
}
func (v *View) ClearLineFlash() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.lineFlashY = -1
v.needsRedraw = true
cells := make([]cell, 0, len(v.buf.lines[y].cells))
for _, c := range v.buf.lines[y].cells {
if on {
c.bgColor = v.SelBgColor
c.fgColor = v.SelFgColor
} else {
c.bgColor = v.BgColor
c.fgColor = v.FgColor
}
cells = append(cells, c)
}
v.tainted = true
v.firstDirtyLine = min(v.firstDirtyLine, y)
v.buf.lines[y].cells = cells
v.clearHover()
}
func lineWrap(line []cell, columns int) [][]cell {
@@ -2445,31 +1970,16 @@ 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()
idx, ok := v.bufferLineForViewLine(v.SelectedLineIdx())
if !ok {
if len(v.buf.lines) == 0 {
return ""
}
return v.lineContentAtIdx(idx)
return v.lineContentAtIdx(v.SelectedLineIdx())
}
// expected to only be used in tests
@@ -2484,17 +1994,8 @@ func (v *View) SelectedLines() []string {
startIdx, endIdx := v.SelectedLineRange()
lines := make([]string, 0, endIdx-startIdx+1)
previous := -1
for i := startIdx; i <= endIdx; 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))
lines = append(lines, v.lineContentAtIdx(i))
}
return lines
@@ -2504,19 +2005,6 @@ 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()
@@ -2587,7 +2075,8 @@ 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.SetWritePos(0, y)
v.buf.wx = 0
v.buf.wy = y
v.clearViewLines()
lines := strings.ReplaceAll(content, "\n", "\x1b[K\n")
-453
View File
@@ -113,99 +113,6 @@ 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
@@ -251,107 +158,6 @@ 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.
@@ -398,61 +204,6 @@ 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
@@ -1029,207 +780,3 @@ 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())
}
func TestLineFlashReversesTheSelectionBarWithoutChangingSelection(t *testing.T) {
WithSimulationScreen(t, 14, 6)
v := NewView("name", 0, 0, 11, 5, OutputNormal)
v.Highlight = true
v.SelBgColor = ColorBlue
v.SelectedLineColorWidth = 2
v.writeString("one\ntwo\nthree\n")
v.FocusPoint(0, 1, false)
v.SetLineFlash(1)
v.draw(true)
for x := 1; x <= 2; x++ {
_, style, _ := Screen.Get(x, 2)
assert.True(t, style.HasReverse(), "selection-bar cell at (%d, 2) should flash", x)
}
_, style, _ := Screen.Get(3, 2)
assert.False(t, style.HasReverse(), "the flash should stop after the selection bar")
assert.Equal(t, "two", v.SelectedLine(), "flashing should not change the selection")
v.ClearLineFlash()
v.draw(true)
_, style, _ = Screen.Get(1, 2)
assert.False(t, style.HasReverse(), "clearing should remove the flash")
}
// 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())
}
// The inclusion gutter reserves columns at the left of every line, draws its marker
// on the marked lines only, and moves the content out of the way.
func TestInclusionGutter(t *testing.T) {
WithSimulationScreen(t, 14, 6)
// InnerWidth 10; the frame puts view x=0 at screen x=1.
v := NewView("name", 0, 0, 11, 5, OutputNormal)
v.Wrap = true
v.InclusionGutterMarker = "✓"
v.writeString("aaa\nbbb\nccc\n")
// The gutter is two columns wide — the marker and a space; mark the middle line.
v.SetInclusionGutter(true, []bool{false, true, false})
v.draw(true)
chr, _, _ := Screen.Get(1, 1)
assert.Equal(t, " ", chr, "an unmarked line has no marker")
chr, _, _ = Screen.Get(1, 2)
assert.Equal(t, "✓", chr, "a marked line has one")
chr, _, _ = Screen.Get(1, 3)
assert.Equal(t, " ", chr, "an unmarked line has no marker")
// The content begins after the gutter: view x=2, i.e. screen x=3.
chr, _, _ = Screen.Get(3, 1)
assert.Equal(t, "a", chr)
chr, _, _ = Screen.Get(3, 2)
assert.Equal(t, "b", chr)
chr, _, _ = Screen.Get(3, 3)
assert.Equal(t, "c", chr)
// Hiding the gutter puts the content back at the left edge.
v.SetInclusionGutter(false, nil)
v.draw(true)
chr, _, _ = Screen.Get(1, 1)
assert.Equal(t, "a", chr)
}
// A marked line the view wraps is marked on every segment it is drawn as, so that
// the mark doesn't look like it belongs to the first part of the line alone. The
// gutter takes its columns out of the width the content wraps in.
func TestInclusionGutterMarksEverySegmentOfAWrappedLine(t *testing.T) {
WithSimulationScreen(t, 14, 6)
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10
v.Wrap = true
v.InclusionGutterMarker = "✓"
// Ten cells, wrapping at eight once the two-column gutter is shown.
v.writeString("0123456789\n")
v.SetInclusionGutter(true, []bool{true})
v.draw(true)
chr, _, _ := Screen.Get(1, 1)
assert.Equal(t, "✓", chr)
chr, _, _ = Screen.Get(3, 1)
assert.Equal(t, "0", chr)
chr, _, _ = Screen.Get(10, 1)
assert.Equal(t, "7", chr, "the content wraps at the width the gutter leaves it")
chr, _, _ = Screen.Get(1, 2)
assert.Equal(t, "✓", chr, "the line's second segment is marked too")
chr, _, _ = Screen.Get(3, 2)
assert.Equal(t, "8", chr)
}
// Showing the gutter narrows the content, so the content wraps again — and the
// positions into it, which count the segments lines are drawn as, have to come
// along, as they do for any other change of width.
func TestShowingTheInclusionGutterKeepsThePlaceInTheContent(t *testing.T) {
v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10
v.Wrap = true
v.Highlight = true
v.InclusionGutterMarker = "✓"
v.writeString("one\ntwo\nthree\nsomethingfartoolong\n")
assert.Equal(t, 5, v.ViewLinesHeight())
v.FocusPoint(0, 2, false)
assert.Equal(t, "three", v.SelectedLine())
// With eight columns left for the content, the last line wraps into three
// segments rather than two.
v.SetInclusionGutter(true, []bool{false, false, true, false})
assert.Equal(t, 6, v.ViewLinesHeight())
assert.Equal(t, "three", v.SelectedLine())
}
+15 -14
View File
@@ -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()
@@ -350,6 +350,18 @@ func (self *ContextMgr) AllList() []types.IListContext {
return listContexts
}
func (self *ContextMgr) AllPatchExplorer() []types.IPatchExplorerContext {
var listContexts []types.IPatchExplorerContext
for _, context := range self.allContexts.Flatten() {
if listContext, ok := context.(types.IPatchExplorerContext); ok {
listContexts = append(listContexts, listContext)
}
}
return listContexts
}
func (self *ContextMgr) ContextForKey(key types.ContextKey) types.Context {
self.RLock()
defer self.RUnlock()
@@ -387,14 +399,3 @@ 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()
})
}
+20 -27
View File
@@ -13,15 +13,15 @@ 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
focusedMainViewDiffSource types.FocusedMainViewDiffSource
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
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()
focusable bool
transient bool
@@ -34,8 +34,9 @@ type BaseContext struct {
}
type (
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
)
var _ types.IBaseContext = &BaseContext{}
@@ -121,14 +122,6 @@ 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
}
@@ -160,7 +153,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
self.onQuitFns = nil
self.onDoubleClickFn = nil
self.onClickFn = nil
self.focusedMainViewDiffSource = nil
self.onClickFocusedMainViewFn = nil
self.onRenderToMainFn = nil
}
@@ -182,12 +175,12 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e
}
}
func (self *BaseContext) AddFocusedMainViewDiffSource(source types.FocusedMainViewDiffSource) {
if source != nil {
if self.focusedMainViewDiffSource != nil {
panic("only one controller is allowed to set the focused main view diff source")
func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) {
if fn != nil {
if self.onClickFocusedMainViewFn != nil {
panic("only one controller is allowed to set an onClickFocusedMainViewFn")
}
self.focusedMainViewDiffSource = source
self.onClickFocusedMainViewFn = fn
}
}
@@ -199,8 +192,8 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
return self.onClickFn
}
func (self *BaseContext) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.focusedMainViewDiffSource
func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
return self.onClickFocusedMainViewFn
}
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
+6 -37
View File
@@ -19,36 +19,11 @@ type CommitFilesContext struct {
}
var (
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
_ types.PullRequestDiffContext = (*CommitFilesContext)(nil)
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
)
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
// BranchForPullRequest asks the panel this one was entered from: the files are a
// commit's, and which branch's pull request that commit is up for review in is known
// there rather than here.
func (self *CommitFilesContext) BranchForPullRequest() string {
if parent, ok := self.GetParentContext().(types.PullRequestDiffContext); ok {
return parent.BranchForPullRequest()
}
return ""
}
// CommitsForPullRequest asks the panel this one was entered from as well: the files
// listed here are those of the commits selected there.
func (self *CommitFilesContext) CommitsForPullRequest() ([]*models.Commit, string) {
if parent, ok := self.GetParentContext().(types.PullRequestDiffContext); ok {
return parent.CommitsForPullRequest()
}
return nil, ""
}
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
viewModel := filetree.NewCommitFileTreeViewModel(
func() []*models.CommitFile { return c.Model().CommitFiles },
@@ -105,16 +80,10 @@ func (self *CommitFilesContext) RefForAdjustingLineNumberInDiff() string {
}
func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) {
return FromAndToForDiff(self.GetRef(), self.GetRefRange())
}
// FromAndToForDiff gives the two ends to diff for a ref, or for a range of them: a
// range runs from the parent of its first ref to its last, a single ref from its own
// parent to itself.
func FromAndToForDiff(ref models.Ref, refRange *types.RefRange) (string, string) {
if refRange != nil {
return refRange.From.ParentRefName(), refRange.To.RefName()
if refs := self.GetRefRange(); refs != nil {
return refs.From.ParentRefName(), refs.To.RefName()
}
ref := self.GetRef()
return ref.ParentRefName(), ref.RefName()
}
+58 -42
View File
@@ -8,23 +8,27 @@ const (
// used as a nil value when passing a context key as an arg
NO_CONTEXT types.ContextKey = "none"
GLOBAL_CONTEXT_KEY types.ContextKey = "global"
STATUS_CONTEXT_KEY types.ContextKey = "status"
SNAKE_CONTEXT_KEY types.ContextKey = "snake"
FILES_CONTEXT_KEY types.ContextKey = "files"
LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches"
REMOTES_CONTEXT_KEY types.ContextKey = "remotes"
WORKTREES_CONTEXT_KEY types.ContextKey = "worktrees"
REMOTE_BRANCHES_CONTEXT_KEY types.ContextKey = "remoteBranches"
TAGS_CONTEXT_KEY types.ContextKey = "tags"
LOCAL_COMMITS_CONTEXT_KEY types.ContextKey = "commits"
REFLOG_COMMITS_CONTEXT_KEY types.ContextKey = "reflogCommits"
SUB_COMMITS_CONTEXT_KEY types.ContextKey = "subCommits"
COMMIT_FILES_CONTEXT_KEY types.ContextKey = "commitFiles"
STASH_CONTEXT_KEY types.ContextKey = "stash"
NORMAL_MAIN_CONTEXT_KEY types.ContextKey = "normal"
NORMAL_SECONDARY_CONTEXT_KEY types.ContextKey = "normalSecondary"
MERGE_CONFLICTS_CONTEXT_KEY types.ContextKey = "mergeConflicts"
GLOBAL_CONTEXT_KEY types.ContextKey = "global"
STATUS_CONTEXT_KEY types.ContextKey = "status"
SNAKE_CONTEXT_KEY types.ContextKey = "snake"
FILES_CONTEXT_KEY types.ContextKey = "files"
LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches"
REMOTES_CONTEXT_KEY types.ContextKey = "remotes"
WORKTREES_CONTEXT_KEY types.ContextKey = "worktrees"
REMOTE_BRANCHES_CONTEXT_KEY types.ContextKey = "remoteBranches"
TAGS_CONTEXT_KEY types.ContextKey = "tags"
LOCAL_COMMITS_CONTEXT_KEY types.ContextKey = "commits"
REFLOG_COMMITS_CONTEXT_KEY types.ContextKey = "reflogCommits"
SUB_COMMITS_CONTEXT_KEY types.ContextKey = "subCommits"
COMMIT_FILES_CONTEXT_KEY types.ContextKey = "commitFiles"
STASH_CONTEXT_KEY types.ContextKey = "stash"
NORMAL_MAIN_CONTEXT_KEY types.ContextKey = "normal"
NORMAL_SECONDARY_CONTEXT_KEY types.ContextKey = "normalSecondary"
STAGING_MAIN_CONTEXT_KEY types.ContextKey = "staging"
STAGING_SECONDARY_CONTEXT_KEY types.ContextKey = "stagingSecondary"
PATCH_BUILDING_MAIN_CONTEXT_KEY types.ContextKey = "patchBuilding"
PATCH_BUILDING_SECONDARY_CONTEXT_KEY types.ContextKey = "patchBuildingSecondary"
MERGE_CONFLICTS_CONTEXT_KEY types.ContextKey = "mergeConflicts"
// these shouldn't really be needed for anything but I'm giving them unique keys nonetheless
OPTIONS_CONTEXT_KEY types.ContextKey = "options"
@@ -62,6 +66,10 @@ var AllContextKeys = []types.ContextKey{
STASH_CONTEXT_KEY,
NORMAL_MAIN_CONTEXT_KEY,
NORMAL_SECONDARY_CONTEXT_KEY,
STAGING_MAIN_CONTEXT_KEY,
STAGING_SECONDARY_CONTEXT_KEY,
PATCH_BUILDING_MAIN_CONTEXT_KEY,
PATCH_BUILDING_SECONDARY_CONTEXT_KEY,
MERGE_CONFLICTS_CONTEXT_KEY,
MENU_CONTEXT_KEY,
@@ -75,31 +83,35 @@ var AllContextKeys = []types.ContextKey{
}
type ContextTree struct {
Global types.Context
Status types.Context
Snake types.Context
Files *WorkingTreeContext
Menu *MenuContext
Branches *BranchesContext
Tags *TagsContext
LocalCommits *LocalCommitsContext
CommitFiles *CommitFilesContext
Remotes *RemotesContext
Worktrees *WorktreesContext
Submodules *SubmodulesContext
RemoteBranches *RemoteBranchesContext
ReflogCommits *ReflogCommitsContext
SubCommits *SubCommitsContext
Stash *StashContext
Suggestions *SuggestionsContext
Normal *MainContext
NormalSecondary *MainContext
MergeConflicts *MergeConflictsContext
Confirmation *ConfirmationContext
Prompt *PromptContext
CommitMessage *CommitMessageContext
CommitDescription types.Context
CommandLog types.Context
Global types.Context
Status types.Context
Snake types.Context
Files *WorkingTreeContext
Menu *MenuContext
Branches *BranchesContext
Tags *TagsContext
LocalCommits *LocalCommitsContext
CommitFiles *CommitFilesContext
Remotes *RemotesContext
Worktrees *WorktreesContext
Submodules *SubmodulesContext
RemoteBranches *RemoteBranchesContext
ReflogCommits *ReflogCommitsContext
SubCommits *SubCommitsContext
Stash *StashContext
Suggestions *SuggestionsContext
Normal *MainContext
NormalSecondary *MainContext
Staging *PatchExplorerContext
StagingSecondary *PatchExplorerContext
CustomPatchBuilder *PatchExplorerContext
CustomPatchBuilderSecondary types.Context
MergeConflicts *MergeConflictsContext
Confirmation *ConfirmationContext
Prompt *PromptContext
CommitMessage *CommitMessageContext
CommitDescription types.Context
CommandLog types.Context
// display contexts
AppStatus types.Context
@@ -137,6 +149,10 @@ func (self *ContextTree) Flatten() []types.Context {
self.CommitDescription,
self.MergeConflicts,
self.StagingSecondary,
self.Staging,
self.CustomPatchBuilderSecondary,
self.CustomPatchBuilder,
self.NormalSecondary,
self.Normal,
+3 -62
View File
@@ -31,70 +31,11 @@ type commitDropIndicator struct {
}
var (
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
_ types.PullRequestDiffContext = (*LocalCommitsContext)(nil)
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
)
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
// BranchForPullRequest returns the checked-out branch: this panel shows its commits,
// so a pull request for it is where they are up for review.
func (self *LocalCommitsContext) BranchForPullRequest() string {
return self.ListContextTrait.c.Model().CheckedOutBranch
}
func (self *LocalCommitsContext) CommitsForPullRequest() ([]*models.Commit, string) {
selectedCommits, _, _ := self.GetSelectedItems()
commits := commitsShownInDiff(selectedCommits, self.GetSelected(), self.GetSelectedRefRangeForDiffFiles())
return commits, pullRequestBaseForCommits(self.GetCommits(), commits)
}
// commitsShownInDiff returns the commits whose combined diff a panel listing a branch's
// commits renders into the main view: the selected range where it has a range to diff,
// and the commit at the cursor otherwise. The panel hands the same two to
// DiffHelper.GetUpdateTaskForRenderingCommitsDiff, so anything acting on the diff on
// screen acts on the commits that diff is of.
func commitsShownInDiff(
selectedCommits []*models.Commit, commitAtCursor *models.Commit, refRange *types.RefRange,
) []*models.Commit {
if refRange != nil {
return selectedCommits
}
if commitAtCursor == nil {
return nil
}
return []*models.Commit{commitAtCursor}
}
// pullRequestBaseForCommits returns the hash of the commit the diff of the given commits
// starts after: the parent of the oldest of them. A pull request holds only the commits
// of the branch that are pushed, so a parent that isn't pushed is none of its own. The
// diff then starts where the pull request itself does, and "" says so.
func pullRequestBaseForCommits(allCommits []*models.Commit, commits []*models.Commit) string {
if len(commits) == 0 {
return ""
}
oldest := commits[len(commits)-1]
if oldest.IsFirstCommit() {
return ""
}
parentHash := oldest.Parents()[0]
parentIsInPullRequest := lo.ContainsBy(allCommits, func(commit *models.Commit) bool {
return commit.Hash() == parentHash && commit.Status == models.StatusPushed
})
if !parentIsInPullRequest {
return ""
}
return parentHash
}
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
dropIndicator := &commitDropIndicator{insertionIndex: -1}
viewModel := NewLocalCommitsViewModel(
@@ -4,12 +4,8 @@ import (
"testing"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
@@ -55,101 +51,3 @@ func TestAddMovingCommitsIndicator(t *testing.T) {
},
}, items)
}
func TestCommitsShownInDiff(t *testing.T) {
hashPool := &utils.StringPool{}
newer := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "newer"})
older := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "older"})
selected := []*models.Commit{newer, older}
scenarios := []struct {
name string
selectedCommit *models.Commit
refRange *types.RefRange
expected []*models.Commit
}{
{
name: "a range is diffed as a whole",
selectedCommit: newer,
refRange: &types.RefRange{From: older, To: newer},
expected: selected,
},
{
name: "without a range to diff, only the commit at the cursor is",
selectedCommit: newer,
expected: []*models.Commit{newer},
},
{
name: "nothing is diffed while nothing is selected",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, commitsShownInDiff(selected, s.selectedCommit, s.refRange))
})
}
}
func TestPullRequestBaseForCommits(t *testing.T) {
hashPool := &utils.StringPool{}
commit := func(hash string, parent string, status models.CommitStatus) *models.Commit {
return models.NewCommit(hashPool, models.NewCommitOpts{
Hash: hash,
Parents: lo.Ternary(parent == "", []string{}, []string{parent}),
Status: status,
})
}
// A branch of three pushed commits whose tip was amended, on top of a commit that
// is in a main branch already, as the panel lists them: newest first.
amended := commit("amended", "third", models.StatusUnpushed)
third := commit("third", "second", models.StatusPushed)
second := commit("second", "first", models.StatusPushed)
first := commit("first", "merged", models.StatusPushed)
merged := commit("merged", "ancient", models.StatusMerged)
ancient := commit("ancient", "", models.StatusMerged)
allCommits := []*models.Commit{amended, third, second, first, merged, ancient}
scenarios := []struct {
name string
commits []*models.Commit
expected string
}{
{
name: "a single commit starts after its parent",
commits: []*models.Commit{second},
expected: "first",
},
{
name: "a range starts after the parent of its oldest commit",
commits: []*models.Commit{third, second},
expected: "first",
},
{
name: "the pull request's first commit starts where the pull request does",
commits: []*models.Commit{first},
},
{
name: "so does a range reaching down to it",
commits: []*models.Commit{third, second, first},
},
{
name: "and so does the first commit of the repository",
commits: []*models.Commit{ancient},
},
{
name: "a parent the panel doesn't list is none of the pull request's",
commits: []*models.Commit{commit("elsewhere", "unlisted", models.StatusPushed)},
},
{
name: "nothing is shown",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, pullRequestBaseForCommits(allCommits, s.commits))
})
}
}
+7 -78
View File
@@ -8,78 +8,9 @@ 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
// contentIsDiff says the render this pane was last given is the diff the panel
// beneath offers here, rather than a message or an explanation (see
// types.ContentIsDiff). It is known as the render is asked for, well before there
// is any content to look at.
contentIsDiff bool
}
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
}
// ContentIsDiff reports whether this pane is showing the panel's diff (see
// contentIsDiff).
func (self *MainContext) ContentIsDiff() bool {
return self.contentIsDiff
}
// SetContentIsDiff records what kind of render the pane is being given.
func (self *MainContext) SetContentIsDiff(value bool) {
self.contentIsDiff = value
}
var _ types.ISearchableContext = (*MainContext)(nil)
func NewMainContext(
view *gocui.View,
@@ -90,11 +21,12 @@ func NewMainContext(
ctx := &MainContext{
SimpleContext: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{
Kind: types.MAIN_CONTEXT,
View: view,
WindowName: windowName,
Key: key,
Focusable: true,
Kind: types.MAIN_CONTEXT,
View: view,
WindowName: windowName,
Key: key,
Focusable: true,
HasSelectableContent: false,
})),
SearchTrait: NewSearchTrait(c),
}
@@ -106,8 +38,5 @@ 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()
}
+154
View File
@@ -0,0 +1,154 @@
package context
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
"github.com/jesseduffield/lazygit/pkg/gui/types"
deadlock "github.com/sasha-s/go-deadlock"
)
type PatchExplorerContext struct {
*SimpleContext
*SearchTrait
state *patch_exploring.State
viewTrait *ViewTrait
getIncludedLineIndices func() []int
c *ContextCommon
mutex deadlock.Mutex
// true if we're inside the OnSelectItem callback; in that case we don't want to update the
// search result index.
inOnSelectItemCallback bool
}
var (
_ types.IPatchExplorerContext = (*PatchExplorerContext)(nil)
_ types.ISearchableContext = (*PatchExplorerContext)(nil)
)
func NewPatchExplorerContext(
view *gocui.View,
windowName string,
key types.ContextKey,
getIncludedLineIndices func() []int,
c *ContextCommon,
) *PatchExplorerContext {
ctx := &PatchExplorerContext{
state: nil,
viewTrait: NewViewTrait(view),
c: c,
getIncludedLineIndices: getIncludedLineIndices,
SimpleContext: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: view,
WindowName: windowName,
Key: key,
Kind: types.MAIN_CONTEXT,
Focusable: true,
HasSelectableContent: true,
NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES,
})),
SearchTrait: NewSearchTrait(c),
}
ctx.SetHandleRenderFunc(ctx.OnViewWidthChanged)
return ctx
}
func (self *PatchExplorerContext) IsPatchExplorerContext() {}
func (self *PatchExplorerContext) GetState() *patch_exploring.State {
return self.state
}
func (self *PatchExplorerContext) SetState(state *patch_exploring.State) {
self.state = state
}
func (self *PatchExplorerContext) GetViewTrait() types.IViewTrait {
return self.viewTrait
}
func (self *PatchExplorerContext) GetIncludedLineIndices() []int {
return self.getIncludedLineIndices()
}
func (self *PatchExplorerContext) RenderAndFocus() {
self.setContent()
self.FocusSelection()
self.c.Render()
}
func (self *PatchExplorerContext) Render() {
self.setContent()
self.c.Render()
}
func (self *PatchExplorerContext) setContent() {
self.GetView().SetContent(self.GetContentToRender())
}
func (self *PatchExplorerContext) FocusSelection() {
view := self.GetView()
state := self.GetState()
bufferHeight := view.InnerHeight()
_, origin := view.Origin()
numLines := view.ViewLinesHeight()
newOriginY := state.CalculateOrigin(origin, bufferHeight, numLines)
view.SetOriginY(newOriginY)
startIdx, endIdx := state.SelectedViewRange()
// As far as the view is concerned, we are always selecting a range
view.SetRangeSelectStart(startIdx)
view.SetCursorY(endIdx - newOriginY)
if !self.inOnSelectItemCallback {
view.SetNearestSearchPosition()
}
}
func (self *PatchExplorerContext) GetContentToRender() string {
if self.GetState() == nil {
return ""
}
return self.GetState().RenderForLineIndices(self.GetIncludedLineIndices())
}
func (self *PatchExplorerContext) NavigateTo(selectedLineIdx int) {
self.GetState().SetLineSelectMode()
self.GetState().SelectLine(selectedLineIdx)
self.RenderAndFocus()
}
func (self *PatchExplorerContext) GetMutex() *deadlock.Mutex {
return &self.mutex
}
func (self *PatchExplorerContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
return nil
}
func (self *PatchExplorerContext) OnSearchSelect(selectedLineIdx int) {
self.GetMutex().Lock()
defer self.GetMutex().Unlock()
self.inOnSelectItemCallback = true
self.NavigateTo(selectedLineIdx)
self.inOnSelectItemCallback = false
}
func (self *PatchExplorerContext) OnViewWidthChanged() {
if state := self.GetState(); state != nil {
state.OnViewWidthChanged(self.GetView())
self.setContent()
self.RenderAndFocus()
}
}
+2 -7
View File
@@ -14,15 +14,10 @@ type ReflogCommitsContext struct {
}
var (
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
_ types.DiffMainViewContext = (*ReflogCommitsContext)(nil)
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*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 },
+42
View File
@@ -41,6 +41,48 @@ func NewContextTree(c *ContextCommon) *ContextTree {
Suggestions: NewSuggestionsContext(c),
Normal: NewMainContext(c.Views().Main, "main", NORMAL_MAIN_CONTEXT_KEY, c),
NormalSecondary: NewMainContext(c.Views().Secondary, "secondary", NORMAL_SECONDARY_CONTEXT_KEY, c),
Staging: NewPatchExplorerContext(
c.Views().Staging,
"main",
STAGING_MAIN_CONTEXT_KEY,
func() []int { return nil },
c,
),
StagingSecondary: NewPatchExplorerContext(
c.Views().StagingSecondary,
"secondary",
STAGING_SECONDARY_CONTEXT_KEY,
func() []int { return nil },
c,
),
CustomPatchBuilder: NewPatchExplorerContext(
c.Views().PatchBuilding,
"main",
PATCH_BUILDING_MAIN_CONTEXT_KEY,
func() []int {
file := commitFilesContext.GetSelectedFile()
if file == nil {
return nil
}
includedLineIndices, err := c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath)
if err != nil {
c.Log.Error(err)
return nil
}
return includedLineIndices
},
c,
),
CustomPatchBuilderSecondary: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{
Kind: types.MAIN_CONTEXT,
View: c.Views().PatchBuildingSecondary,
WindowName: "secondary",
Key: PATCH_BUILDING_SECONDARY_CONTEXT_KEY,
Focusable: false,
}),
),
MergeConflicts: NewMergeConflictsContext(
c,
),
+2 -7
View File
@@ -12,15 +12,10 @@ type StashContext struct {
}
var (
_ types.IListContext = (*StashContext)(nil)
_ types.DiffableContext = (*StashContext)(nil)
_ types.DiffMainViewContext = (*StashContext)(nil)
_ types.IListContext = (*StashContext)(nil)
_ types.DiffableContext = (*StashContext)(nil)
)
func (self *StashContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewStashContext(
c *ContextCommon,
) *StashContext {
+3 -25
View File
@@ -21,33 +21,11 @@ type SubCommitsContext struct {
}
var (
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
_ types.PullRequestDiffContext = (*SubCommitsContext)(nil)
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
)
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
// BranchForPullRequest returns the branch this panel was entered from, whose commits it
// shows. The panel is also entered from a tag, a remote branch and the reflog, none of
// which a pull request is made from.
func (self *SubCommitsContext) BranchForPullRequest() string {
if branch, ok := self.GetRef().(*models.Branch); ok {
return branch.Name
}
return ""
}
func (self *SubCommitsContext) CommitsForPullRequest() ([]*models.Commit, string) {
selectedCommits, _, _ := self.GetSelectedItems()
commits := commitsShownInDiff(selectedCommits, self.GetSelected(), self.GetSelectedRefRangeForDiffFiles())
return commits, pullRequestBaseForCommits(self.GetCommits(), commits)
}
func NewSubCommitsContext(
c *ContextCommon,
) *SubCommitsContext {
+2 -7
View File
@@ -15,15 +15,10 @@ type WorkingTreeContext struct {
}
var (
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
_ types.DiffMainViewContext = (*WorkingTreeContext)(nil)
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*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 },
+37 -6
View File
@@ -52,7 +52,8 @@ func (gui *Gui) resetHelpersAndControllers() {
gpgHelper := helpers.NewGpgHelper(helperCommon)
viewHelper := helpers.NewViewHelper(helperCommon, gui.State.Contexts)
customPatchHelper := helpers.NewCustomPatchHelper(helperCommon)
patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon)
stagingHelper := helpers.NewStagingHelper(helperCommon)
mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon)
searchHelper := helpers.NewSearchHelper(helperCommon)
@@ -60,12 +61,13 @@ func (gui *Gui) resetHelpersAndControllers() {
helperCommon,
refsHelper,
rebaseHelper,
patchBuildingHelper,
stagingHelper,
mergeConflictsHelper,
worktreeHelper,
searchHelper,
)
diffLineHelper := helpers.NewDiffLineHelper(helperCommon)
diffHelper := helpers.NewDiffHelper(helperCommon, diffLineHelper)
diffHelper := helpers.NewDiffHelper(helperCommon)
cherryPickHelper := helpers.NewCherryPickHelper(
helperCommon,
rebaseHelper,
@@ -75,7 +77,7 @@ func (gui *Gui) resetHelpersAndControllers() {
modeHelper := helpers.NewModeHelper(
helperCommon,
diffHelper,
customPatchHelper,
patchBuildingHelper,
cherryPickHelper,
rebaseHelper,
bisectHelper,
@@ -89,7 +91,8 @@ func (gui *Gui) resetHelpersAndControllers() {
gui.helpers = &helpers.Helpers{
Refs: refsHelper,
Host: helpers.NewHostHelper(helperCommon),
CustomPatch: customPatchHelper,
PatchBuilding: patchBuildingHelper,
Staging: stagingHelper,
Bisect: bisectHelper,
Suggestions: suggestionsHelper,
Files: helpers.NewFilesHelper(helperCommon),
@@ -107,7 +110,6 @@ 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),
@@ -174,13 +176,18 @@ 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)
stashController := controllers.NewStashController(common)
commitFilesController := controllers.NewCommitFilesController(common)
patchExplorerControllerFactory := controllers.NewPatchExplorerControllerFactory(common)
stagingController := controllers.NewStagingController(common, gui.State.Contexts.Staging, gui.State.Contexts.StagingSecondary, false)
stagingSecondaryController := controllers.NewStagingController(common, gui.State.Contexts.StagingSecondary, gui.State.Contexts.Staging, true)
mainViewController := controllers.NewMainViewController(common, gui.State.Contexts.Normal, gui.State.Contexts.NormalSecondary)
secondaryViewController := controllers.NewMainViewController(common, gui.State.Contexts.NormalSecondary, gui.State.Contexts.Normal)
patchBuildingController := controllers.NewPatchBuildingController(common)
snakeController := controllers.NewSnakeController(common)
reflogCommitsController := controllers.NewReflogCommitsController(common)
subCommitsController := controllers.NewSubCommitsController(common)
@@ -277,6 +284,28 @@ func (gui *Gui) resetHelpersAndControllers() {
)
// TODO: add scroll controllers for main panels (need to bring some more functionality across for that e.g. reading more from the currently displayed git command)
controllers.AttachControllers(gui.State.Contexts.Staging,
stagingController,
patchExplorerControllerFactory.Create(gui.State.Contexts.Staging),
verticalScrollControllerFactory.Create(gui.State.Contexts.Staging),
)
controllers.AttachControllers(gui.State.Contexts.StagingSecondary,
stagingSecondaryController,
patchExplorerControllerFactory.Create(gui.State.Contexts.StagingSecondary),
verticalScrollControllerFactory.Create(gui.State.Contexts.StagingSecondary),
)
controllers.AttachControllers(gui.State.Contexts.CustomPatchBuilder,
patchBuildingController,
patchExplorerControllerFactory.Create(gui.State.Contexts.CustomPatchBuilder),
verticalScrollControllerFactory.Create(gui.State.Contexts.CustomPatchBuilder),
)
controllers.AttachControllers(gui.State.Contexts.CustomPatchBuilderSecondary,
verticalScrollControllerFactory.Create(gui.State.Contexts.CustomPatchBuilderSecondary),
)
controllers.AttachControllers(gui.State.Contexts.MergeConflicts,
mergeConflictsController,
)
@@ -284,11 +313,13 @@ 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,
+1 -1
View File
@@ -8,7 +8,7 @@ func AttachControllers(context types.Context, controllers ...types.IController)
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
context.AddOnClickFn(controller.GetOnClick())
context.AddFocusedMainViewDiffSource(controller.GetFocusedMainViewDiffSource())
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
context.AddOnFocusFn(controller.GetOnFocus())
context.AddOnFocusLostFn(controller.GetOnFocusLost())

Some files were not shown because too many files have changed in this diff Show More