Compare commits

...
Author SHA1 Message Date
Stefan HallerandClaude Opus 5.5 3aee979006 Draw the commit graph in the demos with the detailed style
The 'auto' style doesn't recognize the terminal that vhs records, so it
would draw the classic graph. Now that the recordings have a font for
the branch drawing symbols, use the detailed graph in all demos.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:28:31 +02:00
Stefan HallerandClaude Opus 5.5 83b144ccc1 Add a font with the branch drawing symbols for the demo recordings
The terminal that vhs records is xterm.js running in a browser, and the
xterm.js that ttyd bundles doesn't draw the branch drawing symbols
itself. So they have to come from a font, and SauceCodePro NF doesn't
have them, nor does any font that macOS comes with. The detailed commit
graph shows up as garbage.

Newer versions of xterm.js do draw the symbols, but only in the WebGL
renderer. vhs forces the canvas renderer, and xterm.js has removed that
since, so moving to a newer xterm.js would mean patching both ttyd and
vhs.

The Flog Symbols font has the symbols, but it draws its lines for a
smaller cell than the one xterm.js uses at our font settings. As a
fallback font, it leaves a gap at the edges of every cell, and each line
of the graph comes out dashed.

Add a copy of the font that fits the cells of the recordings. xterm.js
clips each character to its row, so the vertical strokes reach well
past the top and bottom of the cell and end exactly at the row's edges.
It doesn't clip a character to its cell horizontally, so the horizontal
strokes reach only a little way into the neighbouring cells. Any
further, and they would show past the start of a bend in the next cell.

Give the font a bold face with the same outlines. lazygit draws the
lines of the selected commit in bold, and without a bold face the
browser makes the symbols bold itself by thickening them. That leaves
gaps where they meet.

Add the script that made the font, and list the font after
SauceCodePro NF in demo/settings.tape so that the browser takes the
symbols from it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:28:31 +02:00
Stefan Haller db33521c1b [SQUASHED] better graph drawing 2026-10-01 17:28:31 +02:00
Stefan HallerandClaude Opus 5 383d3d7df8 Add a script for re-recording every demo a page embeds
Changing demo/settings.tape, or anything about how lazygit looks, dates
every recording at once, and re-recording them one at a time means
running the recorder fifteen times and pasting fifteen new URLs.
Attachment URLs say nothing about where they came from, so there is also
nothing to tell you which demo a video in the README is of.

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

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

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

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

Two things about the frame. Pad the bottom, because the browser draws
its playback controls over the video and they are tall enough to cover
the line where the demos put their captions. And cut the end: vhs
records until the marker reaches the screen, by which time lazygit has
exited and the shell has painted its prompt back over the demo. A
browser holds the last frame of a video once it has played to the end,
so leaving those frames in would end every demo on a terminal prompt
and leave it there.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:28:31 +02:00
Stefan Haller 647dba2e90 Update demos and readme for new staging-in-main-view functionality
We don't bother rendering the updated or new demos yet, because we are
about to change them to a different format.
2026-10-01 17:28:31 +02:00
Stefan HallerandClaude Opus 5 31aa870a3b Run demos at the size they are recorded at
How a demo behaves depends on how much of a list fits on the screen, and
`just e2e` ran demos on a 150x100 screen while we record them at 120x35.
So a demo could pass the test suite and still fail partway through a
recording, and there was no way to find out short of recording it.

Give a demo the recording size when it doesn't ask for a size of its
own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:28:31 +02:00
Stefan HallerandClaude Opus 5 84627fabac Check the line we start on when navigating to a list item
NavigateToLine looks for its target among the lines the view has
rendered. When a list is scrolled, the view holds only the part of it
that is on screen, so the target may not be among them. For that case
the helper jumps to the top of the list and walks down instead.

That walk presses a key before it looks, so it never sees the item it
starts on, which after jumping is the first item of the list. A target
sitting there is reported as missing. The shorter the terminal, the more
of a list is scrolled out of view and the more often that walk is
needed, so this surfaced once the demos began running in a 35 line
terminal.

Check the line the walk starts on before moving off it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:28:31 +02:00
Stefan Haller 14f5b86b3b Use accordion mode in diff_commits demo
When running the demos at a hight of 35, as the new recording mechanism
will, this demo failed because the commits list was too small to show
both commits at the same time, and NavigateToLine has a bug that
prevents it from finding it by going to the top and pressing down until
it matches. We are going to fix that bug in the next commit for similar
future situations, but we also solve the problem here by setting the
side panels to accordion mode so that more commits are visible; this
looks better for this demo anyway.
2026-10-01 17:28:31 +02:00
Stefan HallerandClaude Opus 5 9ac8ba035f Link the file names in a diffstat to where each file's diff begins
A diff opens with a diffstat naming every file in it, right above the
diff of each of them. Selecting a commit puts that list in front of you,
naming the same files the menu of the diff's files offers, and the menu
is still the only way to any of them.

Make each of those names a link that goes to where that file's diff
begins, as picking it from the menu does. The panel keeps the focus, so
a file of the commit being read is a click away and the selection stays
on the commit.

The names are found in the output as it is written to the pane, for next
to nothing. 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 and
nothing below is looked at.

The link states the name as the diffstat does, and which file that names
is worked out on the click, against the files the diff turned out to
hold. That is the point at which a name the diffstat cut off behind
"..." or compacted to the "{old => new}" form of a rename can be
recognized at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:28:31 +02:00
46 changed files with 2447 additions and 302 deletions
+4
View File
@@ -68,6 +68,10 @@ bump-lazycore:
record-demo:
demo/record_demo.sh $(filter-out $@,$(MAKECMDGOALS))
.PHONY: rerecord-demos
rerecord-demos:
demo/rerecord_demos.sh $(filter-out $@,$(MAKECMDGOALS))
.PHONY: vendor
vendor:
go mod tidy && go mod vendor
+39 -19
View File
@@ -47,7 +47,8 @@ A simple terminal UI for git commands
[![GitHub Releases](https://img.shields.io/github/downloads/jesseduffield/lazygit/total)](https://github.com/jesseduffield/lazygit/releases) [![Go Report Card](https://goreportcard.com/badge/github.com/jesseduffield/lazygit)](https://goreportcard.com/report/github.com/jesseduffield/lazygit) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/f46416b715d74622895657935fcada21)](https://app.codacy.com/gh/jesseduffield/lazygit/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/f46416b715d74622895657935fcada21)](https://app.codacy.com/gh/jesseduffield/lazygit/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage) [![golangci-lint](https://img.shields.io/badge/linted%20by-golangci--lint-brightgreen)](https://golangci-lint.run/) [![GitHub tag](https://img.shields.io/github/v/tag/jesseduffield/lazygit?color=blue)](https://github.com/jesseduffield/lazygit/releases/latest) [![homebrew](https://img.shields.io/homebrew/v/lazygit?color=blue)](https://formulae.brew.sh/formula/lazygit)
![commit_and_push](../assets/demo/commit_and_push-compressed.gif)
<!-- demo: commit_and_push -->
<video src="https://github.com/user-attachments/assets/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.
![stage_lines](../assets/demo/stage_lines-compressed.gif)
<!-- 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.
![interactive_rebase](../assets/demo/interactive_rebase-compressed.gif)
<!-- 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.
![cherry_pick](../assets/demo/cherry_pick-compressed.gif)
<!-- 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.
![bisect](../assets/demo/bisect-compressed.gif)
<!-- 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.
![Nuke working tree](../assets/demo/nuke_working_tree-compressed.gif)
<!-- 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).
![amend_old_commit](../assets/demo/amend_old_commit-compressed.gif)
<!-- 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.
![filter](../assets/demo/filter-compressed.gif)
<!-- 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.
![custom_command](../assets/demo/custom_command-compressed.gif)
<!-- 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.
![worktree_create_from_branches](../assets/demo/worktree_create_from_branches-compressed.gif)
<!-- 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).
![custom_patch](../assets/demo/custom_patch-compressed.gif)
<!-- 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`.
![rebase_onto](../assets/demo/rebase_onto-compressed.gif)
<!-- 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)
![undo](../assets/demo/undo-compressed.gif)
<!-- 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.
![commit_graph](../assets/demo/commit_graph-compressed.gif)
<!-- 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>`.
![diff_commits](../assets/demo/diff_commits-compressed.gif)
<!-- demo: diff_commits -->
<video src="https://github.com/user-attachments/assets/2ef5dac7-3ab6-4f92-9289-aab56a658762" controls></video>
### Show GitHub pull requests
-112
View File
@@ -1,112 +0,0 @@
# Specify a command to be executed
# like `/bin/bash -l`, `ls`, or any other commands
# the default is bash for Linux
# or powershell.exe for Windows
command: echo "YOU NEED TO SPECIFY YOUR OWN COMMAND WITH THE -d ARG"
# Specify the current working directory path
# the default is the current working directory path
cwd: null
# Export additional ENV variables
env:
recording: true
# Explicitly set the number of columns
# or use `auto` to take the current
# number of columns of your shell
cols: 120 # 100
# Explicitly set the number of rows
# or use `auto` to take the current
# number of rows of your shell
rows: 35 # 30
# Amount of times to repeat GIF
# If value is -1, play once
# If value is 0, loop indefinitely
# If value is a positive number, loop n times
repeat: 0
# Quality
# 1 - 100
# Higher quality seems to make no difference, but running it through
# gifsicle ends up with a much better compressed version.
quality: 100
# Delay between frames in ms
# If the value is `auto` use the actual recording delays
frameDelay: auto
# Maximum delay between frames in ms
# Ignored if the `frameDelay` isn't set to `auto`
# Set to `auto` to prevent limiting the max idle time
maxIdleTime: 2000
# The surrounding frame box
# The `type` can be null, window, floating, or solid`
# To hide the title use the value null
# Don't forget to add a backgroundColor style with a null as type
frameBox:
type: floating
title: Lazygit
style:
border: 0px black solid
backgroundColor: "#1d1d1d"
margin: -5px
# Add a watermark image to the rendered gif
# You need to specify an absolute path for
# the image on your machine or a URL, and you can also
# add your own CSS styles
watermark:
imagePath: null
style:
position: absolute
right: 15px
bottom: 15px
width: 100px
opacity: 0.9
# Cursor style can be one of
# `block`, `underline`, or `bar`
cursorStyle: block
# Font family
# You can use any font that is installed on your machine
# in CSS-like syntax
# Download from:
# https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/DejaVuSansMono.zip
# Not using the mono font because it makes icons too small.
fontFamily: "DejaVuSansM Nerd Font"
# The size of the font
fontSize: 8
# The height of lines
lineHeight: 1
# The spacing between letters
letterSpacing: 0
# Theme
theme:
background: "transparent"
foreground: "#dddad6"
cursor: "#c7c7c7"
black: "#7a7a7a"
red: "#fc4384"
green: "#b3e33b"
yellow: "#ffa727"
blue: "#102895"
magenta: "#c930c7"
cyan: "#00c5c7"
white: "#c7c7c7"
brightBlack: "#676767"
brightRed: "#ff7fac"
brightGreen: "#c8ed71"
brightYellow: "#ebdf86"
brightBlue: "#6871ff"
brightMagenta: "#ff76ff"
brightCyan: "#5ffdff"
brightWhite: "#fffefe"
Binary file not shown.
Binary file not shown.
+21
View File
@@ -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.
+116
View File
@@ -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
View File
@@ -2,55 +2,72 @@
set -e
TYPE=$1
TEST=$2
# The repository the demo is uploaded to. GitHub only plays videos that live in
# its own attachment store, and an attachment is tied to one repository.
REPO=jesseduffield/lazygit
# The issue that collects the demo recordings. Posting a comment there is what
# makes an uploaded video readable by people who are not signed in to GitHub.
# It can stay closed; commenting on a closed issue publishes the video just as
# well, and does not reopen it.
PUBLISH_ISSUE=6051
usage() {
echo "Usage: $0 [gif|mp4] <test path>"
echo "e.g. using full path: $0 gif pkg/integration/tests/demo/nuke_working_tree.go"
echo "Usage: $0 [--no-upload] <test path>"
echo "e.g. $0 pkg/integration/tests/demo/nuke_working_tree.go"
echo
echo "--no-upload leaves the video in demo/output and stops there, for"
echo "checking how a change to demo/settings.tape turns out."
exit 1
}
if [ "$#" -ne 2 ]
UPLOAD=true
if [ "$1" = "--no-upload" ]
then
UPLOAD=false
shift
fi
TEST=$1
if [ "$#" -ne 1 ]
then
usage
fi
if [ "$TYPE" != "gif" ] && [ "$TYPE" != "mp4" ]
TOOLS="vhs ttyd ffmpeg"
if [ "$UPLOAD" = true ]
then
usage
exit 1
TOOLS="$TOOLS gh"
fi
if [ -z "$TEST" ]
for TOOL in $TOOLS
do
if ! command -v "$TOOL" > /dev/null 2>&1
then
echo "$TOOL could not be found"
echo "Install it with: brew install $TOOL"
exit 1
fi
done
if [ "$UPLOAD" = true ]
then
usage
fi
WORKTREE_PATH=$(git worktree list | grep assets | awk '{print $1}')
WORKTREE_PATH=$(git worktree list | grep assets | awk '{print $1}')
if [ -z "$WORKTREE_PATH" ]
then
echo "Could not find assets worktree. You'll need to create a worktree for the assets branch using the following command:"
echo "git worktree add .worktrees/assets assets"
echo "The assets branch has no shared history with the main branch: it exists to store assets which are too large to store in the main branch."
exit 1
fi
if [ -z "$WORKTREE_PATH" ]
then
echo "Could not find assets worktree. You'll need to create a worktree for the assets branch using the following command:"
echo "git worktree add .worktrees/assets assets"
echo "The assets branch has no shared history with the main branch: it exists to store assets which are too large to store in the main branch."
exit 1
fi
OUTPUT_DIR="$WORKTREE_PATH/demo"
if ! command -v terminalizer &> /dev/null
then
echo "terminalizer could not be found"
echo "Install it with: npm install -g terminalizer"
exit 1
fi
if ! command -v "gifsicle" &> /dev/null
then
echo "gifsicle could not be found"
echo "Install it with: npm install -g gifsicle"
exit 1
OUTPUT_DIR="$WORKTREE_PATH/demo"
else
OUTPUT_DIR=demo/output
fi
# Get last part of the test path and set that as the output name
@@ -63,19 +80,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>"
+113
View File
@@ -0,0 +1,113 @@
#!/bin/sh
set -e
# Re-records the demos that a page embeds and points the page at the new
# videos. Use it when a change to demo/settings.tape, or to lazygit's
# appearance, leaves the existing recordings looking out of date.
#
# A page names the demo behind each video in a comment above it:
#
# <!-- demo: commit_and_push -->
# <video src="https://github.com/user-attachments/assets/..." controls></video>
#
# GitHub drops that comment when it renders the page, so it costs the reader
# nothing. This script re-records the demo the comment names and rewrites the
# URL on the line below it.
usage() {
echo "Usage: $0 [--no-upload] [page ...]"
echo "e.g. $0 README.md"
echo
echo "Re-records every demo the given pages embed. With no page given,"
echo "that means README.md."
echo
echo "--no-upload leaves the videos in demo/output and the pages untouched,"
echo "which is what you want for reviewing a change to demo/settings.tape."
exit 1
}
NO_UPLOAD=
if [ "$1" = "--no-upload" ]
then
NO_UPLOAD=--no-upload
shift
fi
if [ "$1" = "-h" ] || [ "$1" = "--help" ]
then
usage
fi
if [ ! -x demo/record_demo.sh ]
then
echo "Run this from the root of the repository."
exit 1
fi
if [ "$#" -eq 0 ]
then
set -- README.md
fi
for PAGE in "$@"
do
if [ ! -f "$PAGE" ]
then
echo "$PAGE does not exist"
exit 1
fi
NAMES=$(sed -n 's/^<!-- demo: \([A-Za-z0-9_]*\) -->$/\1/p' "$PAGE")
if [ -z "$NAMES" ]
then
echo "$PAGE embeds no demos. Each video needs a <!-- demo: <name> -->"
echo "comment on the line above it to say which demo it came from."
exit 1
fi
for NAME in $NAMES
do
TEST="pkg/integration/tests/demo/$NAME.go"
if [ ! -f "$TEST" ]
then
echo "$PAGE asks for a demo called $NAME, but $TEST does not exist"
exit 1
fi
echo
echo "=== $NAME ==="
if [ -n "$NO_UPLOAD" ]
then
demo/record_demo.sh --no-upload "$TEST"
continue
fi
# Keep the recording chatter on screen, since a full run takes a while,
# and read the new URL back out of it afterwards.
LOG=$(mktemp)
demo/record_demo.sh "$TEST" | tee "$LOG"
URL=$(sed -n 's/.*<video src="\([^"]*\)".*/\1/p' "$LOG")
rm -f "$LOG"
if [ -z "$URL" ]
then
echo "Recording $NAME produced no URL"
exit 1
fi
awk -v marker="<!-- demo: $NAME -->" -v url="$URL" '
hit { sub(/src="[^"]*"/, "src=\"" url "\""); hit = 0 }
$0 == marker { hit = 1 }
{ print }
' "$PAGE" > "$PAGE.new"
mv "$PAGE.new" "$PAGE"
echo "$PAGE now points at $URL"
done
done
+48
View File
@@ -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"
}
+12
View File
@@ -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
+77 -25
View File
@@ -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
![Nuke working tree](../assets/demo/interactive_rebase-compressed.gif)
```html
<video src="https://github.com/user-attachments/assets/<uuid>" controls></video>
```
This means we can update assets without needing to update the docs that embed them.
GitHub plays a video in a README only when it is served from its own attachment
store. If you commit a video to the assets branch and link it the way we link
the images, GitHub drops the whole `<video>` element when it renders the page.
So the README reads the uploaded copy rather than the one in the assets
worktree. Keep that one anyway, so that we still have the file if we ever need
to upload it again. Stage it and raise a PR against the assets branch as you
would for any other asset.
Attachment URLs are opaque and have no path we can predict, so a new recording
of an existing demo means a new URL and an edit to the page that embeds it.
That comment on `PUBLISH_ISSUE` is not bookkeeping; it is what makes the video
watchable. An uploaded attachment is readable only by people signed in to
GitHub until some posted comment in the repository refers to it, and a README
on a branch does not count. Skip that step and the video plays for you and
404s for everyone else, which is easy to miss because you are signed in. The
script waits until the video can be fetched without a token before it prints
the tag. Referring to an attachment once is enough and cannot be undone, so
the comments could be deleted later, but leaving them gives us a dated list of
every recording.
Uploading needs push access to the lazygit repository. If you don't have it,
record the demo, then ask a maintainer to upload the mp4 for you.
### Re-recording every demo on a page
Write the name of the demo above each video, so that we can find our way from
a page back to the demo that produced it:
```html
<!-- demo: commit_and_push -->
<video src="https://github.com/user-attachments/assets/<uuid>" controls></video>
```
GitHub drops the comment when it renders the page. With it in place, a change
to `demo/settings.tape` or to lazygit's own appearance can be rolled out across
every recording at once:
```sh
scripts/rerecord_demos.sh
# or, to look before you upload anything:
scripts/rerecord_demos.sh --no-upload
```
That re-records every demo the README embeds and rewrites each URL in place.
Name other pages as arguments to do the same for them.
+4
View File
@@ -74,5 +74,9 @@ bump-gocui:
demo *args:
demo/record_demo.sh {{ args }}
# Re-record every demo the README embeds
rerecord-demos *args:
demo/rerecord_demos.sh {{ args }}
vendor:
go mod tidy && go mod vendor
+6
View File
@@ -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,
+4
View File
@@ -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
+6
View File
@@ -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) {
+14
View File
@@ -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 {
+1
View File
@@ -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
View File
@@ -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)
+6
View File
@@ -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 {
+4
View File
@@ -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 {
+2 -1
View File
@@ -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))
})
}
}
+66 -25
View File
@@ -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
+60 -13
View File
@@ -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 {
+324 -5
View File
@@ -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)
}
}
+24 -1
View File
@@ -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
+2
View File
@@ -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",
+12
View File
@@ -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()
+63 -30
View File
@@ -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"))
},
})
+3
View File
@@ -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"
}
@@ -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()
},
})
+5 -1
View File
@@ -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,
+10
View File
@@ -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.",
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
demo/rerecord_demos.sh "$@"
+7 -1
View File
@@ -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.