mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-04 04:56:50 -04:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aee979006 | ||
|
|
83b144ccc1 | ||
|
|
db33521c1b | ||
|
|
383d3d7df8 | ||
|
|
48f3dfba11 | ||
|
|
3b5110240f | ||
|
|
e9a34a0925 | ||
|
|
647dba2e90 | ||
|
|
31aa870a3b | ||
|
|
84627fabac | ||
|
|
14f5b86b3b | ||
|
|
9ac8ba035f |
@@ -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
|
||||
|
||||
@@ -47,7 +47,8 @@ A simple terminal UI for git commands
|
||||
|
||||
[](https://github.com/jesseduffield/lazygit/releases) [](https://goreportcard.com/report/github.com/jesseduffield/lazygit) [](https://app.codacy.com/gh/jesseduffield/lazygit/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [](https://app.codacy.com/gh/jesseduffield/lazygit/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage) [](https://golangci-lint.run/) [](https://github.com/jesseduffield/lazygit/releases/latest) [](https://formulae.brew.sh/formula/lazygit)
|
||||
|
||||

|
||||
<!-- demo: commit_and_push -->
|
||||
<video src="https://github.com/user-attachments/assets/032c61ae-067a-4547-856d-a552a5a8e14c" controls></video>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -73,7 +74,7 @@ If you're a mere mortal like me and you're tired of hearing how powerful git is
|
||||
- [Elevator Pitch](#elevator-pitch)
|
||||
- [Table of contents](#table-of-contents)
|
||||
- [Features](#features)
|
||||
- [Stage individual lines](#stage-individual-lines)
|
||||
- [Stage hunks or individual lines](#stage-hunks-or-individual-lines)
|
||||
- [Interactive Rebase](#interactive-rebase)
|
||||
- [Cherry-pick](#cherry-pick)
|
||||
- [Bisect](#bisect)
|
||||
@@ -133,11 +134,12 @@ Lazygit is not my fulltime job but it is a hefty part time job so if you want to
|
||||
|
||||
## Features
|
||||
|
||||
### Stage individual lines
|
||||
### Stage hunks or 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 `0` on a changed file to focus its diff in the main view. The selection covers a whole hunk to begin with, so `<space>` stages that hunk and moves on to the next one. When you want only part of a hunk, press `a` for line-by-line selection and `v` to select a range of lines. What you staged shows up in the pane below. Press `<tab>` to move between the two panes; `<space>` unstages in the lower one. Press `c` to commit without leaving the diff.
|
||||
|
||||

|
||||
<!-- demo: stage_hunks_or_lines -->
|
||||
<video src="https://github.com/user-attachments/assets/d9b0777b-37cd-4d2d-bfe5-724f6e453e43" controls></video>
|
||||
|
||||
### Interactive Rebase
|
||||
|
||||
@@ -147,65 +149,80 @@ You can also perform any of these actions as a once-off (e.g. pressing `s` on a
|
||||
|
||||
This demo also uses shift+down to select a range of commits to move and fixup.
|
||||
|
||||

|
||||
<!-- demo: interactive_rebase -->
|
||||
<video src="https://github.com/user-attachments/assets/0e6670a6-e43a-424a-abf3-10f7554c84c8" controls></video>
|
||||
|
||||
### Cherry-pick
|
||||
|
||||
Press `shift+c` on a commit to copy it and press `shift+v` to paste (cherry-pick) it.
|
||||
|
||||

|
||||
<!-- demo: cherry_pick -->
|
||||
<video src="https://github.com/user-attachments/assets/d02a9988-e8c6-469a-8a32-53aa8c89fbd7" controls></video>
|
||||
|
||||
### Bisect
|
||||
|
||||
Press `b` in the commits view to mark a commit as good/bad in order to begin a git bisect.
|
||||
|
||||

|
||||
<!-- demo: bisect -->
|
||||
<video src="https://github.com/user-attachments/assets/572a2d66-3244-443c-9113-63806dfa1402" controls></video>
|
||||
|
||||
### Nuke the working tree
|
||||
|
||||
For when you really want to just get rid of anything that shows up when you run `git status` (and yes that includes dirty submodules) [kidpix style](https://www.youtube.com/watch?v=N4E2B_k2Bss), press `shift+d` to bring up the reset options menu and then select the 'nuke' option.
|
||||
|
||||

|
||||
<!-- demo: nuke_working_tree -->
|
||||
<video src="https://github.com/user-attachments/assets/2b80fd1b-20d0-4fa0-9e8b-efd6b6020592" controls></video>
|
||||
|
||||
### Amend an old commit
|
||||
|
||||
Pressing `shift+a` on any commit will amend that commit with the currently staged changes (running an interactive rebase in the background).
|
||||
|
||||

|
||||
<!-- demo: amend_old_commit -->
|
||||
<video src="https://github.com/user-attachments/assets/084dcd4e-7bf9-42db-b9a4-406df0cf26db" controls></video>
|
||||
|
||||
### Filter
|
||||
|
||||
You can filter a view with `/`. Here we filter down our branches view and then hit `enter` to view its commits.
|
||||
|
||||

|
||||
<!-- demo: filter -->
|
||||
<video src="https://github.com/user-attachments/assets/928e1ad9-a225-42a4-87fb-47104cca648d" controls></video>
|
||||
|
||||
### Invoke a custom command
|
||||
|
||||
Lazygit has a very flexible [custom command system](docs/Custom_Command_Keybindings.md). In this example a custom command is defined which emulates the built-in branch checkout action.
|
||||
|
||||

|
||||
<!-- demo: custom_command -->
|
||||
<video src="https://github.com/user-attachments/assets/44d21622-4cc2-47d3-a292-b0066843ce22" controls></video>
|
||||
|
||||
### Worktrees
|
||||
|
||||
You can create worktrees to have multiple branches going at once without the need for stashing or creating WIP commits when switching between them. Press `w` in the branches view to create a worktree from the selected branch and switch to it.
|
||||
|
||||

|
||||
<!-- demo: worktree_create_from_branches -->
|
||||
<video src="https://github.com/user-attachments/assets/a2fb0a29-fb0b-4ee5-afa2-2896e48fea74" controls></video>
|
||||
|
||||
### Rebase magic (custom patches)
|
||||
|
||||
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 an old commit contains a change that belongs in a commit of its own. We hit `0` on the commit to focus its diff. `<space>` on the hunk we want to move adds it to the custom patch, and `ctrl+p` opens the custom patch options, where we choose to move the patch into a new commit.
|
||||
|
||||
Learn more in the [Rebase magic Youtube tutorial](https://youtu.be/4XaToVut_hs).
|
||||
|
||||

|
||||
<!-- demo: custom_patch -->
|
||||
<video src="https://github.com/user-attachments/assets/5ed43a09-042b-4d29-997d-55dc9db4550f" controls></video>
|
||||
|
||||
If you only want to remove a hunk from an old commit, you don't need a custom patch for that. Select the hunk in the commit's diff and press `d`. Lazygit rewrites the commit without it, running an interactive rebase in the background.
|
||||
|
||||
<!-- demo: remove_hunk_from_commit -->
|
||||
<video src="https://github.com/user-attachments/assets/3b0091f3-2a64-4094-b101-d5d5a72bb79e" controls></video>
|
||||
|
||||
### Rebase from marked base commit
|
||||
|
||||
Say you're on a feature branch that was itself branched off of the develop branch, and you've decided you'd rather be branching off the master branch. You need a way to rebase only the commits from your feature branch. In this demo we check to see which was the last commit on the develop branch, then press `shift+b` to mark that commit as our base commit, then press `r` on the master branch to rebase onto it, only bringing across the commits from our feature branch. Then we push our changes with `shift+p`.
|
||||
|
||||

|
||||
<!-- demo: rebase_onto -->
|
||||
<video src="https://github.com/user-attachments/assets/6cb8eed7-5623-4a91-8a36-670540cfbe0d" controls></video>
|
||||
|
||||
### Undo
|
||||
|
||||
@@ -214,19 +231,22 @@ Undo uses the reflog which is specific to commits and branches so we can't undo
|
||||
|
||||
[More info](/docs/Undoing.md)
|
||||
|
||||

|
||||
<!-- demo: undo -->
|
||||
<video src="https://github.com/user-attachments/assets/8c8fa5b7-2493-484d-bacb-97f049f8c864" controls></video>
|
||||
|
||||
### Commit graph
|
||||
|
||||
When viewing the commit graph in an enlarged window (use `+` and `_` to cycle screen modes), the commit graph is shown. Colours correspond to the commit authors, and as you navigate down the graph, the parent commits of the selected commit are highlighted.
|
||||
|
||||

|
||||
<!-- demo: commit_graph -->
|
||||
<video src="https://github.com/user-attachments/assets/fa4fe760-0924-4e12-a2b3-931df425d7ee" controls></video>
|
||||
|
||||
### Compare two commits
|
||||
|
||||
If you press `shift+w` on a commit (or branch/ref) a menu will open that allows you to mark that commit so that any other commit you select will be diffed against it. Once you've selected the second commit, you'll see the diff in the main view and if you press `<enter>` you'll see the files of the diff. You can press `shift+w` to view the diff menu again to see options like reversing the diff direction or exiting diff mode. You can also exit diff mode by pressing `<escape>`.
|
||||
|
||||

|
||||
<!-- demo: diff_commits -->
|
||||
<video src="https://github.com/user-attachments/assets/2ef5dac7-3ab6-4f92-9289-aab56a658762" controls></video>
|
||||
|
||||
### Show GitHub pull requests
|
||||
|
||||
|
||||
-112
@@ -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"
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 rbong
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Make a copy of the Flog Symbols font whose lines fill a terminal cell.
|
||||
|
||||
The demo recordings draw the commit graph with the branch drawing symbols, and
|
||||
the terminal that vhs records takes them from the Flog Symbols Demo font in
|
||||
demo/fonts. This script made its regular and bold faces from FlogSymbols.ttf of
|
||||
https://github.com/rbong/flog-symbols (see LICENSE-FlogSymbols):
|
||||
|
||||
pip install fonttools
|
||||
python3 demo/fonts/fit_flog_symbols.py FlogSymbols.ttf "Flog Symbols Demo" \\
|
||||
demo/fonts
|
||||
|
||||
Flog Symbols draws its lines for a cell that is 620 units wide and reaches from
|
||||
-206 to 1006 units. A terminal that takes the symbols from a fallback font
|
||||
draws them in the cells of its main font, and if those are larger, the lines
|
||||
stop short of the cell edges and leave gaps between neighbouring cells.
|
||||
|
||||
So move the ends of the strokes that run to an edge of the cell out to the
|
||||
terminal's cell edges, and centre everything else in the cell. The circles and
|
||||
bends in the middle of the cell keep their shape.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
# The cell of the symbols, in font units
|
||||
LEFT, RIGHT, BOTTOM, TOP = -6, 626, -206, 1006
|
||||
|
||||
# The width of the terminal cells in the recordings, in font units: at the font
|
||||
# size in demo/settings.tape they are 16 pixels wide (SauceCodePro's 14.4
|
||||
# pixels, plus the pixel of letter spacing that vhs adds, rounded up), which is
|
||||
# 16/24 of an em. Regenerate the font if the font size changes.
|
||||
CELL_WIDTH = 667
|
||||
|
||||
# How far the horizontal strokes reach into the neighbouring cells, in font
|
||||
# units. xterm.js doesn't clip a character to its cell horizontally, so this
|
||||
# has to stay short of where the bends in a neighbouring cell begin. In the
|
||||
# recordings, less than 30 leaves a dim line where two cells meet, and 35 or
|
||||
# more opens a dark gap there.
|
||||
HORIZONTAL_OVERLAP = 30
|
||||
|
||||
# How far past the top and bottom of the symbols' cell the vertical strokes
|
||||
# reach, in font units. xterm.js clips a character to its row, so this only
|
||||
# has to be more than the terminal's row sticks out beyond the symbols' cell.
|
||||
VERTICAL_REACH = 400
|
||||
|
||||
# Points this close to an edge of the symbols' cell belong to the end of a
|
||||
# stroke that runs to that edge
|
||||
EDGE_TOLERANCE = 30
|
||||
|
||||
|
||||
def main():
|
||||
flog_path, family, output_dir = sys.argv[1:4]
|
||||
font = TTFont(flog_path)
|
||||
fit_to_cell(font)
|
||||
|
||||
# The bold face has the same outlines. Without one, the browser makes the
|
||||
# symbols of bold text bold itself by thickening them, and that leaves gaps
|
||||
# where they meet.
|
||||
for style in ("Regular", "Bold"):
|
||||
set_style(font, family, style)
|
||||
font.save(os.path.join(output_dir, f"{family.replace(' ', '')}-{style}.ttf"))
|
||||
|
||||
|
||||
def fit_to_cell(font):
|
||||
glyf = font["glyf"]
|
||||
|
||||
# Centre the symbols in the terminal's cell
|
||||
dx = round((CELL_WIDTH - (RIGHT + LEFT)) / 2)
|
||||
|
||||
for name in font.getGlyphOrder():
|
||||
glyph = glyf[name]
|
||||
if glyph.numberOfContours <= 0:
|
||||
continue
|
||||
coordinates = glyph.coordinates
|
||||
for i, (x, y) in enumerate(coordinates):
|
||||
if x <= LEFT + EDGE_TOLERANCE:
|
||||
x = -HORIZONTAL_OVERLAP
|
||||
elif x >= RIGHT - EDGE_TOLERANCE:
|
||||
x = CELL_WIDTH + HORIZONTAL_OVERLAP
|
||||
else:
|
||||
x += dx
|
||||
if y <= BOTTOM + EDGE_TOLERANCE:
|
||||
y -= VERTICAL_REACH
|
||||
elif y >= TOP - EDGE_TOLERANCE:
|
||||
y += VERTICAL_REACH
|
||||
coordinates[i] = (x, y)
|
||||
glyph.recalcBounds(glyf)
|
||||
font["hmtx"][name] = (CELL_WIDTH, glyph.xMin)
|
||||
|
||||
|
||||
def set_style(font, family, style):
|
||||
bold = style == "Bold"
|
||||
postscript_name = f"{family.replace(' ', '')}-{style}"
|
||||
full_name = family if not bold else f"{family} {style}"
|
||||
for record in font["name"].names:
|
||||
if record.nameID == 1:
|
||||
record.string = family
|
||||
elif record.nameID == 2:
|
||||
record.string = style
|
||||
elif record.nameID == 4:
|
||||
record.string = full_name
|
||||
elif record.nameID in (3, 6):
|
||||
record.string = postscript_name
|
||||
|
||||
os2 = font["OS/2"]
|
||||
os2.usWeightClass = 700 if bold else 400
|
||||
fs_bold, fs_regular = 1 << 5, 1 << 6
|
||||
os2.fsSelection &= ~(fs_bold | fs_regular)
|
||||
os2.fsSelection |= fs_bold if bold else fs_regular
|
||||
font["head"].macStyle = 1 if bold else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+213
-46
@@ -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,169 @@ 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"
|
||||
|
||||
# Start recording once lazygit has drawn the top left corner of a view frame.
|
||||
# Demos run in whichever screen mode they ask for, so no particular panel is
|
||||
# on screen for all of them, but every view is drawn with a frame. This is the
|
||||
# corner that `border: rounded` draws; a demo config that turns borders off
|
||||
# would need a different signal.
|
||||
#
|
||||
# The two quotes in the end 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 /╭/
|
||||
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
|
||||
|
||||
# vhs keeps recording until the marker reaches the screen, and by then lazygit
|
||||
# has exited and the shell has painted its prompt back over the demo. Find the
|
||||
# moment that happened so we can cut it off. Measure the share of pixels that
|
||||
# are not background: it collapses when lazygit's panels give way to a prompt,
|
||||
# and the reading holds steady from there to the end of the recording.
|
||||
CUT=$(ffmpeg -v error -i "$RECORDING" \
|
||||
-vf "format=gray,lutyuv=y='if(gt(val\,60)\,255\,0)',signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-" \
|
||||
-an -f null - 2>/dev/null |
|
||||
# ffmpeg writes these numbers with a decimal point, so keep awk in a
|
||||
# locale that reads them back that way. The + 0 turns them from strings
|
||||
# into numbers, without which awk compares them as text.
|
||||
LC_ALL=C awk '/pts_time:/ { split($3, a, ":"); time = a[2] }
|
||||
/YAVG=/ { split($0, b, "="); n++; at[n] = time + 0; ink[n] = b[2] + 0 }
|
||||
END {
|
||||
# Walk back over the frames that read the same as the last one.
|
||||
k = n
|
||||
while (k > 1 && ink[k - 1] < ink[n] * 1.05 && ink[k - 1] > ink[n] * 0.95) {
|
||||
k--
|
||||
}
|
||||
# Only call it a prompt if the screen really did empty out. A
|
||||
# demo that simply ends on a still frame leaves nothing to cut.
|
||||
if (k > 1 && ink[k - 1] > ink[n] * 2) {
|
||||
printf "%.3f\n", at[k]
|
||||
}
|
||||
}')
|
||||
|
||||
if [ -n "$CUT" ]
|
||||
then
|
||||
TRIM="-t $CUT"
|
||||
else
|
||||
TRIM=
|
||||
fi
|
||||
|
||||
# The video ends on the last frame of the demo. A browser goes on showing that
|
||||
# frame once it has played to the end, so it needs no padding out. Move the
|
||||
# moov atom to the front so that playback can start before the whole file has
|
||||
# downloaded.
|
||||
# shellcheck disable=SC2086
|
||||
ffmpeg -y -loglevel error -i "$RECORDING" $TRIM \
|
||||
-vf "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>"
|
||||
|
||||
Executable
+113
@@ -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
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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. The terminal that vhs records doesn't draw the branch drawing symbols
|
||||
# of the commit graph itself, so they come from the second font, which is in
|
||||
# demo/fonts.
|
||||
Set FontFamily "SauceCodePro NF,Flog Symbols Demo"
|
||||
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"
|
||||
}
|
||||
@@ -300,6 +300,18 @@ gui:
|
||||
# NerdFontsVersion is not empty.
|
||||
showFileIcons: true
|
||||
|
||||
# How the commit graph is drawn.
|
||||
# One of: 'auto' (default) | 'classic' | 'detailed'
|
||||
# 'detailed' connects the lines to the commit circles, and shows exactly where
|
||||
# branches fork off and merge. It draws the graph with the git branch drawing
|
||||
# symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself:
|
||||
# kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with
|
||||
# GPU acceleration. Other terminals need a font that contains them, such as
|
||||
# https://github.com/rbong/flog-symbols.
|
||||
# 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws
|
||||
# these symbols (kitty and Ghostty), and 'classic' otherwise.
|
||||
commitGraphStyle: auto
|
||||
|
||||
# Length of author name in (non-expanded) commits view. 2 means show initials
|
||||
# only.
|
||||
commitAuthorShortLength: 2
|
||||
|
||||
@@ -8,17 +8,21 @@ 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
|
||||
|
||||
# font with the branch drawing symbols of the commit graph
|
||||
cp demo/fonts/FlogSymbolsDemo-*.ttf ~/Library/Fonts
|
||||
```
|
||||
|
||||
## Creating a demo
|
||||
@@ -49,34 +53,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
|
||||

|
||||
```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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -179,6 +179,11 @@ type GuiConfig struct {
|
||||
NerdFontsVersion string `yaml:"nerdFontsVersion" jsonschema:"enum=2,enum=3,enum="`
|
||||
// If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.
|
||||
ShowFileIcons bool `yaml:"showFileIcons"`
|
||||
// How the commit graph is drawn.
|
||||
// One of: 'auto' (default) | 'classic' | 'detailed'
|
||||
// 'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.
|
||||
// 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise.
|
||||
CommitGraphStyle string `yaml:"commitGraphStyle" jsonschema:"enum=auto,enum=classic,enum=detailed"`
|
||||
// Length of author name in (non-expanded) commits view. 2 means show initials only.
|
||||
CommitAuthorShortLength int `yaml:"commitAuthorShortLength"`
|
||||
// Length of author name in expanded commits view. 2 means show initials only.
|
||||
@@ -925,6 +930,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
ShowIcons: false,
|
||||
NerdFontsVersion: "",
|
||||
ShowFileIcons: true,
|
||||
CommitGraphStyle: "auto",
|
||||
CommitAuthorShortLength: 2,
|
||||
CommitAuthorLongLength: 17,
|
||||
CommitHashLength: 8,
|
||||
|
||||
@@ -30,6 +30,10 @@ func (config *UserConfig) Validate() error {
|
||||
[]string{"mixed", "filesFirst", "foldersFirst"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("gui.commitGraphStyle", config.Gui.CommitGraphStyle,
|
||||
[]string{"auto", "classic", "detailed"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches,
|
||||
[]string{"none", "onlyMainBranches", "allBranches"}); err != nil {
|
||||
return err
|
||||
|
||||
@@ -743,6 +743,12 @@ func (g *Gui) DetectedColorScheme() DetectedColorScheme {
|
||||
return g.colorScheme
|
||||
}
|
||||
|
||||
// Terminal returns the name and version that the terminal reported at
|
||||
// startup. Both are empty if it didn't report them.
|
||||
func (g *Gui) Terminal() (string, string) {
|
||||
return g.screen.Terminal()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/graph"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
@@ -132,6 +133,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
startIdx,
|
||||
endIdx,
|
||||
shouldShowGraph(c),
|
||||
commitGraphSymbolSet(c),
|
||||
c.Model().BisectInfo,
|
||||
)
|
||||
}
|
||||
@@ -408,6 +410,18 @@ func shouldShowGraph(c *ContextCommon) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func commitGraphSymbolSet(c *ContextCommon) graph.SymbolSet {
|
||||
switch c.UserConfig().Gui.CommitGraphStyle {
|
||||
case "detailed":
|
||||
return graph.BranchDrawingSymbols
|
||||
case "auto":
|
||||
if graph.TerminalDrawsBranchDrawingSymbols(c.GocuiGui().Terminal()) {
|
||||
return graph.BranchDrawingSymbols
|
||||
}
|
||||
}
|
||||
return graph.BoxDrawingSymbols
|
||||
}
|
||||
|
||||
func searchModelCommits(caseSensitive bool, commits []*models.Commit, columnPositions []int,
|
||||
modelToViewIndex func(int) int, searchStr string,
|
||||
) []gocui.SearchPosition {
|
||||
|
||||
@@ -97,6 +97,7 @@ func NewSubCommitsContext(
|
||||
startIdx,
|
||||
endIdx,
|
||||
shouldShowGraph(c),
|
||||
commitGraphSymbolSet(c),
|
||||
git_commands.NewNullBisectInfo(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,21 @@ func (self *DiffLineHelper) MainViewDiffMode() git_commands.DiffMode {
|
||||
return git_commands.DiffModeRendered
|
||||
}
|
||||
|
||||
// DiffRowsCanBePlaced reports whether the rows of the diff the main view is about to be
|
||||
// given can be placed in the file they show. git's own diff describes itself, whether it
|
||||
// is what the user configured or what MainViewDiffMode is about to substitute for a
|
||||
// rendering that can't be acted on; any other rendering says where its rows belong only
|
||||
// if it states records for them.
|
||||
//
|
||||
// It is what anything that means to go from a row back to the file it shows has to ask
|
||||
// first: with neither records nor a diff that describes itself, there is nothing to go
|
||||
// on, and offering the user the way there would be offering nothing.
|
||||
func (self *DiffLineHelper) DiffRowsCanBePlaced() bool {
|
||||
return !self.diffNeedsMetadata() ||
|
||||
self.MainViewDiffMode() == git_commands.DiffModeRaw ||
|
||||
self.diffRendererEmitsMetadata()
|
||||
}
|
||||
|
||||
// RenderFocusedMainViewAgain has the panel beneath the focused main view render its
|
||||
// diff again — which, the main view now holding focus, is git's own diff rather than
|
||||
// the renderer's — and calls place once that is on screen.
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// A diff opens with a diffstat naming every file in it, above the diff of each of
|
||||
// them. Here each of those names is made a link to where that file's diff begins, so
|
||||
// that a file of a long diff can be gone to by clicking the line that names it.
|
||||
//
|
||||
// The names are recognized in the output as it is written to the pane, where they cost
|
||||
// next to nothing to find. The diffstat is git's own text whichever renderer the diff
|
||||
// goes through — delta, diff-so-fancy and difftastic all pass it on untouched — and it
|
||||
// comes first, so the scan for it ends with it.
|
||||
|
||||
// DiffStatLinkScheme names a link to a file of the diff the pane is showing, as
|
||||
// lazygit-edit names one that opens a file in the editor. The link is never handed to
|
||||
// the terminal — gocui takes the escape sequence out of the content and gives the URL
|
||||
// back when the cell it covers is clicked — so the path in it needs no escaping.
|
||||
const DiffStatLinkScheme = "lazygit-diff-file://"
|
||||
|
||||
// diffStatEntryPattern matches a line of a diffstat and captures the path it states.
|
||||
// Such a line holds the name of the file, padded out to the width of the longest, then
|
||||
// the number of lines it changes (or "Bin" for a binary file) and the graph of them.
|
||||
//
|
||||
// The name is captured greedily, so that the separator found is the last one on the
|
||||
// line rather than one in a file name that contains " | " itself.
|
||||
var diffStatEntryPattern = regexp.MustCompile(`^ (.*[^ ]) +\| +(?:Bin|\d+)`)
|
||||
|
||||
// DiffStatLinkWriter hands a pane's content on to it, turning the file names in the
|
||||
// diffstat the content opens with into links (see DiffStatLinkScheme).
|
||||
type DiffStatLinkWriter struct {
|
||||
writer io.Writer
|
||||
|
||||
// Whether the diffstat is still to come, is being written now, or is behind us. It
|
||||
// is behind us once a line comes that is no entry of it, or that the diff proper
|
||||
// begins with, and nothing past that is looked at. A name down there names the file
|
||||
// the reader is already in.
|
||||
//
|
||||
// It is atomic because the render is begun on the UI thread while the content of
|
||||
// it arrives on the goroutine reading the command's output.
|
||||
state atomic.Int32
|
||||
}
|
||||
|
||||
type diffStatState int32
|
||||
|
||||
const (
|
||||
diffStatToCome diffStatState = iota
|
||||
inDiffStat
|
||||
diffStatDone
|
||||
)
|
||||
|
||||
func NewDiffStatLinkWriter(writer io.Writer) *DiffStatLinkWriter {
|
||||
return &DiffStatLinkWriter{writer: writer}
|
||||
}
|
||||
|
||||
// BeginRender starts a fresh render, whose own diffstat is the one to look for. It is
|
||||
// called as the render is asked for, before any of it is written.
|
||||
//
|
||||
// linkFiles says whether this render is one whose file names lead anywhere: the pane's
|
||||
// own diff, whose rows can be placed in the files they show. Either is passed through
|
||||
// untouched without it. Content that is no diff of the panel's — a commit log, a
|
||||
// message — has no diffstat in it, and a line of one that happens to read like an entry
|
||||
// of a diffstat names no file to go to. A rendering whose rows nothing can place does
|
||||
// have the files in it, but nothing to find the one a name stands for with.
|
||||
func (self *DiffStatLinkWriter) BeginRender(linkFiles bool) {
|
||||
self.setState(lo.Ternary(linkFiles, diffStatToCome, diffStatDone))
|
||||
}
|
||||
|
||||
func (self *DiffStatLinkWriter) getState() diffStatState {
|
||||
return diffStatState(self.state.Load())
|
||||
}
|
||||
|
||||
func (self *DiffStatLinkWriter) setState(state diffStatState) {
|
||||
self.state.Store(int32(state))
|
||||
}
|
||||
|
||||
func (self *DiffStatLinkWriter) Write(p []byte) (int, error) {
|
||||
linked := self.withFileNameLinked(p)
|
||||
|
||||
written, err := self.writer.Write(linked)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if written < len(linked) {
|
||||
return 0, io.ErrShortWrite
|
||||
}
|
||||
// The caller is owed an answer about what it gave us, not about what we passed on.
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// withFileNameLinked returns the given line of the render with the name in it linked,
|
||||
// where the line is an entry of the diffstat.
|
||||
func (self *DiffStatLinkWriter) withFileNameLinked(line []byte) []byte {
|
||||
state := self.getState()
|
||||
if state == diffStatDone {
|
||||
return line
|
||||
}
|
||||
|
||||
if beginsTheDiffItself(line) {
|
||||
// A diffstat that hasn't come by now isn't coming: the pane is showing a diff
|
||||
// that was asked for without one.
|
||||
self.setState(diffStatDone)
|
||||
return line
|
||||
}
|
||||
|
||||
match := diffStatEntry(line)
|
||||
if match == nil {
|
||||
if state == inDiffStat {
|
||||
self.setState(diffStatDone)
|
||||
}
|
||||
return line
|
||||
}
|
||||
self.setState(inDiffStat)
|
||||
|
||||
start, end := match[2], match[3]
|
||||
// The link states the name as it reads on screen, so that a renderer that colors
|
||||
// the diffstat doesn't put escape sequences into it.
|
||||
name := utils.Decolorise(string(line[start:end]))
|
||||
linked := make([]byte, 0, len(line)+len(name)+32)
|
||||
linked = append(linked, line[:start]...)
|
||||
linked = append(linked, style.PrintHyperlink(string(line[start:end]), DiffStatLinkScheme+name)...)
|
||||
return append(linked, line[end:]...)
|
||||
}
|
||||
|
||||
// diffLineRecordOpener opens an OSC 1717 record, ahead of the version whose fields the
|
||||
// record states (see parseDiffLineMetadata).
|
||||
const diffLineRecordOpener = "\x1b]1717;"
|
||||
|
||||
// beginsTheDiffItself reports whether the line is one of the diff proper rather than
|
||||
// one of the diffstat above it: git's own header for a file, or a line a renderer
|
||||
// states a record about (see statesADiffLine).
|
||||
func beginsTheDiffItself(line []byte) bool {
|
||||
return bytes.HasPrefix(line, []byte("diff --")) || statesADiffLine(line)
|
||||
}
|
||||
|
||||
// diffLineRecordKinds are the kinds of record a renderer states about a line of the
|
||||
// diff itself. A line stating one of them is below the whole diffstat, which is what
|
||||
// makes them the end of the search for it.
|
||||
//
|
||||
// They are listed here rather than read off the parser's table (see
|
||||
// diffLineTypeFromMetadata), which answers a different question: whether a record can be
|
||||
// read at all. A kind the protocol gains for something above the diff — one renderer
|
||||
// stated the commit line — would belong in that table and not in this list, and taking
|
||||
// the table for this would end the search where the diffstat hasn't even begun. Every
|
||||
// kind of the protocol is held against this list by a test.
|
||||
var diffLineRecordKinds = []string{"c", "a", "d", "f", "h"}
|
||||
|
||||
// statesADiffLine reports whether the line carries a record in which a diff renderer
|
||||
// states which line of which file it is rendering. Those records are about the lines of
|
||||
// the diff, and the diffstat comes before all of them.
|
||||
//
|
||||
// The kind the record states has to be one of diffLineRecordKinds. A record of any
|
||||
// other kind says nothing about where the diffstat ends, so the search goes on past it.
|
||||
//
|
||||
// The version the record opens with is passed over rather than read. This asks which
|
||||
// lines a renderer states records for, and the answer holds whichever version of the
|
||||
// protocol it speaks. A record with nothing after the version is the handshake a
|
||||
// renderer announces itself with, which is about no line, so the search goes on past it.
|
||||
func statesADiffLine(line []byte) bool {
|
||||
for rest := line; ; {
|
||||
at := bytes.Index(rest, []byte(diffLineRecordOpener))
|
||||
if at == -1 {
|
||||
return false
|
||||
}
|
||||
rest = rest[at+len(diffLineRecordOpener):]
|
||||
|
||||
// The record reads <version>;<kind>;, and the kind is a single character.
|
||||
digits := 0
|
||||
for digits < len(rest) && rest[digits] >= '0' && rest[digits] <= '9' {
|
||||
digits++
|
||||
}
|
||||
kind := digits + 1
|
||||
if digits == 0 || kind+1 >= len(rest) || rest[digits] != ';' || rest[kind+1] != ';' {
|
||||
continue
|
||||
}
|
||||
if lo.Contains(diffLineRecordKinds, string(rest[kind:kind+1])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// diffStatEntry matches line against diffStatEntryPattern, behind the two checks that
|
||||
// answer for nearly every line of a diff without the pattern being run at all: an entry
|
||||
// of a diffstat is indented by a space, and holds the separator. The indices it returns
|
||||
// are into the whole line, whatever the match was made past.
|
||||
func diffStatEntry(line []byte) []int {
|
||||
start := handshakeEnd(line)
|
||||
rest := line[start:]
|
||||
if len(rest) == 0 || rest[0] != ' ' || bytes.IndexByte(rest, '|') == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
match := diffStatEntryPattern.FindSubmatchIndex(rest)
|
||||
for i := range match {
|
||||
if match[i] >= 0 {
|
||||
match[i] += start
|
||||
}
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
// handshakeEnd returns where the record a renderer announces itself with ends, for a
|
||||
// line that opens with one, and 0 for every other line.
|
||||
//
|
||||
// A renderer writes the handshake before anything else and with no newline after it,
|
||||
// so it lands at the start of the first line of its output. For a diff with nothing
|
||||
// above its diffstat — the diff of a range of commits, or of a stash — that is the line
|
||||
// naming the first file in it, and the entry begins after the record rather than at the
|
||||
// start of the line.
|
||||
func handshakeEnd(line []byte) int {
|
||||
if !bytes.HasPrefix(line, []byte(diffLineRecordOpener)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
after := len(diffLineRecordOpener)
|
||||
for after < len(line) && line[after] >= '0' && line[after] <= '9' {
|
||||
after++
|
||||
}
|
||||
|
||||
// Either terminator ends a record. One that goes on into a field instead is about
|
||||
// a line of the diff, which is below the whole diffstat and no entry of it.
|
||||
switch {
|
||||
case after < len(line) && line[after] == '\x07':
|
||||
return after + 1
|
||||
case after+1 < len(line) && line[after] == '\x1b' && line[after+1] == '\\':
|
||||
return after + 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// JumpToFileNamedInDiffStat goes to the file of the pane's diff that the given diffstat
|
||||
// entry names, for a click on the link made for that entry. It lands the way picking
|
||||
// the file from the menu of the diff's files does.
|
||||
//
|
||||
// The diff is read to the end first, as it is for that menu. The diffstat is on screen
|
||||
// only while the view is at the top of the diff, so the file clicked is nearly always
|
||||
// below the part of it that has been read.
|
||||
func (self *DiffLineHelper) JumpToFileNamedInDiffStat(pane types.DiffPaneContext, entry string) {
|
||||
manager := self.c.GetViewBufferManagerForView(pane.GetView())
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
self.jumpToFileNamedInDiffStat(pane, entry)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (self *DiffLineHelper) jumpToFileNamedInDiffStat(pane types.DiffPaneContext, entry string) {
|
||||
view := pane.GetView()
|
||||
worktreePath := self.c.Git().RepoPaths.WorktreePath()
|
||||
files := self.FilesInDiff(view)
|
||||
names := lo.Map(files, func(file string, _ int) string {
|
||||
return repoRelativePath(worktreePath, file)
|
||||
})
|
||||
|
||||
index, ok := fileNamedByDiffStatEntry(entry, names)
|
||||
if !ok {
|
||||
self.c.ErrorToast(utils.ResolvePlaceholderString(
|
||||
self.c.Tr.NoFileInDiffNamed, map[string]string{"path": entry}))
|
||||
return
|
||||
}
|
||||
|
||||
if target, ok := self.StartOfFileInDiff(view, files[index]); ok {
|
||||
self.PlaceNavigationTarget(pane, target, true)
|
||||
}
|
||||
}
|
||||
|
||||
// fileNamedByDiffStatEntry returns which of the diff's files a diffstat entry names.
|
||||
//
|
||||
// An entry states the path as the diffstat has room for it rather than as git names
|
||||
// the file. A path too long for the column is cut off on the left behind "...", and a
|
||||
// rename is compacted to the "{old => new}" form. So the name is looked for among the
|
||||
// files the diff turned out to hold, whole and then as the end of one, and is taken
|
||||
// only where it names a single file.
|
||||
func fileNamedByDiffStatEntry(entry string, paths []string) (int, bool) {
|
||||
name := renamedTo(strings.TrimSpace(entry))
|
||||
|
||||
if index, ok := theOneMatching(paths, func(p string) bool { return p == name }); ok {
|
||||
return index, true
|
||||
}
|
||||
|
||||
// Where the diffstat cut the path off, what is left is the end of it. The cut is at
|
||||
// a directory boundary where there is room for one, and inside the file name where
|
||||
// there isn't.
|
||||
tail := strings.TrimPrefix(name, "...")
|
||||
return theOneMatching(paths, func(p string) bool { return strings.HasSuffix(p, tail) })
|
||||
}
|
||||
|
||||
// renamedTo returns the path a diffstat entry for a rename leaves the file at, and the
|
||||
// entry itself for any other one. A rename states both paths, with whatever they have
|
||||
// in common written once: "dir/{old => new}/file", or "old => new" where they share
|
||||
// nothing. The part shared with the old path is gone along with the "{" when the entry
|
||||
// is cut off on the left, which leaves a path to match the end of.
|
||||
func renamedTo(entry string) string {
|
||||
const arrow = " => "
|
||||
at := strings.Index(entry, arrow)
|
||||
if at == -1 {
|
||||
return entry
|
||||
}
|
||||
|
||||
shared := ""
|
||||
if brace := strings.Index(entry[:at], "{"); brace != -1 {
|
||||
shared = entry[:brace]
|
||||
}
|
||||
renamed := entry[at+len(arrow):]
|
||||
if closing := strings.Index(renamed, "}"); closing != -1 {
|
||||
return shared + renamed[:closing] + renamed[closing+1:]
|
||||
}
|
||||
return shared + renamed
|
||||
}
|
||||
|
||||
// theOneMatching returns the index of the one element the predicate holds for, and
|
||||
// false where it holds for none of them or for several.
|
||||
func theOneMatching(paths []string, matches func(string) bool) (int, bool) {
|
||||
found := -1
|
||||
for i, candidate := range paths {
|
||||
if !matches(candidate) {
|
||||
continue
|
||||
}
|
||||
if found != -1 {
|
||||
return 0, false
|
||||
}
|
||||
found = i
|
||||
}
|
||||
return found, found != -1
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// link is the line the writer is expected to produce for a diffstat entry: the space
|
||||
// it is indented by, the name linked, and the rest of the line as it came.
|
||||
func link(name string, rest string) string {
|
||||
return " " + style.PrintHyperlink(name, DiffStatLinkScheme+name) + rest
|
||||
}
|
||||
|
||||
// record is the OSC 1717 record a diff renderer speaking the given version of the
|
||||
// protocol states a line of the given kind with, as it precedes that line in its
|
||||
// output.
|
||||
func record(version string, kind string) string {
|
||||
return fmt.Sprintf("%s%s;%s;;;pkg/gui.go\x1b\\", diffLineRecordOpener, version, kind)
|
||||
}
|
||||
|
||||
// handshake is the record a renderer announces the protocol with: the version it
|
||||
// speaks, and nothing about any line. Renderers end their records with either
|
||||
// terminator, so both turn up.
|
||||
var (
|
||||
handshake = diffLineRecordOpener + "1\x1b\\"
|
||||
handshakeBel = diffLineRecordOpener + "1\x07"
|
||||
)
|
||||
|
||||
func TestDiffStatLinkWriter(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
linkFiles bool
|
||||
lines []string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "links the entries of the diffstat, and nothing after it",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
"commit 1234567",
|
||||
"",
|
||||
" A commit message",
|
||||
"",
|
||||
" pkg/gui.go | 12 ++++++------",
|
||||
" dir/other.go | 3 ++-",
|
||||
" 2 files changed, 8 insertions(+), 7 deletions(-)",
|
||||
"",
|
||||
"diff --git a/pkg/gui.go b/pkg/gui.go",
|
||||
" a context line that reads like an entry | 3 ++-",
|
||||
},
|
||||
expected: []string{
|
||||
"commit 1234567",
|
||||
"",
|
||||
" A commit message",
|
||||
"",
|
||||
link("pkg/gui.go", " | 12 ++++++------"),
|
||||
link("dir/other.go", " | 3 ++-"),
|
||||
" 2 files changed, 8 insertions(+), 7 deletions(-)",
|
||||
"",
|
||||
"diff --git a/pkg/gui.go b/pkg/gui.go",
|
||||
" a context line that reads like an entry | 3 ++-",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "links a binary file, a file that changes nothing, and a name with spaces",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
" logo.png | Bin 0 -> 1234 bytes",
|
||||
" script.sh | 0",
|
||||
" my file.txt | 2 +-",
|
||||
},
|
||||
expected: []string{
|
||||
link("logo.png", " | Bin 0 -> 1234 bytes"),
|
||||
link("script.sh", " | 0"),
|
||||
link("my file.txt", " | 2 +-"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stops looking once the diff itself has begun",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
"diff --git a/pkg/gui.go b/pkg/gui.go",
|
||||
" a context line that reads like an entry | 3 ++-",
|
||||
},
|
||||
expected: []string{
|
||||
"diff --git a/pkg/gui.go b/pkg/gui.go",
|
||||
" a context line that reads like an entry | 3 ++-",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stops looking at the first line of the diff a renderer states",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
" pkg/gui.go | 1 +",
|
||||
record("1", "c") + " a context line of the diff",
|
||||
" this/looks/like/a/diff/stat | 2 +",
|
||||
},
|
||||
expected: []string{
|
||||
link("pkg/gui.go", " | 1 +"),
|
||||
record("1", "c") + " a context line of the diff",
|
||||
" this/looks/like/a/diff/stat | 2 +",
|
||||
},
|
||||
},
|
||||
{
|
||||
// A renderer is free to state records about something that is no line of
|
||||
// the diff, and one has stated the commit line above it. The diffstat is
|
||||
// below such a record as much as it is below the handshake.
|
||||
name: "keeps looking past a record of a kind it doesn't know",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
record("1", "C") + "commit 1234567",
|
||||
" pkg/gui.go | 1 +",
|
||||
},
|
||||
expected: []string{
|
||||
record("1", "C") + "commit 1234567",
|
||||
link("pkg/gui.go", " | 1 +"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stops for a record of a protocol version it doesn't read",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
record("7", "f") + "── pkg/gui.go ──",
|
||||
" this/looks/like/a/diff/stat | 2 +",
|
||||
},
|
||||
expected: []string{
|
||||
record("7", "f") + "── pkg/gui.go ──",
|
||||
" this/looks/like/a/diff/stat | 2 +",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keeps looking past the handshake, which states no line",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
handshake,
|
||||
" pkg/gui.go | 1 +",
|
||||
},
|
||||
expected: []string{
|
||||
handshake,
|
||||
link("pkg/gui.go", " | 1 +"),
|
||||
},
|
||||
},
|
||||
{
|
||||
// A diff with nothing above its diffstat. The handshake is written with no
|
||||
// newline after it, so it runs into the entry naming the first file.
|
||||
name: "links an entry the handshake runs into",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
handshake + " pkg/gui.go | 1 +",
|
||||
" dir/other.go | 2 +-",
|
||||
},
|
||||
expected: []string{
|
||||
handshake + link("pkg/gui.go", " | 1 +"),
|
||||
link("dir/other.go", " | 2 +-"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "links an entry a handshake ended with a BEL runs into",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
handshakeBel + " pkg/gui.go | 1 +",
|
||||
},
|
||||
expected: []string{
|
||||
handshakeBel + link("pkg/gui.go", " | 1 +"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leaves a render whose file names lead nowhere alone",
|
||||
linkFiles: false,
|
||||
lines: []string{
|
||||
" pkg/gui.go | 12 ++++++------",
|
||||
},
|
||||
expected: []string{
|
||||
" pkg/gui.go | 12 ++++++------",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
buffer := &bytes.Buffer{}
|
||||
writer := NewDiffStatLinkWriter(buffer)
|
||||
writer.BeginRender(scenario.linkFiles)
|
||||
|
||||
for _, line := range scenario.lines {
|
||||
written, err := writer.Write([]byte(line + "\n"))
|
||||
assert.NoError(t, err)
|
||||
// The writer answers for what it was given, not for what it passed on.
|
||||
assert.Equal(t, len(line)+1, written)
|
||||
}
|
||||
|
||||
assert.Equal(t, strings.Join(scenario.expected, "\n")+"\n", buffer.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffStatLinkWriterStartsLookingAgainWithEachRender(t *testing.T) {
|
||||
buffer := &bytes.Buffer{}
|
||||
writer := NewDiffStatLinkWriter(buffer)
|
||||
|
||||
for range 2 {
|
||||
buffer.Reset()
|
||||
writer.BeginRender(true)
|
||||
_, _ = writer.Write([]byte(" pkg/gui.go | 1 +\n"))
|
||||
_, _ = writer.Write([]byte(" 1 file changed, 1 insertion(+)\n"))
|
||||
|
||||
assert.Equal(t, link("pkg/gui.go", " | 1 +")+
|
||||
"\n 1 file changed, 1 insertion(+)\n", buffer.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileNamedByDiffStatEntry(t *testing.T) {
|
||||
paths := []string{
|
||||
"pkg/gui.go",
|
||||
"pkg/integration/tests/main_view/jump_to_a_file_of_the_diff.go",
|
||||
"vendor/github.com/gdamore/tcell/v3/AUTHORS",
|
||||
"pkg/gocui/AUTHORS",
|
||||
"renamed.txt",
|
||||
"a/very/deeply/nested/directory/structure/some_long_file_name.txt",
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
entry string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "a path the diffstat had room for",
|
||||
entry: "pkg/gui.go",
|
||||
expected: "pkg/gui.go",
|
||||
},
|
||||
{
|
||||
name: "a path cut off at a directory boundary",
|
||||
entry: ".../tests/main_view/jump_to_a_file_of_the_diff.go",
|
||||
expected: "pkg/integration/tests/main_view/jump_to_a_file_of_the_diff.go",
|
||||
},
|
||||
{
|
||||
name: "a path cut off inside the file name",
|
||||
entry: "..._long_file_name.txt",
|
||||
expected: "a/very/deeply/nested/directory/structure/some_long_file_name.txt",
|
||||
},
|
||||
{
|
||||
name: "a rename, stated as the part the two paths share",
|
||||
entry: "vendor/github.com/gdamore/tcell/{v2 => v3}/AUTHORS",
|
||||
expected: "vendor/github.com/gdamore/tcell/v3/AUTHORS",
|
||||
},
|
||||
{
|
||||
name: "a rename whose shared part was cut off along with the brace",
|
||||
entry: ".../github.com/jesseduffield => pkg}/gocui/AUTHORS",
|
||||
expected: "pkg/gocui/AUTHORS",
|
||||
},
|
||||
{
|
||||
name: "a rename of paths that share nothing",
|
||||
entry: "original.txt => renamed.txt",
|
||||
expected: "renamed.txt",
|
||||
},
|
||||
{
|
||||
name: "a name of no file of the diff",
|
||||
entry: "pkg/nowhere.go",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "a name several files of the diff end with",
|
||||
entry: "AUTHORS",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
index, ok := fileNamedByDiffStatEntry(scenario.entry, paths)
|
||||
if scenario.expected == "" {
|
||||
assert.False(t, ok)
|
||||
return
|
||||
}
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, scenario.expected, paths[index])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryRecordKindIsWeighedAgainstTheDiffStat fails when the protocol gains a kind
|
||||
// of record that nobody has placed relative to the diffstat. Where a line stating that
|
||||
// kind can only come below the diffstat, it ends the search for it and belongs in
|
||||
// diffLineRecordKinds; where it can come above the diff — a record about the commit,
|
||||
// say — it says nothing about where the diffstat ends, and belongs in the list here.
|
||||
//
|
||||
// The parser takes the kind as a field rather than a table, so the kinds it reads are
|
||||
// found by asking it about each character in turn.
|
||||
func TestEveryRecordKindIsWeighedAgainstTheDiffStat(t *testing.T) {
|
||||
kindsAboveTheDiff := []string{}
|
||||
|
||||
printable := lo.RangeFrom(byte(' '), 0x7f-' ')
|
||||
kindsTheParserReads := lo.FilterMap(printable, func(char byte, _ int) (string, bool) {
|
||||
kind := string([]byte{char})
|
||||
_, ok := diffLineTypeFromMetadata(kind)
|
||||
return kind, ok
|
||||
})
|
||||
|
||||
assert.ElementsMatch(t,
|
||||
append(slices.Clone(diffLineRecordKinds), kindsAboveTheDiff...),
|
||||
kindsTheParserReads,
|
||||
"a kind of record has been added to the protocol without being weighed "+
|
||||
"against the diffstat; see this test's comment for where it belongs")
|
||||
}
|
||||
+28
-11
@@ -82,6 +82,9 @@ type Gui struct {
|
||||
statusManager *status.StatusManager
|
||||
waitForIntro sync.WaitGroup
|
||||
viewBufferManagerMap map[string]*tasks.ViewBufferManager
|
||||
// holds a mapping of the main section's view names to the writers that link the
|
||||
// files named in the diffstat of what is rendered into them
|
||||
diffStatLinkWriterMap map[string]*helpers.DiffStatLinkWriter
|
||||
// holds a mapping of view names to ptmx's. This is for rendering command outputs
|
||||
// from within a pty. The point of keeping track of them is so that if we re-size
|
||||
// the window, we can tell the pty it needs to resize accordingly.
|
||||
@@ -415,6 +418,17 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
|
||||
return gui.helpers.Files.EditFiles([]string{filepath})
|
||||
}
|
||||
|
||||
if entry, ok := strings.CutPrefix(url, helpers.DiffStatLinkScheme); ok {
|
||||
view, err := gui.g.View(viewname)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if pane := gui.mainContextForView(view); pane != nil {
|
||||
gui.helpers.DiffLine.JumpToFileNamedInDiffStat(pane, entry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := gui.os.OpenLink(url); err != nil {
|
||||
return fmt.Errorf(gui.Tr.FailedToOpenURL, url, err)
|
||||
}
|
||||
@@ -775,17 +789,18 @@ func NewGui(
|
||||
test integrationTypes.IntegrationTest,
|
||||
) (*Gui, error) {
|
||||
gui := &Gui{
|
||||
Common: cmn,
|
||||
gitVersion: gitVersion,
|
||||
Config: configurer,
|
||||
Updater: updater,
|
||||
statusManager: status.NewStatusManager(),
|
||||
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
|
||||
viewPtmxMap: map[string]oscommands.Pty{},
|
||||
showRecentRepos: showRecentRepos,
|
||||
RepoPathStack: &utils.Stack[types.RepoLocation]{},
|
||||
RepoStateMap: map[Repo]*GuiRepoState{},
|
||||
GuiLog: []string{},
|
||||
Common: cmn,
|
||||
gitVersion: gitVersion,
|
||||
Config: configurer,
|
||||
Updater: updater,
|
||||
statusManager: status.NewStatusManager(),
|
||||
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
|
||||
diffStatLinkWriterMap: map[string]*helpers.DiffStatLinkWriter{},
|
||||
viewPtmxMap: map[string]oscommands.Pty{},
|
||||
showRecentRepos: showRecentRepos,
|
||||
RepoPathStack: &utils.Stack[types.RepoLocation]{},
|
||||
RepoStateMap: map[Repo]*GuiRepoState{},
|
||||
GuiLog: []string{},
|
||||
|
||||
// initializing this to true for the time being; it will be reset to the
|
||||
// real value after loading the user config:
|
||||
@@ -938,6 +953,8 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
|
||||
|
||||
g.ErrorHandler = gui.PopupHandler.ErrorHandler
|
||||
|
||||
terminalName, terminalVersion := g.Terminal()
|
||||
gui.c.Log.Infof("Terminal: %s %s", terminalName, terminalVersion)
|
||||
gui.c.Log.Infof("Terminal color scheme: %s", g.DetectedColorScheme())
|
||||
g.SetColorSchemeChangeHandler(func(colorScheme gocui.DetectedColorScheme) error {
|
||||
gui.c.Log.Infof("Terminal color scheme changed: %s", colorScheme)
|
||||
|
||||
@@ -74,6 +74,12 @@ func (gui *Gui) RefreshMainView(opts *types.ViewUpdateOpts, context types.Contex
|
||||
// or a log, and reads as badly cut off at the edge of the pane as it would
|
||||
// anywhere else.
|
||||
view.Wrap = !mainContext.ContentIsDiff() || gui.c.UserConfig().Gui.WrapLinesInDiffView
|
||||
// The files named in the diffstat are linked to where their diff begins, over a
|
||||
// render that has both: the panel's own diff, and rows that can be placed in the
|
||||
// files they show. The writer is told here, on the UI thread, since it is asked
|
||||
// on the one reading the command's output.
|
||||
gui.diffStatLinkWriter(view).BeginRender(
|
||||
mainContext.ContentIsDiff() && gui.helpers.DiffLine.DiffRowsCanBePlaced())
|
||||
}
|
||||
|
||||
if err := gui.runTaskForView(view, opts.Task); err != nil {
|
||||
|
||||
@@ -59,6 +59,7 @@ func GetCommitListDisplayStrings(
|
||||
startIdx int,
|
||||
endIdx int,
|
||||
showGraph bool,
|
||||
graphSymbolSet graph.SymbolSet,
|
||||
bisectInfo *git_commands.BisectInfo,
|
||||
) [][]string {
|
||||
mutex.Lock()
|
||||
@@ -107,6 +108,7 @@ func GetCommitListDisplayStrings(
|
||||
graphPipeSets,
|
||||
graphCommits,
|
||||
selectedCommitHashPtr,
|
||||
graphSymbolSet,
|
||||
)
|
||||
allGraphLines = append(allGraphLines, graphLines...)
|
||||
}
|
||||
@@ -124,6 +126,7 @@ func GetCommitListDisplayStrings(
|
||||
graphPipeSets,
|
||||
graphCommits,
|
||||
selectedCommitHashPtr,
|
||||
graphSymbolSet,
|
||||
)
|
||||
allGraphLines = append(allGraphLines, graphLines...)
|
||||
}
|
||||
@@ -145,6 +148,7 @@ func GetCommitListDisplayStrings(
|
||||
graphPipeSets,
|
||||
graphCommits,
|
||||
selectedCommitHashPtr,
|
||||
graphSymbolSet,
|
||||
)
|
||||
getGraphLine = func(idx int) string {
|
||||
if idx >= graphOffset {
|
||||
|
||||
@@ -611,6 +611,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
s.startIdx,
|
||||
s.endIdx,
|
||||
s.showGraph,
|
||||
graph.BoxDrawingSymbols,
|
||||
s.bisectInfo,
|
||||
)
|
||||
|
||||
@@ -635,7 +636,7 @@ func TestGraphColorsFollowTheAuthorColors(t *testing.T) {
|
||||
models.NewCommit(hashPool, models.NewCommitOpts{Hash: "authorcolors2", AuthorName: "Jane Doe"}),
|
||||
}
|
||||
renderGraph := func() string {
|
||||
return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil), "\n")
|
||||
return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil, graph.BoxDrawingSymbols), "\n")
|
||||
}
|
||||
|
||||
authors.SetCustomAuthors(map[string]string{"Jane Doe": "red"})
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The branch drawing symbols are characters in the Unicode Private Use Area
|
||||
// (U+F5D0 to U+F60D) for drawing git graphs, introduced by kitty in
|
||||
// https://github.com/kovidgoyal/kitty/pull/7681. Unlike the box drawing
|
||||
// characters, they can show how the lines in a cell connect. For example, there
|
||||
// is a symbol for a cell in which a line from above bends to the left and a
|
||||
// line from the left bends down.
|
||||
|
||||
const branchDrawingHorizontal = "\uf5d0"
|
||||
|
||||
// The lines in a cell that isn't a commit
|
||||
type lineCourses struct {
|
||||
up, down verticalLine
|
||||
horizontal bool
|
||||
}
|
||||
|
||||
var branchDrawingConnections = map[lineCourses]string{
|
||||
{}: " ",
|
||||
{horizontal: true}: branchDrawingHorizontal,
|
||||
// A horizontal line passing behind a vertical one only shows in the
|
||||
// neighbouring cells
|
||||
{up: straightLine, down: straightLine}: "\uf5d1", // │
|
||||
{up: straightLine, down: straightLine, horizontal: true}: "\uf5d1", // │
|
||||
{up: lineToLeft}: "\uf5d9", // ╯
|
||||
{up: lineToRight}: "\uf5d8", // ╰
|
||||
{down: lineToLeft}: "\uf5d7", // ╮
|
||||
{down: lineToRight}: "\uf5d6", // ╭
|
||||
{up: lineToLeft, horizontal: true}: "\uf5e3", // ╯ on ─
|
||||
{up: lineToRight, horizontal: true}: "\uf5e4", // ╰ on ─
|
||||
{down: lineToLeft, horizontal: true}: "\uf5e0", // ╮ on ─
|
||||
{down: lineToRight, horizontal: true}: "\uf5e1", // ╭ on ─
|
||||
{up: lineToLeft, down: lineToLeft}: "\uf5df", // ╯ and ╮
|
||||
{up: lineToRight, down: lineToRight}: "\uf5dc", // ╰ and ╭
|
||||
{up: lineToLeft, down: lineToLeft, horizontal: true}: "\uf5e8", // ╯ and ╮ on ─
|
||||
{up: lineToRight, down: lineToRight, horizontal: true}: "\uf5e9", // ╰ and ╭ on ─
|
||||
{up: lineToLeft, down: lineToRight, horizontal: true}: "\uf5ec", // ╯ and ╭ on ─
|
||||
{up: lineToRight, down: lineToLeft, horizontal: true}: "\uf5ed", // ╰ and ╮ on ─
|
||||
}
|
||||
|
||||
// The edges of a commit's cell that its lines touch
|
||||
type commitEdges struct {
|
||||
up, down, left, right bool
|
||||
}
|
||||
|
||||
// Commits are drawn as hollow circles, merge commits as filled ones. Both
|
||||
// connect to the lines at the given edges.
|
||||
var branchDrawingCommitSymbols = map[commitEdges]struct{ commit, merge string }{
|
||||
{}: {"\uf5ef", "\uf5ee"},
|
||||
{right: true}: {"\uf5f1", "\uf5f0"},
|
||||
{left: true}: {"\uf5f3", "\uf5f2"},
|
||||
{left: true, right: true}: {"\uf5f5", "\uf5f4"},
|
||||
{down: true}: {"\uf5f7", "\uf5f6"},
|
||||
{up: true}: {"\uf5f9", "\uf5f8"},
|
||||
{up: true, down: true}: {"\uf5fb", "\uf5fa"},
|
||||
{down: true, right: true}: {"\uf5fd", "\uf5fc"},
|
||||
{down: true, left: true}: {"\uf5ff", "\uf5fe"},
|
||||
{up: true, right: true}: {"\uf601", "\uf600"},
|
||||
{up: true, left: true}: {"\uf603", "\uf602"},
|
||||
{up: true, down: true, right: true}: {"\uf605", "\uf604"},
|
||||
{up: true, down: true, left: true}: {"\uf607", "\uf606"},
|
||||
{down: true, left: true, right: true}: {"\uf609", "\uf608"},
|
||||
{up: true, left: true, right: true}: {"\uf60b", "\uf60a"},
|
||||
{up: true, down: true, left: true, right: true}: {"\uf60d", "\uf60c"},
|
||||
}
|
||||
|
||||
func (cell *Cell) branchDrawingChars() (string, string) {
|
||||
second := " "
|
||||
if cell.right {
|
||||
second = branchDrawingHorizontal
|
||||
}
|
||||
|
||||
switch cell.cellType {
|
||||
case COMMIT, MERGE:
|
||||
symbols := branchDrawingCommitSymbols[commitEdges{
|
||||
up: cell.up != noLine,
|
||||
down: cell.down != noLine,
|
||||
left: cell.left,
|
||||
right: cell.right,
|
||||
}]
|
||||
if cell.cellType == MERGE {
|
||||
return symbols.merge, second
|
||||
}
|
||||
return symbols.commit, second
|
||||
default:
|
||||
if cell.horizontalOnTop {
|
||||
return branchDrawingHorizontal, second
|
||||
}
|
||||
if first, ok := branchDrawingConnections[lineCourses{cell.up, cell.down, cell.horizontal}]; ok {
|
||||
return first, second
|
||||
}
|
||||
// There is no symbol for lines that the layout of the graph never
|
||||
// produces, such as a lone half of a line
|
||||
first, _ := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right)
|
||||
return first, second
|
||||
}
|
||||
}
|
||||
|
||||
// The terminals that draw the branch drawing symbols themselves, with the
|
||||
// first version that draws all of them
|
||||
var terminalsWithBranchDrawingSymbols = map[string][]int{
|
||||
"kitty": {0, 36, 2},
|
||||
"ghostty": {1, 0, 0},
|
||||
}
|
||||
|
||||
// TerminalDrawsBranchDrawingSymbols says whether a terminal is known to draw
|
||||
// the branch drawing symbols itself, given the name and version it reports
|
||||
func TerminalDrawsBranchDrawingSymbols(name, version string) bool {
|
||||
minVersion, ok := terminalsWithBranchDrawingSymbols[strings.ToLower(name)]
|
||||
return ok && slices.Compare(versionNumbers(version), minVersion) >= 0
|
||||
}
|
||||
|
||||
// The numbers at the start of the dot-separated parts of a version, e.g.
|
||||
// [1 3 0] for "1.3.0-dev+abc"
|
||||
func versionNumbers(version string) []int {
|
||||
var numbers []int
|
||||
for part := range strings.SplitSeq(version, ".") {
|
||||
digits := part[:len(part)-len(strings.TrimLeft(part, "0123456789"))]
|
||||
number, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
numbers = append(numbers, number)
|
||||
}
|
||||
return numbers
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTerminalDrawsBranchDrawingSymbols(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
{name: "kitty", version: "0.36.1", expected: false},
|
||||
{name: "kitty", version: "0.36.2", expected: true},
|
||||
{name: "kitty", version: "0.44.0", expected: true},
|
||||
{name: "ghostty", version: "0.9.0", expected: false},
|
||||
{name: "ghostty", version: "1.0.0", expected: true},
|
||||
{name: "ghostty", version: "1.3.0-main+0123abcd", expected: true},
|
||||
{name: "WezTerm", version: "20250601-102030-89abcdef", expected: false},
|
||||
{name: "tmux", version: "3.5a", expected: false},
|
||||
{name: "iTerm2", version: "3.6.4", expected: false},
|
||||
{name: "kitty", version: "", expected: false},
|
||||
{name: "", version: "", expected: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name+" "+test.version, func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, TerminalDrawsBranchDrawingSymbols(test.name, test.version))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,14 @@ const (
|
||||
CommitSymbol = '○'
|
||||
)
|
||||
|
||||
// The characters that the commit graph is drawn with
|
||||
type SymbolSet uint8
|
||||
|
||||
const (
|
||||
BoxDrawingSymbols SymbolSet = iota
|
||||
BranchDrawingSymbols
|
||||
)
|
||||
|
||||
type cellType int
|
||||
|
||||
const (
|
||||
@@ -21,25 +29,40 @@ const (
|
||||
MERGE
|
||||
)
|
||||
|
||||
// How a line that touches the top or bottom edge of a cell runs within it
|
||||
type verticalLine uint8
|
||||
|
||||
const (
|
||||
noLine verticalLine = iota
|
||||
// On to the opposite edge, or into the commit symbol
|
||||
straightLine
|
||||
// Bends towards the left edge
|
||||
lineToLeft
|
||||
// Bends towards the right edge
|
||||
lineToRight
|
||||
)
|
||||
|
||||
type Cell struct {
|
||||
up, down, left, right bool
|
||||
cellType cellType
|
||||
rightStyle *style.TextStyle
|
||||
style *style.TextStyle
|
||||
// The lines that touch the top and bottom edges
|
||||
up, down verticalLine
|
||||
// Whether lines touch the left and right edges
|
||||
left, right bool
|
||||
// Whether a line passes through from the left edge to the right edge
|
||||
horizontal bool
|
||||
// Whether that line is drawn over the vertical line that it crosses
|
||||
horizontalOnTop bool
|
||||
cellType cellType
|
||||
rightStyle *style.TextStyle
|
||||
style *style.TextStyle
|
||||
}
|
||||
|
||||
func (cell *Cell) render(writer io.StringWriter) {
|
||||
up, down, left, right := cell.up, cell.down, cell.left, cell.right
|
||||
|
||||
first, second := getBoxDrawingChars(up, down, left, right)
|
||||
var adjustedFirst string
|
||||
switch cell.cellType {
|
||||
case CONNECTION:
|
||||
adjustedFirst = first
|
||||
case COMMIT:
|
||||
adjustedFirst = string(CommitSymbol)
|
||||
case MERGE:
|
||||
adjustedFirst = string(MergeSymbol)
|
||||
func (cell *Cell) render(writer io.StringWriter, symbolSet SymbolSet) {
|
||||
var first, second string
|
||||
switch symbolSet {
|
||||
case BoxDrawingSymbols:
|
||||
first, second = cell.boxDrawingChars()
|
||||
case BranchDrawingSymbols:
|
||||
first, second = cell.branchDrawingChars()
|
||||
}
|
||||
|
||||
var rightStyle *style.TextStyle
|
||||
@@ -59,10 +82,22 @@ func (cell *Cell) render(writer io.StringWriter) {
|
||||
styledSecondChar = cachedSprint(*rightStyle, second)
|
||||
}
|
||||
|
||||
_, _ = writer.WriteString(cachedSprint(*cell.style, adjustedFirst))
|
||||
_, _ = writer.WriteString(cachedSprint(*cell.style, first))
|
||||
_, _ = writer.WriteString(styledSecondChar)
|
||||
}
|
||||
|
||||
func (cell *Cell) boxDrawingChars() (string, string) {
|
||||
first, second := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right)
|
||||
switch cell.cellType {
|
||||
case COMMIT:
|
||||
return string(CommitSymbol), second
|
||||
case MERGE:
|
||||
return string(MergeSymbol), second
|
||||
default:
|
||||
return first, second
|
||||
}
|
||||
}
|
||||
|
||||
type rgbCacheKey struct {
|
||||
*color.RGBStyle
|
||||
str string
|
||||
@@ -98,34 +133,35 @@ func cachedSprint(style style.TextStyle, str string) string {
|
||||
}
|
||||
|
||||
func (cell *Cell) reset() {
|
||||
cell.up = false
|
||||
cell.down = false
|
||||
cell.up = noLine
|
||||
cell.down = noLine
|
||||
cell.left = false
|
||||
cell.right = false
|
||||
cell.horizontal = false
|
||||
cell.horizontalOnTop = false
|
||||
}
|
||||
|
||||
func (cell *Cell) setUp(style *style.TextStyle) *Cell {
|
||||
cell.up = true
|
||||
func (cell *Cell) setUp(style *style.TextStyle, line verticalLine) *Cell {
|
||||
cell.up = line
|
||||
cell.style = style
|
||||
return cell
|
||||
}
|
||||
|
||||
func (cell *Cell) setDown(style *style.TextStyle) *Cell {
|
||||
cell.down = true
|
||||
func (cell *Cell) setDown(style *style.TextStyle, line verticalLine) *Cell {
|
||||
cell.down = line
|
||||
cell.style = style
|
||||
return cell
|
||||
}
|
||||
|
||||
func (cell *Cell) setLeft(style *style.TextStyle) *Cell {
|
||||
cell.left = true
|
||||
if !cell.up && !cell.down {
|
||||
if cell.up == noLine && cell.down == noLine {
|
||||
// vertical trumps left
|
||||
cell.style = style
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
//nolint:unparam
|
||||
func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell {
|
||||
cell.right = true
|
||||
if cell.rightStyle == nil || override {
|
||||
@@ -134,6 +170,11 @@ func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell {
|
||||
return cell
|
||||
}
|
||||
|
||||
func (cell *Cell) setHorizontal(style *style.TextStyle, overrideRightStyle bool) *Cell {
|
||||
cell.horizontal = true
|
||||
return cell.setLeft(style).setRight(style, overrideRightStyle)
|
||||
}
|
||||
|
||||
func (cell *Cell) setStyle(style *style.TextStyle) *Cell {
|
||||
cell.style = style
|
||||
return cell
|
||||
|
||||
@@ -45,13 +45,13 @@ func (self Pipe) right() int16 {
|
||||
return max(self.fromPos, self.toPos)
|
||||
}
|
||||
|
||||
func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle) []string {
|
||||
func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle, symbolSet SymbolSet) []string {
|
||||
pipeSets := GetPipeSets(commits, getStyle)
|
||||
if len(pipeSets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := RenderAux(pipeSets, commits, selectedCommitHashPtr)
|
||||
lines := RenderAux(pipeSets, commits, selectedCommitHashPtr, symbolSet)
|
||||
|
||||
return lines
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func GetPipeSets(commits []*models.Commit, getStyle func(c *models.Commit) *styl
|
||||
})
|
||||
}
|
||||
|
||||
func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string) []string {
|
||||
func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string, symbolSet SymbolSet) []string {
|
||||
maxProcs := runtime.GOMAXPROCS(0)
|
||||
|
||||
// splitting up the rendering of the graph into multiple goroutines allows us to render the graph in parallel
|
||||
@@ -93,7 +93,7 @@ func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPt
|
||||
if k > 0 {
|
||||
prevCommit = commits[k-1]
|
||||
}
|
||||
line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit)
|
||||
line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit, symbolSet)
|
||||
innerLines = append(innerLines, line)
|
||||
}
|
||||
chunks[i] = innerLines
|
||||
@@ -276,6 +276,7 @@ func renderPipeSet(
|
||||
pipes []Pipe,
|
||||
selectedCommitHashPtr *string,
|
||||
prevCommit *models.Commit,
|
||||
symbolSet SymbolSet,
|
||||
) string {
|
||||
maxPos := int16(0)
|
||||
commitPos := int16(0)
|
||||
@@ -304,17 +305,17 @@ func renderPipeSet(
|
||||
|
||||
if left != right {
|
||||
for i := left + 1; i < right; i++ {
|
||||
cells[i].setLeft(style).setRight(style, overrideRightStyle)
|
||||
cells[i].setHorizontal(style, overrideRightStyle)
|
||||
}
|
||||
cells[left].setRight(style, overrideRightStyle)
|
||||
cells[right].setLeft(style)
|
||||
}
|
||||
|
||||
if pipe.kind == STARTS || pipe.kind == CONTINUES {
|
||||
cells[pipe.toPos].setDown(style)
|
||||
cells[pipe.toPos].setDown(style, lineTowards(pipe.toPos, pipe.fromPos))
|
||||
}
|
||||
if pipe.kind == TERMINATES || pipe.kind == CONTINUES {
|
||||
cells[pipe.fromPos].setUp(style)
|
||||
cells[pipe.fromPos].setUp(style, lineTowards(pipe.fromPos, pipe.toPos))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,14 +344,31 @@ func renderPipeSet(
|
||||
}
|
||||
|
||||
for _, pipe := range nonSelectedPipes {
|
||||
if pipe.kind != STARTS && !(pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos) {
|
||||
renderPipe(&pipe, pipe.style, false)
|
||||
if pipe.kind == STARTS {
|
||||
continue
|
||||
}
|
||||
if pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos {
|
||||
// Record this line without drawing it, so that the commit symbol
|
||||
// keeps the style of the commit itself. The branch drawing commit
|
||||
// symbols connect to it. The pipe from above the first commit
|
||||
// doesn't come from a commit, so it has no line.
|
||||
if !equalHashes(pipe.fromHash, &StartCommitHash) {
|
||||
cells[commitPos].up = straightLine
|
||||
}
|
||||
continue
|
||||
}
|
||||
renderPipe(&pipe, pipe.style, false)
|
||||
}
|
||||
|
||||
for _, pipe := range selectedPipes {
|
||||
for i := pipe.left(); i <= pipe.right(); i++ {
|
||||
cells[i].reset()
|
||||
// Box drawing characters can't show every combination of lines in a cell,
|
||||
// so the cells that the selected commit's lines run through show only
|
||||
// those lines. The branch drawing symbols can, so they keep the other
|
||||
// lines too.
|
||||
if symbolSet == BoxDrawingSymbols {
|
||||
for _, pipe := range selectedPipes {
|
||||
for i := pipe.left(); i <= pipe.right(); i++ {
|
||||
cells[i].reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pipe := range selectedPipes {
|
||||
@@ -360,6 +378,22 @@ func renderPipeSet(
|
||||
}
|
||||
}
|
||||
|
||||
// Where a line of the selected commit crosses the vertical line of another
|
||||
// commit, draw it over that line, so that it reads as one line. The
|
||||
// vertical line visibly continues above and below. A vertical line in a
|
||||
// cell that doesn't have the highlight style belongs to another commit.
|
||||
if symbolSet == BranchDrawingSymbols {
|
||||
for _, pipe := range selectedPipes {
|
||||
for i := pipe.left() + 1; i < pipe.right(); i++ {
|
||||
cell := cells[i]
|
||||
if cell.up == straightLine && cell.down == straightLine && cell.style != &highlightStyle {
|
||||
cell.horizontalOnTop = true
|
||||
cell.style = &highlightStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cType := COMMIT
|
||||
if isMerge {
|
||||
cType = MERGE
|
||||
@@ -371,11 +405,24 @@ func renderPipeSet(
|
||||
writer := &strings.Builder{}
|
||||
writer.Grow(len(cells) * 2)
|
||||
for _, cell := range cells {
|
||||
cell.render(writer)
|
||||
cell.render(writer, symbolSet)
|
||||
}
|
||||
return writer.String()
|
||||
}
|
||||
|
||||
// The course of a line through the top or bottom edge of the cell at pos,
|
||||
// given the column of the pipe's other end
|
||||
func lineTowards(pos, otherPos int16) verticalLine {
|
||||
switch {
|
||||
case otherPos < pos:
|
||||
return lineToLeft
|
||||
case otherPos > pos:
|
||||
return lineToRight
|
||||
default:
|
||||
return straightLine
|
||||
}
|
||||
}
|
||||
|
||||
func equalHashes(a, b *string) bool {
|
||||
// if our selectedCommitHashPtr is nil, there is no selected commit
|
||||
if a == nil || b == nil {
|
||||
|
||||
@@ -224,7 +224,7 @@ func TestRenderCommitGraph(t *testing.T) {
|
||||
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
|
||||
commits := lo.Map(test.commitOpts,
|
||||
func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) })
|
||||
lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle)
|
||||
lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle, BoxDrawingSymbols)
|
||||
|
||||
trimmedExpectedOutput := ""
|
||||
for line := range strings.SplitSeq(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") {
|
||||
@@ -247,6 +247,325 @@ func TestRenderCommitGraph(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Box drawing look-alikes for the branch drawing symbols, so that the expected
|
||||
// output in tests is readable. The box drawing characters of the graph never
|
||||
// use ┤ and ┼; here they stand for the symbols that have two bends. Commit
|
||||
// symbols are shown as ○ and ◎, whichever lines they connect to.
|
||||
var branchDrawingLookAlikes = func() map[rune]rune {
|
||||
lookAlikes := map[rune]rune{
|
||||
'\uf5d0': '─',
|
||||
'\uf5d1': '│',
|
||||
'\uf5d6': '╭',
|
||||
'\uf5d7': '╮',
|
||||
'\uf5d8': '╰',
|
||||
'\uf5d9': '╯',
|
||||
'\uf5e0': '┬', // ╮ on ─
|
||||
'\uf5e3': '┴', // ╯ on ─
|
||||
'\uf5df': '┤', // ╯ and ╮
|
||||
'\uf5e8': '┼', // ╯ and ╮ on ─
|
||||
}
|
||||
for _, symbols := range branchDrawingCommitSymbols {
|
||||
lookAlikes[[]rune(symbols.commit)[0]] = CommitSymbol
|
||||
lookAlikes[[]rune(symbols.merge)[0]] = MergeSymbol
|
||||
}
|
||||
return lookAlikes
|
||||
}()
|
||||
|
||||
func TestRenderCommitGraphWithBranchDrawingSymbols(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commitOpts []models.NewCommitOpts
|
||||
selectedHash string
|
||||
expectedOutput string
|
||||
}{
|
||||
{
|
||||
name: "branch forked off a merge commit",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "3"}},
|
||||
{Hash: "3", Parents: []string{"5"}},
|
||||
{Hash: "2", Parents: []string{"5", "4"}},
|
||||
{Hash: "4", Parents: []string{"5"}},
|
||||
{Hash: "5", Parents: []string{"6", "7"}},
|
||||
{Hash: "7", Parents: []string{"6"}},
|
||||
{Hash: "6", Parents: []string{"8"}},
|
||||
},
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
3 │ ○
|
||||
2 ◎─│─╮
|
||||
4 │ │ ○
|
||||
5 ◎─┼─╯
|
||||
7 │ ○
|
||||
6 ○─╯`,
|
||||
},
|
||||
{
|
||||
name: "branch forked off a merge commit, with the merge commit selected",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "3"}},
|
||||
{Hash: "3", Parents: []string{"5"}},
|
||||
{Hash: "2", Parents: []string{"5", "4"}},
|
||||
{Hash: "4", Parents: []string{"5"}},
|
||||
{Hash: "5", Parents: []string{"6", "7"}},
|
||||
{Hash: "7", Parents: []string{"6"}},
|
||||
{Hash: "6", Parents: []string{"8"}},
|
||||
},
|
||||
selectedHash: "5",
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
3 │ ○
|
||||
2 ◎─│─╮
|
||||
4 │ │ ○
|
||||
5 ◎─┼─╯
|
||||
7 │ ○
|
||||
6 ○─╯`,
|
||||
},
|
||||
{
|
||||
name: "branch forked off a merge commit, with the merge commit above it selected",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "3"}},
|
||||
{Hash: "3", Parents: []string{"5"}},
|
||||
{Hash: "2", Parents: []string{"5", "4"}},
|
||||
{Hash: "4", Parents: []string{"5"}},
|
||||
{Hash: "5", Parents: []string{"6", "7"}},
|
||||
{Hash: "7", Parents: []string{"6"}},
|
||||
{Hash: "6", Parents: []string{"8"}},
|
||||
},
|
||||
selectedHash: "2",
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
3 │ ○
|
||||
2 ◎───╮
|
||||
4 │ │ ○
|
||||
5 ◎─┼─╯
|
||||
7 │ ○
|
||||
6 ○─╯`,
|
||||
},
|
||||
{
|
||||
name: "branch forked off a merge commit, with no line passing through",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "3"}},
|
||||
{Hash: "3", Parents: []string{"2"}},
|
||||
{Hash: "2", Parents: []string{"4", "5"}},
|
||||
{Hash: "4", Parents: []string{"6", "7"}},
|
||||
{Hash: "6", Parents: []string{"8"}},
|
||||
},
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
3 │ ○
|
||||
2 ◎─┤
|
||||
4 ◎─│─╮
|
||||
6 ○ │ │`,
|
||||
},
|
||||
{
|
||||
name: "several lines ending in a commit",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2"}},
|
||||
{Hash: "2", Parents: []string{"3", "4"}},
|
||||
{Hash: "3", Parents: []string{"5", "4"}},
|
||||
{Hash: "5", Parents: []string{"7", "8"}},
|
||||
{Hash: "7", Parents: []string{"4", "A"}},
|
||||
{Hash: "4", Parents: []string{"B"}},
|
||||
{Hash: "B", Parents: []string{"C"}},
|
||||
},
|
||||
expectedOutput: `
|
||||
1 ○
|
||||
2 ◎─╮
|
||||
3 ◎─│─╮
|
||||
5 ◎─│─│─╮
|
||||
7 ◎─│─│─│─╮
|
||||
4 ○─┴─╯ │ │
|
||||
B ○ ╭───╯ │`,
|
||||
},
|
||||
}
|
||||
|
||||
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
|
||||
defer color.ForceSetColorLevel(oldColorLevel)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
|
||||
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
|
||||
commits := lo.Map(test.commitOpts,
|
||||
func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) })
|
||||
lines := RenderCommitGraph(commits, hashPool.Add(test.selectedHash), getStyle, BranchDrawingSymbols)
|
||||
|
||||
trimmedExpectedOutput := ""
|
||||
for line := range strings.SplitSeq(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") {
|
||||
trimmedExpectedOutput += strings.TrimSpace(line) + "\n"
|
||||
}
|
||||
|
||||
output := ""
|
||||
for i, line := range lines {
|
||||
lookAlikes := strings.Map(func(r rune) rune {
|
||||
return lo.ValueOr(branchDrawingLookAlikes, r, r)
|
||||
}, utils.Decolorise(line))
|
||||
output += strings.TrimSpace(test.commitOpts[i].Hash+" "+lookAlikes) + "\n"
|
||||
}
|
||||
|
||||
assert.Equal(t, trimmedExpectedOutput, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPipeSetWithBranchDrawingSymbols(t *testing.T) {
|
||||
cyan := style.FgCyan
|
||||
red := style.FgRed
|
||||
green := style.FgGreen
|
||||
yellow := style.FgYellow
|
||||
magenta := style.FgMagenta
|
||||
nothing := style.Nothing
|
||||
|
||||
hashPool := &utils.StringPool{}
|
||||
pool := func(s string) *string { return hashPool.Add(s) }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pipes []Pipe
|
||||
prevCommit *models.Commit
|
||||
expectedStr string
|
||||
expectedStyles []style.TextStyle
|
||||
}{
|
||||
{
|
||||
name: "commit with lines above and below",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}),
|
||||
expectedStr: "\uf5fb", // ○ with lines up and down
|
||||
expectedStyles: []style.TextStyle{green},
|
||||
},
|
||||
{
|
||||
name: "first commit",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: &StartCommitHash, toHash: pool("b"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green},
|
||||
},
|
||||
expectedStr: "\uf5f7", // ○ with a line down
|
||||
expectedStyles: []style.TextStyle{green},
|
||||
},
|
||||
{
|
||||
name: "selected commit",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("selected"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("c"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}),
|
||||
expectedStr: "\uf5fb", // ○ with lines up and down
|
||||
expectedStyles: []style.TextStyle{highlightStyle},
|
||||
},
|
||||
{
|
||||
name: "commit whose previous commit is selected and is a merge commit",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
|
||||
{fromPos: 1, toPos: 1, fromHash: pool("selected"), toHash: pool("b3"), kind: CONTINUES, style: &red},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "selected"}),
|
||||
expectedStr: "\uf5fb \uf5d1", // ○ with lines up and down, │
|
||||
expectedStyles: []style.TextStyle{
|
||||
highlightStyle, nothing, highlightStyle,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merge commit that a branch forks off",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
|
||||
{fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
|
||||
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
|
||||
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
|
||||
expectedStyles: []style.TextStyle{
|
||||
green, green, magenta, cyan, cyan,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merge commit that a branch forks off, with the branch's first commit selected",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 1, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
|
||||
{fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
|
||||
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
|
||||
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
|
||||
expectedStyles: []style.TextStyle{
|
||||
highlightStyle, highlightStyle, highlightStyle, cyan, cyan,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "line of the selected commit crossing another line",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("selected"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a3"), kind: STARTS, style: &yellow},
|
||||
{fromPos: 1, toPos: 1, fromHash: pool("b1"), toHash: pool("b2"), kind: CONTINUES, style: &magenta},
|
||||
{fromPos: 3, toPos: 0, fromHash: pool("e1"), toHash: pool("selected"), kind: TERMINATES, style: &green},
|
||||
{fromPos: 0, toPos: 2, fromHash: pool("selected"), toHash: pool("c3"), kind: STARTS, style: &yellow},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
|
||||
expectedStr: "\uf604\uf5d0\uf5d0\uf5d0\uf5e0\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ─, ─, ╮ on ─, ─, ╯
|
||||
expectedStyles: []style.TextStyle{
|
||||
highlightStyle, highlightStyle, highlightStyle, highlightStyle, highlightStyle, green, green,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "line of the selected commit passing through a cell in which other lines bend",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
|
||||
{fromPos: 2, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
|
||||
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
|
||||
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
|
||||
expectedStyles: []style.TextStyle{
|
||||
highlightStyle, highlightStyle, magenta, highlightStyle, highlightStyle,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
|
||||
defer color.ForceSetColorLevel(oldColorLevel)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit, BranchDrawingSymbols)
|
||||
if len([]rune(test.expectedStr)) != len(test.expectedStyles) {
|
||||
t.Fatalf("Error in test setup: you have %d characters in the expected output but have specified %d styles", len([]rune(test.expectedStr)), len(test.expectedStyles))
|
||||
}
|
||||
expectedStr := ""
|
||||
for i, char := range []rune(test.expectedStr) {
|
||||
expectedStr += test.expectedStyles[i].Sprint(string(char))
|
||||
}
|
||||
expectedStr += " "
|
||||
|
||||
assert.Equal(t, expectedStr, actualStr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchDrawingSymbolsCoverAllCells(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
commits := generateCommits(hashPool, 1000)
|
||||
getStyle := func(commit *models.Commit) *style.TextStyle { return &style.FgDefault }
|
||||
isBoxDrawingChar := func(r rune) bool { return r >= '\u2500' && r <= '\u257f' }
|
||||
|
||||
for _, selectedHash := range []string{"none", commits[10].Hash(), commits[500].Hash()} {
|
||||
lines := RenderCommitGraph(commits, hashPool.Add(selectedHash), getStyle, BranchDrawingSymbols)
|
||||
for i, line := range lines {
|
||||
line = utils.Decolorise(line)
|
||||
assert.False(t, strings.ContainsFunc(line, isBoxDrawingChar),
|
||||
"row %d falls back to box drawing characters: %q", i, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPipeSet(t *testing.T) {
|
||||
cyan := style.FgCyan
|
||||
red := style.FgRed
|
||||
@@ -462,7 +781,7 @@ func TestRenderPipeSet(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit)
|
||||
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit, BoxDrawingSymbols)
|
||||
t.Log("actual cells:")
|
||||
t.Log(actualStr)
|
||||
expectedStr := ""
|
||||
@@ -540,8 +859,8 @@ func TestGetNextPipes(t *testing.T) {
|
||||
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
|
||||
pipes := getNextPipes(test.prevPipes, test.commit, getStyle)
|
||||
// rendering cells so that it's easier to see what went wrong
|
||||
actualStr := renderPipeSet(pipes, pool("selected"), nil)
|
||||
expectedStr := renderPipeSet(test.expected, pool("selected"), nil)
|
||||
actualStr := renderPipeSet(pipes, pool("selected"), nil, BoxDrawingSymbols)
|
||||
expectedStr := renderPipeSet(test.expected, pool("selected"), nil, BoxDrawingSymbols)
|
||||
t.Log("expected cells:")
|
||||
t.Log(expectedStr)
|
||||
t.Log("actual cells:")
|
||||
@@ -562,7 +881,7 @@ func BenchmarkRenderCommitGraph(b *testing.B) {
|
||||
}
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
RenderCommitGraph(commits, hashPool.Add("selected"), getStyle)
|
||||
RenderCommitGraph(commits, hashPool.Add("selected"), getStyle, BoxDrawingSymbols)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/tasks"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -156,12 +157,34 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentWriter returns what a render of the given view writes its content to: the
|
||||
// view itself, or, for a pane of the main section, the writer that links the files
|
||||
// named in the diffstat on its way there (see DiffStatLinkWriter).
|
||||
func (gui *Gui) contentWriter(view *gocui.View) io.Writer {
|
||||
if gui.mainContextForView(view) == nil {
|
||||
return view
|
||||
}
|
||||
return gui.diffStatLinkWriter(view)
|
||||
}
|
||||
|
||||
// diffStatLinkWriter returns the writer that links the diffstat of the given pane,
|
||||
// making it if the pane hasn't rendered yet. It lasts as long as the view does, and
|
||||
// each render tells it what to make of that render (see DiffStatLinkWriter.BeginRender).
|
||||
func (gui *Gui) diffStatLinkWriter(view *gocui.View) *helpers.DiffStatLinkWriter {
|
||||
writer, ok := gui.diffStatLinkWriterMap[view.Name()]
|
||||
if !ok {
|
||||
writer = helpers.NewDiffStatLinkWriter(view)
|
||||
gui.diffStatLinkWriterMap[view.Name()] = writer
|
||||
}
|
||||
return writer
|
||||
}
|
||||
|
||||
func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
manager, ok := gui.viewBufferManagerMap[view.Name()]
|
||||
if !ok {
|
||||
manager = tasks.NewViewBufferManager(
|
||||
gui.Log,
|
||||
view,
|
||||
gui.contentWriter(view),
|
||||
func() {
|
||||
// Called before showing the "loading..." indicator: clear the
|
||||
// displayed buffer so only "loading..." is shown. The actual content
|
||||
|
||||
@@ -420,6 +420,7 @@ type TranslationSet struct {
|
||||
JumpToFileInDiff string
|
||||
JumpToFileInDiffTooltip string
|
||||
OnlyOneFileInDiff string
|
||||
NoFileInDiffNamed string
|
||||
PrevConflict string
|
||||
NextConflict string
|
||||
SelectPrevHunk string
|
||||
@@ -1610,6 +1611,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
JumpToFileInDiff: "Jump to file in diff",
|
||||
JumpToFileInDiffTooltip: "Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel.",
|
||||
OnlyOneFileInDiff: "There is only one file in this diff",
|
||||
NoFileInDiffNamed: "This diff has no file named '{{.path}}'",
|
||||
PrevConflict: "Previous conflict",
|
||||
NextConflict: "Next conflict",
|
||||
SelectPrevHunk: "Previous hunk",
|
||||
|
||||
@@ -23,6 +23,14 @@ const (
|
||||
defaultHeight = 100
|
||||
)
|
||||
|
||||
// The terminal size that demo/settings.tape gives a recording. How a demo
|
||||
// behaves depends on how much of a list fits on the screen, so run demos at
|
||||
// the size they will be recorded at. Keep these in step with that file.
|
||||
const (
|
||||
demoWidth = 120
|
||||
demoHeight = 35
|
||||
)
|
||||
|
||||
type IntegrationTest struct {
|
||||
name string
|
||||
description string
|
||||
@@ -213,6 +221,10 @@ func (self *IntegrationTest) Run(gui integrationTypes.GuiDriver) {
|
||||
|
||||
func (self *IntegrationTest) HeadlessDimensions() (int, int) {
|
||||
if self.width == 0 && self.height == 0 {
|
||||
if self.isDemo {
|
||||
return demoWidth, demoHeight
|
||||
}
|
||||
|
||||
return defaultWidth, defaultHeight
|
||||
}
|
||||
|
||||
|
||||
@@ -905,6 +905,14 @@ func (self *ViewDriver) NavigateToLine(matcher *TextMatcher) *ViewDriver {
|
||||
keyPress = func() { self.SelectPreviousItem() }
|
||||
}
|
||||
|
||||
// The loop below only looks at the lines it moves onto, so the line we are
|
||||
// starting from has to be checked here. After jumping to the top that line
|
||||
// is the first item of the list, and for a list we had to scroll through
|
||||
// it is a plausible target.
|
||||
if ok, _ := matcher.test(view.BufferLines()[selectedLineIdx]); ok {
|
||||
return self
|
||||
}
|
||||
|
||||
for range maxNumKeyPresses {
|
||||
keyPress()
|
||||
idx := self.getSelectedLineIdx()
|
||||
|
||||
@@ -10,66 +10,99 @@ var usersFileContent = `package main
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
// TODO: verify that this actually works
|
||||
fmt.Println(shims.Greeting())
|
||||
serve()
|
||||
}
|
||||
|
||||
func serve() {
|
||||
fmt.Println("listening on :8080")
|
||||
}
|
||||
`
|
||||
|
||||
var usersFileContentWithLogging = `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("hello world")
|
||||
serve()
|
||||
}
|
||||
|
||||
func logRequest(path string) {
|
||||
fmt.Println("request:", path)
|
||||
}
|
||||
|
||||
func serve() {
|
||||
fmt.Println("listening on :8080")
|
||||
}
|
||||
`
|
||||
|
||||
var CustomPatch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Remove a line from an old commit",
|
||||
Description: "Split a change out of an old commit into a new one",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
IsDemo: true,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
setDefaultDemoConfig(cfg)
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateNCommitsWithRandomMessages(30)
|
||||
shell.NewBranch("feature/user-authentication")
|
||||
shell.EmptyCommit("Add user authentication feature")
|
||||
shell.CreateFileAndAdd("src/users.go", "package main\n")
|
||||
shell.CreateFileAndAdd("src/users.go", usersFileContent)
|
||||
shell.Commit("Fix local session storage")
|
||||
shell.CreateFile("src/authentication.go", "package main")
|
||||
shell.CreateFile("src/session.go", "package main")
|
||||
shell.UpdateFileAndAdd("src/users.go", usersFileContent)
|
||||
shell.EmptyCommit("Stop using shims")
|
||||
shell.UpdateFileAndAdd("src/users.go", usersFileContentWithLogging)
|
||||
shell.Commit("Stop using shims")
|
||||
shell.UpdateFileAndAdd("src/authentication.go", "package authentication")
|
||||
shell.UpdateFileAndAdd("src/session.go", "package session")
|
||||
shell.Commit("Enhance user authentication feature")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.SetCaptionPrefix("Remove a line from an old commit")
|
||||
t.SetCaptionPrefix("Split a change out of an old commit")
|
||||
t.Wait(1000)
|
||||
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
NavigateToLine(Contains("Stop using shims")).
|
||||
Wait(1000).
|
||||
PressEnter().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Wait(1000).
|
||||
Press(keys.Universal.NextItem).
|
||||
SelectedLines(
|
||||
Contains("+func logRequest(path string) {"),
|
||||
Contains(`fmt.Println("request:", path)`),
|
||||
Contains("+}"),
|
||||
Equals("+"),
|
||||
).
|
||||
Wait(500).
|
||||
SetCaptionPrefix("Add the hunk to a custom patch").
|
||||
PressPrimaryAction().
|
||||
Wait(1000).
|
||||
SetCaptionPrefix("Move the patch into a new commit").
|
||||
Press(keys.Universal.CreatePatchOptionsMenu).
|
||||
Tap(func() {
|
||||
t.Views().CommitFiles().
|
||||
IsFocused().
|
||||
NavigateToLine(Contains("users.go")).
|
||||
Wait(1000).
|
||||
PressEnter().
|
||||
Tap(func() {
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
NavigateToLine(Contains("TODO")).
|
||||
Wait(500).
|
||||
PressPrimaryAction().
|
||||
PressEscape()
|
||||
}).
|
||||
Press(keys.Universal.CreatePatchOptionsMenu).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Patch options")).
|
||||
Select(Contains("Remove patch from original commit")).
|
||||
Wait(500).
|
||||
Confirm()
|
||||
}).
|
||||
PressEscape()
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Patch options")).
|
||||
Select(Contains("Move patch into new commit after the original commit")).
|
||||
Wait(500).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().CommitMessagePanel().
|
||||
Type("Add request logging").
|
||||
Confirm()
|
||||
})
|
||||
|
||||
t.Views().Commits().
|
||||
IsFocused().
|
||||
TopLines(
|
||||
Contains("Enhance user authentication feature"),
|
||||
Contains("Add request logging"),
|
||||
Contains("Stop using shims"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ var DiffCommits = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
|
||||
config.GetUserConfig().Gui.ShowFileTree = false
|
||||
config.GetUserConfig().Gui.ShowCommandLog = false
|
||||
config.GetUserConfig().Gui.ExpandFocusedSidePanel = true
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateNCommitsWithRandomMessages(50)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package demo
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var greetingFileContent = `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func greet(name string) {
|
||||
fmt.Println("hello", name)
|
||||
}
|
||||
|
||||
func farewell(name string) {
|
||||
fmt.Println("bye", name)
|
||||
}
|
||||
`
|
||||
|
||||
var greetingFileContentWithDebugLine = `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func greet(name string) {
|
||||
fmt.Println("hello there", name)
|
||||
}
|
||||
|
||||
func farewell(name string) {
|
||||
fmt.Println("DEBUG: saying bye to", name)
|
||||
fmt.Println("bye", name)
|
||||
}
|
||||
`
|
||||
|
||||
var RemoveHunkFromCommit = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Remove a hunk from an old commit",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
IsDemo: true,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
setDefaultDemoConfig(cfg)
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateNCommitsWithRandomMessages(30)
|
||||
shell.NewBranch("feature/user-authentication")
|
||||
shell.CreateFileAndAdd("src/greeting.go", greetingFileContent)
|
||||
shell.Commit("Greet users when they sign in")
|
||||
shell.UpdateFileAndAdd("src/greeting.go", greetingFileContentWithDebugLine)
|
||||
shell.Commit("Make the greeting friendlier")
|
||||
shell.EmptyCommit("Expire sessions after a day")
|
||||
shell.EmptyCommit("Enhance user authentication feature")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.SetCaptionPrefix("Remove a hunk from an old commit")
|
||||
t.Wait(1000)
|
||||
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
NavigateToLine(Contains("Make the greeting friendlier")).
|
||||
Wait(1000).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Wait(1000).
|
||||
Press(keys.Universal.NextItem).
|
||||
SelectedLines(
|
||||
Contains(`fmt.Println("DEBUG: saying bye to", name)`),
|
||||
).
|
||||
Wait(1000).
|
||||
Press(keys.Universal.Remove).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Confirmation().
|
||||
Title(Equals("Discard lines from commit")).
|
||||
Content(AnyString()).
|
||||
Wait(1000).
|
||||
Confirm()
|
||||
}).
|
||||
Wait(1000).
|
||||
Content(Contains(`fmt.Println("hello there", name)`).DoesNotContain("DEBUG"))
|
||||
},
|
||||
})
|
||||
@@ -16,4 +16,7 @@ func setGeneratedAuthorColours(config *config.AppConfig) {
|
||||
func setDefaultDemoConfig(config *config.AppConfig) {
|
||||
// demos look much nicer with icons shown
|
||||
config.GetUserConfig().Gui.NerdFontsVersion = "3"
|
||||
// The terminal that we record demos in doesn't tell us that it can draw the
|
||||
// detailed graph; demo/settings.tape gives it a font for it
|
||||
config.GetUserConfig().Gui.CommitGraphStyle = "detailed"
|
||||
}
|
||||
|
||||
+26
-12
@@ -30,11 +30,11 @@ Simple terminal UI for git
|
||||
|
||||
Just do brew install lazygit and bada bing bada
|
||||
boom you have begun on the path of laziness.
|
||||
|
||||
TODO: mention the other install methods
|
||||
`
|
||||
|
||||
var StageLines = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Stage individual lines",
|
||||
var StageHunksOrLines = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Stage hunks or individual lines",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
IsDemo: true,
|
||||
@@ -51,29 +51,43 @@ var StageLines = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
shell.UpdateFile("docs/README.md", updatedFile)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.SetCaptionPrefix("Stage individual lines")
|
||||
t.SetCaptionPrefix("Stage a hunk")
|
||||
t.Wait(1000)
|
||||
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
PressEnter()
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-Simple terminal UI for git commands"),
|
||||
Contains("+Simple terminal UI for git"),
|
||||
Contains("+(Not too simple though)"),
|
||||
).
|
||||
Wait(1000).
|
||||
PressPrimaryAction().
|
||||
Wait(1000).
|
||||
SetCaptionPrefix("Stage individual lines").
|
||||
Press(keys.Main.ToggleSelectHunk).
|
||||
Wait(500).
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
PressFast(keys.Universal.NextItem).
|
||||
PressFast(keys.Universal.NextItem).
|
||||
SelectedLines(
|
||||
Contains("+Just do brew install lazygit"),
|
||||
Contains("+boom you have begun"),
|
||||
).
|
||||
Wait(500).
|
||||
PressPrimaryAction().
|
||||
Wait(500).
|
||||
PressEscape()
|
||||
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Wait(1000).
|
||||
SelectedLines(
|
||||
Contains("+TODO: mention the other install methods"),
|
||||
).
|
||||
SetCaptionPrefix("Commit our changes").
|
||||
Press(keys.Files.CommitChanges).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().CommitMessagePanel().
|
||||
Type("Update tagline").
|
||||
Type("Update tagline and install instructions").
|
||||
Confirm()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ClickAFileInADiffStatThatComesFirst = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Jump to a file by clicking its name in a diffstat the renderer's handshake runs into",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
Width: 100,
|
||||
Height: 30,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
// A renderer that announces the protocol and then passes the diff on as it came.
|
||||
// The handshake has no newline after it, so it runs into the first line the
|
||||
// renderer is given — which for the diff of a range of commits is the first
|
||||
// entry of the diffstat, there being no commit above it.
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{Command: `printf '\033]1717;1\007'; cat`},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("aaa.txt", "one\n")
|
||||
shell.Commit("one")
|
||||
shell.CreateFileAndAdd("zzz.txt", "one\n")
|
||||
shell.Commit("two")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
SelectedLine(Contains("two")).
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
SelectNextItem().
|
||||
SelectedLines(
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
|
||||
// Below the line lazygit writes to say what the diff is of, the diff opens with
|
||||
// the diffstat, and the handshake runs into its first entry. That is the one
|
||||
// clicked here.
|
||||
t.Views().Main().
|
||||
TopLines(
|
||||
Contains("Showing diff for range"),
|
||||
Equals(""),
|
||||
Contains("aaa.txt"),
|
||||
Contains("zzz.txt"),
|
||||
Contains("2 files changed"),
|
||||
).
|
||||
Click(2, 2)
|
||||
|
||||
t.Views().Commits().IsFocused()
|
||||
|
||||
t.Views().Main().
|
||||
TopVisibleLine(Contains("diff --git a/aaa.txt b/aaa.txt"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ClickAFileInTheDiffStat = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Jump to a file of a commit's diff by clicking the line that names it in the diffstat",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
Width: 100,
|
||||
Height: 30,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
lines := make([]string, 600)
|
||||
for i := range lines {
|
||||
lines[i] = fmt.Sprintf("line%03d", i+1)
|
||||
}
|
||||
// A long file at either end, so that the file jumped to is far below the
|
||||
// diffstat and has a diff under it to scroll past.
|
||||
shell.CreateFileAndAdd("aaa.txt", strings.Join(lines, "\n")+"\n")
|
||||
shell.CreateFileAndAdd("dir/bbb.txt", "one\n")
|
||||
shell.CreateFileAndAdd("zzz.txt", strings.Join(lines, "\n")+"\n")
|
||||
shell.Commit("one")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
SelectedLine(Contains("one"))
|
||||
|
||||
// The click below is at a line of the diffstat, so the diff has to open with
|
||||
// the lines this expects.
|
||||
t.Views().Main().
|
||||
TopLines(
|
||||
Contains("commit"),
|
||||
Contains("Author:"),
|
||||
Contains("Date:"),
|
||||
Equals(""),
|
||||
Contains("one"),
|
||||
Equals("---"),
|
||||
Contains("aaa.txt"),
|
||||
Contains("dir/bbb.txt"),
|
||||
Contains("zzz.txt"),
|
||||
Contains("3 files changed"),
|
||||
).
|
||||
Click(2, 8)
|
||||
|
||||
// The panel keeps the focus, and the diff goes to the file clicked.
|
||||
t.Views().Commits().
|
||||
IsFocused().
|
||||
SelectedLine(Contains("one"))
|
||||
|
||||
t.Views().Main().
|
||||
TopVisibleLine(Contains("diff --git a/zzz.txt b/zzz.txt"))
|
||||
|
||||
t.Views().Commits().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// With the pane focused there is a selection to move, and the click moves it to
|
||||
// the file, exactly as picking the file from the menu would.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectionIsActive().
|
||||
Press(keys.Universal.GotoTop)
|
||||
|
||||
// The first file's diff begins on screen already, so the view stays where it
|
||||
// is; a jump only scrolls as far as it must once there is a selection to point
|
||||
// at the file with.
|
||||
t.Views().Main().
|
||||
TopVisibleLine(Contains("commit")).
|
||||
Click(2, 6).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/aaa.txt b/aaa.txt"),
|
||||
).
|
||||
TopVisibleLine(Contains("commit")).
|
||||
// The last file's is far below, so that one is scrolled to.
|
||||
Click(2, 8).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/zzz.txt b/zzz.txt"),
|
||||
).
|
||||
TopVisibleLine(Contains("diff --git a/zzz.txt b/zzz.txt"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var NoDiffStatLinksUnderAnExternalDiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The files named in the diffstat are not linked under a diff renderer that says nothing about its rows",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
Width: 100,
|
||||
Height: 30,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
// An external diff whose output has nothing to say about which line of which
|
||||
// file each row shows. git writes the diffstat itself, so the names are there
|
||||
// to be clicked, but nothing could find the file they name.
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{Name: "opaque", Type: "extDiff", Command: `sh -c 'echo EXT'`},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("aaa.txt", "one\n")
|
||||
shell.CreateFileAndAdd("zzz.txt", "one\n")
|
||||
shell.Commit("one")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
SelectedLine(Contains("one"))
|
||||
|
||||
// The click below is at a line of the diffstat, so the diff has to open with
|
||||
// the lines this expects.
|
||||
t.Views().Main().
|
||||
TopLines(
|
||||
Contains("commit"),
|
||||
Contains("Author:"),
|
||||
Contains("Date:"),
|
||||
Equals(""),
|
||||
Contains("one"),
|
||||
Equals("---"),
|
||||
Contains("aaa.txt"),
|
||||
Contains("zzz.txt"),
|
||||
Contains("2 files changed"),
|
||||
).
|
||||
Click(2, 7)
|
||||
|
||||
// The name is no link, so the click is an ordinary one, which focuses the pane
|
||||
// it lands in. Were it a link, it would have been followed instead, and would
|
||||
// have had to report that it found no such file — the test fails on the toast
|
||||
// that leaves unacknowledged.
|
||||
t.Views().Main().IsFocused()
|
||||
},
|
||||
})
|
||||
@@ -219,7 +219,8 @@ var tests = []*components.IntegrationTest{
|
||||
demo.InteractiveRebase,
|
||||
demo.NukeWorkingTree,
|
||||
demo.RebaseOnto,
|
||||
demo.StageLines,
|
||||
demo.RemoveHunkFromCommit,
|
||||
demo.StageHunksOrLines,
|
||||
demo.Undo,
|
||||
demo.WorktreeCreateFromBranches,
|
||||
diff.CopyToClipboard,
|
||||
@@ -378,6 +379,8 @@ var tests = []*components.IntegrationTest{
|
||||
main_view.BuildPatchWithMixedSelections,
|
||||
main_view.ChangeContextSizeWhileBuildingPatch,
|
||||
main_view.ChangeScreenModeInFocusedDiff,
|
||||
main_view.ClickAFileInADiffStatThatComesFirst,
|
||||
main_view.ClickAFileInTheDiffStat,
|
||||
main_view.ClickSelectsDiffLine,
|
||||
main_view.CommitFromMainView,
|
||||
main_view.CopyRowsThatAreNoDiffLine,
|
||||
@@ -442,6 +445,7 @@ var tests = []*components.IntegrationTest{
|
||||
main_view.MovePatchToNewCommitBefore,
|
||||
main_view.MovePatchToNewCommitInStackedBranch,
|
||||
main_view.NavigateByHunkAndFile,
|
||||
main_view.NoDiffStatLinksUnderAnExternalDiff,
|
||||
main_view.NoSelectionOverABinaryDiff,
|
||||
main_view.NoSelectionOverACommitLog,
|
||||
main_view.NoSelectionOverAConflictHint,
|
||||
|
||||
@@ -1221,6 +1221,16 @@
|
||||
"description": "If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.",
|
||||
"default": true
|
||||
},
|
||||
"commitGraphStyle": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"classic",
|
||||
"detailed"
|
||||
],
|
||||
"description": "How the commit graph is drawn.\nOne of: 'auto' (default) | 'classic' | 'detailed'\n'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.\n'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise.",
|
||||
"default": "auto"
|
||||
},
|
||||
"commitAuthorShortLength": {
|
||||
"type": "integer",
|
||||
"description": "Length of author name in (non-expanded) commits view. 2 means show initials only.",
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
demo/rerecord_demos.sh "$@"
|
||||
@@ -8,10 +8,16 @@ gui:
|
||||
activeBorderColor:
|
||||
- green
|
||||
- bold
|
||||
# Not the "black" of the terminal palette: that is a real black in an
|
||||
# ordinary terminal, which makes the frames all but invisible when you run
|
||||
# a test with `just e2e-cli`.
|
||||
inactiveBorderColor:
|
||||
- black
|
||||
- '#999999'
|
||||
# Not important in tests but it creates clutter in demos
|
||||
showRandomTip: false
|
||||
# The expected output of the tests draws the commit graph with box drawing
|
||||
# characters, whichever terminal a test runs in
|
||||
commitGraphStyle: classic
|
||||
animateExplosion: false # takes too long
|
||||
git:
|
||||
# We don't want to run any periodic background git commands because it'll introduce race conditions and flakiness.
|
||||
|
||||
Reference in New Issue
Block a user