Commit Graph
8419 Commits
Author SHA1 Message Date
Stefan HallerandClaude Opus 5 99a52ef604 Edit the selected line of a diff from the focused main view
Reading a diff is often how you notice something to fix, and the file and
line are right there in front of you — so pressing edit opens the file at
the line under the selection, as it does in the staging view.

The line number the diff shows is the line number in the version the diff
is of, which for a commit's diff is not where that line sits today, so it
is carried forward the same way clicking a diff-renderer hyperlink already
does. A file header names no line, so it just opens the file.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 440e507888 Jump by hunk and by file in the focused main view
Reading a diff of any size means moving in bigger steps than a line at a
time, which the staging view offers and the main view didn't. So the same
hunk keys work here, and n / N step from file to file — worth having only
here, since a diff spanning several files is something the staging view
never showed.

Where "the next file" begins isn't in the text: a diff renderer may print
whatever it likes above a file's content. So navigation lands on the first
row that states which file it belongs to, which is the file's header when
the source says so and its first content line otherwise. The anchor's own
file is found by scanning down rather than to the nearest row either way —
having just landed on a header, the nearest row above belongs to the file
we came from, and taking it would send the next press back where we
started.

A file you go to is brought to the top of the view, since the file is what
you went there for and the more of it is on screen the better. That only
applies where the view has to scroll at all: a file already on screen
leaves it where it is. In hunk mode what ends up selected is the file's
first change rather than the row the file begins at, and a large context
size can put that change further down than a screenful; the selection is
scrolled into view afterwards as any other jump's is, and the alignment
gives way where the two can't both hold.

The diff loads lazily, so a target below the loaded portion isn't there to
be found; rather than doing nothing, we read the rest in and look again.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 002ac12665 Select a range of diff lines by dragging
Selecting a range with the mouse is the obvious gesture once clicking
selects a line, and it's the only way to get a range without knowing the
keybindings.

The range has to be anchored where the mouse went down, which the view
can't tell us: a click in hunk mode selects a whole block, leaving the
view's own range anchor at the block's far end, so a drag from there would
grow the selection from the wrong end. So the clicked line is remembered
when the click happens, and the drag anchors there.

A drag that reaches the edge of the view keeps scrolling, using the same
autoscroller the staging view does — mouse capture means the pointer can be
dragged past the edge, and there is more diff down there than fits on
screen. Unlike the staging view, whose content is a string that is always
there in full, this diff loads lazily, so scrolling down has to keep
reading it in or the autoscroll would stop at the loaded edge.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 42df7ef093 Select a whole change block when focusing the main view in hunk mode
Users who set hunk mode as their default get it when entering the staging
view, and the focused main view is on its way to replacing that view, so it
has to behave the same: focusing selects a whole change block, and clicking
a change line selects that line's block, ready to act on. A click on a
context line still selects only that line, since the click points at it
precisely — you may well want to edit it. A click inside the block that is
already selected does the same, giving hunk mode up and going back to line
by line. That keeps a single line reachable with the mouse in a block too
long to see the end of, and matches how a click inside a range selection
already collapses it.

The block offered up is the first one that begins on screen, so that its
whole extent can be seen before acting on it. Only when none does — a change
too long to fit on the screen — is the block that reaches into the view from
above taken instead, and the selection then extends to its first line off
screen: focusing a diff must not move it, so nothing here scrolls.

The staging view makes one exception, and so must we, or a file that is one
solid block of changes (a file you just created, or deleted) would come up
with all of it selected. The staging view asks the parsed patch; we ask the
rendered diff the same question, which needs no second git invocation and
works over a whole commit's diff, where the answer differs per file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 0fb30cd986 Make selecting a change block reachable without a controller
Focusing the main view is about to establish a hunk selection, and that
happens in a free function shared with the controller that focuses the
main view from a side panel, which has no main view controller to hand.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 57757a1b8e Let a main pane be named without the context package
The mode of a diff view's selection lives on the context of the pane showing
it, and until now only the controllers that drive the selection needed to
name that pane, all of which have it as the concrete context it is.

A side panel is about to be handed the pane a command was invoked in, so
that it can act on what is selected there — and it can't be handed a
context, the interface it is handed one through being declared where a
context's concrete type isn't available. So the state moves to where such an
interface can speak of it, and the panes gain one: a context that has a diff
selection.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 81851dcbbf Show a selection in the focused main view
The focused main view could be scrolled but not pointed at: there was no
way to say "this line" or "these lines", which is what every line-level
interaction needs — editing a line, copying part of a diff, and, later,
staging. So a diff main view now always carries a selection while focused,
starting at the first change already on screen so that focusing doesn't
move the view.

The mode of the selection lives on the main context (a single line, a
range from a fixed anchor, or the change block around the cursor); the
selected line and the range anchor stay in the view itself, whose native
range select draws them, so there is no new highlight machinery. The modes
and their keys are the ones the staging view has, including that the arrow
keys step from block to block in hunk mode.

A pane holds something to select only when what it is showing is a diff
with changes in it, which the render works out once its content is final: a
placeholder message is not a diff, and neither is a diff with nothing in it
at all, such as a binary file's or an empty commit's. Whether a selection
is then drawn there follows from the context stack, as it does for every
other view.

A click in the focused main view now has a meaning again: it selects the
line it points at.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 723fa02bc2 Separate reading a fixed number of lines from reading to the end
ReadToEnd holds a gocui task while it reads, so that lazygit doesn't count as
idle while something waits on the result. That has nothing to do with reading
all the way to the end, and the next commit needs it for a bounded read too, so
the two are pulled apart.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 3234834f63 Guard View.LinesHeight against a concurrent write
The count comes from the view's buffer, which a rendering task appends to on
its own goroutine, so reading it without the write mutex is a data race. Nobody
called it until now, which is why nothing has tripped over it; the next commit
does, from the UI thread while a render is still loading.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 49a71aa929 Take the focused main view's context by its concrete type
Both callers pass a main context, and focusing one is about to need more
of it than the Context interface offers. Saying so in the signature also
retires the type assertion that was there only to reach ClearSearchString.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 b797c2e788 Remove the plumbing for clicking the focused main view
With both implementations gone, nothing is left that lets a side panel
handle a click in the focused main view, so the mechanism for attaching
one to a context can go as well.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 a6c0da41cb Stop diving into a patch explorer when clicking the focused main view
Clicking a line of the focused main view entered the staging or patch
building panel at that line. The focused main view is about to gain a
selection of its own, which is what a click there should set — and with
the explorers on their way out, the dive has nowhere to go.

Nothing replaces the gesture yet, so for the next couple of commits a
click in the focused main view does nothing.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 e8f1a969c4 Classify which side panels show a diff in their main view
The focused main view is about to show a selection, but only where there
are diff lines to act on: a branch's commit log or the status dashboard
has nothing to select. Rather than have the main view guess from the
rendered content, let the side panels say so, since each of them knows
what it renders.

The classification is finer than a yes/no because acting on a selection
means different things per panel — staging into the working tree for the
files panel, taking lines into a custom patch for the commit panels — and
those actions want the same one answer as this. Only whether a panel
shows a diff at all is read for now.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan HallerandClaude Opus 5 71cde8ac9f Fold ViewSelectionController into MainViewController
The focused main view is about to get a real diff selection, which makes
its up/down/page/top/bottom keys mode-aware: what they do depends on the
select state the main view controller owns. Keeping them in a separate
controller would mean either duplicating that state or reaching across
controllers for it, so move them to where the state will live. The two
controllers were attached to the same pair of contexts and nothing else,
so nothing else can notice.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00
Stefan Haller 9d4424b8ac Support diff renderers that emit OSC 1717 diff line metadata (#6029)
Second PR in the stack of PRs towards staging hunks directly from the
main view. This one is stacked on #6026.

Parsing the rendered diff back (#6026) only works for renderings that
stay faithful to a unified diff. If a renderer puts the line numbers in
a gutter, or lays the diff out side by side, the +/- marker moves off
the start of each body line and the parse refuses the result. Those are
exactly the renderers people like using.

OSC 1717 is a small terminal protocol that lets a diff renderer state,
inline in its own output, which file and line each row of its rendering
stands for. The spec is at
<https://github.com/stefanhaller/diff-line-metadata-spec>. This PR
implements lazygit's side of it. Nothing changes for users yet; the
consumers arrive in the following PRs.

What it does:

- Advertises the protocol by setting `OSC1717=V1` in the environment of
every renderer invocation, and of git itself. If a renderer understands
the variable, it answers with a handshake record and then emits records.
If it doesn't, nothing changes and lazygit works exactly as before.
- Reads the records in gocui and attaches each payload to the cells that
follow it, clearing at line boundaries. A row carries every distinct
payload on it, so a side-by-side row reports both of its sides.
- Swallows the version-only handshake record, so it doesn't show up as a
stray blank line.
- Keeps records whose region covers no cell at all. Back-to-back records
with nothing between them get their payloads drained into carrier cells,
so a modification pair collapsed into one column stays discoverable.
- Slots the metadata backend ahead of the buffer parser, so a conforming
renderer's own word is taken where it is given, and the parse is the
fallback.

There is also a preparatory refactor in the escape-sequence parser. It
dispatched on a single character, so only the single-digit OSC 8 could
be recognized; it now accumulates the number.

Reference emitters exist as open draft PRs against delta
([#2181](https://github.com/dandavison/delta/pull/2181)), difftastic
([#1014](https://github.com/Wilfred/difftastic/issues/1004)) and
diff-so-fancy
([#538](https://github.com/so-fancy/diff-so-fancy/pull/538)), and as an
unproposed patch to git for its word-diff formats. None of them is
merged.
2026-10-05 10:03:27 +02:00
Stefan HallerandClaude Opus 5 3af5e7940c Ask diff renderers for OSC 1717 metadata
A renderer that speaks the protocol emits nothing unless it is asked to,
so that its output stays plain wherever it is used outside lazygit. The
variable names the versions we understand.

git is one of the renderers we ask. It has no pager to spawn, and so no
terminal to spawn one in, but it still renders the diff itself — for the
word-diff formats, whose markup nothing else could resolve — so the
request has to be made before we decide a pty isn't needed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 09:54:56 +02:00
Stefan HallerandClaude Opus 5 2770b2e5a2 Take a diff renderer at its word about a diff line
Where a renderer states which line of which file it is rendering, take
that as the line's identity. The renderer knows, and for a rendering
that no longer looks like a diff nothing else does. Reading the rendered
text stays the way for the renderings that keep a diff's structure, and
for the renderers that say nothing at all.

Which of the two is used is settled for the rendering as a whole, not
row by row. A rendering with records is not parsed at all, not even for
the rows the renderer says nothing about. Its text is no unified diff,
and a row of it can read like one. Under delta, a commit that adds a
test whose input is a diff shows the test's "diff --git a/img.png
b/img.png" line as a row of its own; parsed, that row opens a file
section that runs to the end of the rendering, and every row delta puts
between hunks and files comes out as a header of a file called img.png.
So the untagged rows of such a rendering stay unresolved, as the
protocol has it (spec section 6.4).

Header records are accepted too. This lets a renderer point at a file
with no content lines, such as a pure rename, a mode change or a binary
file: for those files the header is the only row in the diff.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 09:54:56 +02:00
Stefan HallerandClaude Opus 5 7563dc0f52 Rename parsedDiffLine.RelPath to Path
A diff line's identity is about to become recoverable from a second
source, a diff renderer's own records, and a renderer states the path
however it likes — absolute paths included. The field can't promise
repo-relative any more.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 09:54:56 +02:00
Stefan HallerandClaude Opus 5 03347edd72 Keep the OSC 1717 records that cover no cell
Wherever two diff lines end up on one rendered line, a renderer emits
their records back to back: the deletion and the addition of a
modification collapsed into a single column, or a banner announcing a
file and its first hunk at once. A changed line that is empty is
rendered as its record alone. Attaching a record only to the cells it
precedes loses all of these — the last record of a run wins, and an
empty changed line becomes a line we can say nothing about at all.

Give such a record a cell of its own instead. It renders nothing, so the
diff looks the same, but the line keeps every record it was given.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 09:54:56 +02:00
Stefan HallerandClaude Opus 5 82684f1fa6 Swallow a diff renderer's protocol handshake
Before the diff, a conforming renderer emits one OSC 1717 record that
carries only the version — its way of announcing that it speaks the
protocol at all, without a host having to inspect what it renders. It
describes no line, so keeping it would give the first line of the diff a
record that says nothing about it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 09:54:56 +02:00
Stefan HallerandClaude Opus 5 a7f08990f8 Read the OSC 1717 records a diff renderer emits
A diff renderer that restructures the diff — into columns, or with the
+/- markers replaced by colour — leaves us no way to tell which line of
which file a rendered row came from, which is what acting on the row
requires. The OSC 1717 protocol has the renderer say so directly: it
prefixes each line it renders with a record naming the file and the
line's position in the old and new versions of it.

Attach each record to the cells it precedes, so that a row's records
survive wrapping and the columns of a side-by-side rendering, and hand
them to readers together with the row's text: the two have to describe
the same buffer, and a re-render can rebuild it between two reads.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 09:54:56 +02:00
Stefan HallerandClaude Opus 5 808b6af802 Recognize OSC numbers with more than one digit
The OSC parser dispatched on a single character, so only the
single-digit OSC 8 could ever be recognized; the diff-line metadata
protocol we are about to read uses OSC 1717.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 09:54:56 +02:00
Stefan Haller 61da2345ad Work out which file and line a row of a diff belongs to (#6026)
This is the first in a series of stacked PRs that implement staging
directly in the main view.

To act on the line a user is pointing at in a diff — to stage it, to
open it in an editor, to keep the cursor on it while the diff is
generated again — we have to know which file it belongs to and where it
sits in that file. By the time the diff is on screen, all we have is
text. So this PR parses the view's contents back through the patch
parser and recovers each row's file, kind and line numbers.

No new functionality here, this just lays the ground for future work by
introducing data types and functions for getting information about a
diff that is being shown in a view. In this PR we only support this for
raw git diffs generated without a diff renderer; support for those will
follow in the next PR.

A general note about the whole PR stack: unfortunately this is a lot
more AI-generated with a lot less manual review than I would like. But
applying the normal scrutiny to the code that I usually do would have
taken me months to release this, and I don't want to delay this feature
any longer than necessary.
2026-10-05 09:54:52 +02:00
Stefan HallerandClaude Opus 5 8cd0a0e9ad Decode the path of a diff header the way git writes it
git doesn't just print the path: it terminates it with a tab when the
path contains a space, and C-quotes the whole field when the path
contains anything it won't print raw — with core.quotePath, which is on
by default, that means any non-ASCII byte, so a file called café shows
up as "b/caf\303\251".

Taking the field verbatim therefore gives a path that doesn't exist, for
a whole class of perfectly ordinary file names. The quoting is Go's own
string syntax, octal escapes and all, so decoding it is a call to
strconv.Unquote.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 2a190fd1e4 Recover the identity of a diff line from the rendered diff
To act on the line the user is pointing at in a diff view — to stage it,
to open it in an editor, to keep the cursor on it while the diff is
regenerated — we need to know which file it belongs to and where it sits
in that file. Only the diff the view was rendered from knows that, and
by the time it's on screen all we have is text.

So parse it back: the view's contents are (usually) a unified diff, and
running them through the patch parser recovers each row's file, kind and
line numbers. "Usually" is why this goes behind a seam, and why it
answers "I don't know" rather than guessing: a diff renderer is free to
restructure what it prints, and a wrong answer here means acting on the
wrong line of the wrong file.

Parsing a whole buffer is a separate entry point from parsing a single
line, because a caller resolving every row of a large diff must not
re-parse a file's section once per line of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00
Stefan HallerandClaude Fable 5.1 d5c19a83f3 Keep a view's lines as they were written, beside their cells
A reader that parses a view's content rather than showing it wants the
text the writer wrote, and the cells don't always spell it. A tab is
expanded into the spaces it fills, so a line read back from the cells
ends in one to four spaces where the writer put a tab. A carriage
return moves the write cursor back to the start of the line, so the
text written after it overwrites what came before.

The parser of the main view's diff, which the next commit adds, meets
the first case in every header of a file whose path contains a space.
git terminates the path field of a "---" or "+++" line with a tab
then, and a parser reading the cells takes the spaces the tab became
for part of the path. That path names a file that doesn't exist, so
the file's lines can't be acted on.

Keep the text as written per line, from the first character on that
the cells spell differently, so that a line without a tab or a
carriage return costs nothing. LinesAsWritten hands it out the way
BufferLines hands out the cells' text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00
Stefan HallerandClaude Fable 5.1 caa20f20a0 Clear a pending newline when overwriting lines in place
OverwriteLines is asked for a line and writes the one below it when the
write before it ended in a newline. The view holds such a newline back
until more content arrives, so that it doesn't end in an empty line,
and OverwriteLines moved the write cursor without letting go of it, so
the write that followed advanced to the next line first.

Move the cursor through SetWritePos, which drops the pending newline
along with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00
Stefan HallerandClaude Fable 5.1 d317ff1824 Demonstrate that overwriting lines after a pending newline lands a line low
A view holds back the newline that ends a write until more content
arrives, so that it doesn't end in an empty line. OverwriteLines moves
the write cursor to the line it is given without letting go of that
pending newline, so the write that follows advances first and lands on
the line below. Nothing in lazygit overwrites lines right after such a
write today.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 26e5f773cb Let callers map between view lines and buffer lines
Everything about a diff's content — which file and line a row belongs
to, whether it's a change — is a property of the unwrapped buffer line,
while the cursor, clicks and the range selection all speak in view
lines, which count wrapped segments. Reading the content under the
cursor therefore needs the mapping in both directions, and doing it
outside gocui isn't possible: the wrapping is internal, and the caller
couldn't take the view's lock across the lookup and the read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 db6a04b8a5 Add a well-formedness check for a parsed patch
Parse is lenient: it takes any text and reads a diff out of it, which is
what we want when we hand it a diff, but it has no way to say "this
isn't one". We're about to parse the *rendered* contents of a diff view,
which a diff renderer is free to restructure — putting the line numbers
in a gutter, say, shifts the +/- marker off the start of each body line,
so every line reads as context and the parse silently lies about which
lines are changes.

Comparing each hunk's body against the lengths its header declares
catches exactly that, without teaching us anything about any particular
renderer's layout: a faithful unified diff agrees with its headers, a
restructured one doesn't.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 43a4ba3666 Add an old-file counterpart to Patch.LineNumberOfLine
Identifying a change line of a diff by its file line number needs both
sides: two consecutive deletions sit at the same new-file position, so
only their old-file line numbers tell them apart. LineNumberOfLine only
answers for the new file, which leaves deletions ambiguous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 f04c561b2e Keep a resized view's place in the content it wraps
Wrapping the content at another width moves every line of it to a
different view line. The positions into the view are all view lines: the
scroll offset, the cursor, a range's anchor. Each of them is then left
pointing at a line it was never on. Committing the last of the staged
changes widens the main view by half a screen, and that moves a selected
hunk somewhere else entirely.

Carry the positions through the lines of content they were on. A position
always meant a line of content rather than a view line. The line the
cursor is on keeps the row it was drawn on, so it stays in front of the
user rather than the view scrolling under it; a view with no cursor on
screen keeps its own place instead. A range's ends go on the outermost
segments of their lines, since a range covers lines of content and not
the segments those lines are drawn as.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 9c8c1cb479 Demonstrate that resizing a wrapping view moves its selection
The scroll offset, the cursor and a range's anchor are all view lines, which
count the segments each line of the content is wrapped into. A change of
width wraps the content differently, so every one of them ends up on a
different line than the one it was put on.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 3b0cf1e182 Report a wrapped selection by the lines of content it covers
A test asks which lines of a view are selected; a wrapping view's cursor and
range anchor answer in view lines, which count the segments each line is
drawn as. Going through the segment-to-line mapping keeps the answer in the
terms the question was asked in, and a line the selection covers several
segments of is reported once.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 bc7d95b4a3 Demonstrate that a wrapped selection is reported by segment
The lines a selection covers are asked for by view line, which counts the
segments a wrapping view breaks a line into, and then used to index the
content, whose lines are unwrapped. The two agree only for a view whose
content doesn't wrap.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:01 +02:00
Stefan Haller 576d21868a Improve the rendering of the commit graph for terminals that support it (#6077)
Lazygit uses box drawing characters to render the commit graph. This is
problematic, because not all possible graph topologies can be
represented by them. In the example below, the first merge commit is the
parent of two more commits, a1 and b1, but you can't see this in the
left image; it looks like a1's parent was x1.

Improve this by using kitty's branch drawing characters that were added
to kitty in https://github.com/kovidgoyal/kitty/pull/7681. Ghostty,
Contour and VS Code's builtin terminal support them too; WezTerm
supports them in a nightly build. Lazygit uses them automatically when
it can tell that the terminal supports them, which is the case for
Ghostty and kitty, but not VS Code or Contour; if you are using a
terminal that supports them but isn't recognized (or if you happen to
use a font that contains them), you can opt in by setting the new
`gui.commitGraphStyle` config to "detailed".

The little circles that represent commits look nicer too.

| Before | After |
| ------ | ----- |
| <img width="191" height="114" alt="before"
src="https://github.com/user-attachments/assets/f00e1e91-67f5-4026-81ed-9a5f8b2fdef1"
/> | <img width="191" height="114" alt="after"
src="https://github.com/user-attachments/assets/bf60ac91-b761-458f-a13a-8eb5ea915e14"
/> |
2026-10-04 19:00:35 +02:00
Stefan HallerandClaude Opus 5.5 4780c8bb72 Use the detailed commit graph automatically in terminals that draw it
The detailed commit graph is only drawn for users who set
gui.commitGraphStyle to 'detailed'. Many users of terminals that can
draw it will never find out about it. A terminal can't be asked whether
it draws a given character, but it does tell us its name and version
when tcell asks for them with XTVERSION at startup.

Add an 'auto' value and make it the default. It uses the detailed graph
in kitty from 0.36.2 and in Ghostty from 1.0.0 on. These are the first
versions that draw all of the symbols. Everywhere else it stays with
the classic graph. This includes tmux, because tmux answers XTVERSION
itself. So far, WezTerm draws the symbols only in its nightly builds, so
it isn't detected until a release has them. VS Code isn't detected
either, because it only draws the symbols with GPU acceleration.

The expected output of the integration tests has the classic graph, so
pin it in their config. Otherwise they would fail when run with a
visible UI in one of these terminals.

Log the terminal's name and version at startup, to make it possible to
find out why 'auto' picked what it did.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 18:47:01 +02:00
Stefan HallerandClaude Opus 5.5 da5b9b54eb Add an option to draw the commit graph with branch drawing symbols
If a branch forks off a merge commit, the graph can't show both lines
in the merge commit's row. The line from the branch's first commit ends
in the same cell in which the merge's line to its second parent starts.
The box drawing characters have no symbol for a line from above that
bends to the left combined with a line from the left that bends down,
so one of the two gets lost. Today the cell shows a plain vertical
line, and the branch looks as if it sat on the merge's second parent
(#5497).

The branch drawing symbols in the Unicode Private Use Area (U+F5D0 to
U+F60D) have a symbol for every way in which lines meet in a cell.
kitty introduced them for git graph viewers like vim-flog, and Ghostty,
Contour, the terminal of VS Code and nightly builds of WezTerm render
them too. Other terminals need a font that contains them.

Add the gui.commitGraphStyle config. Its 'detailed' style draws the
graph with these symbols, and the commit symbols of the set connect to
their lines. Commits are drawn as hollow circles and merge commits as
filled ones. The default stays the 'classic' style with the box drawing
characters, because not every terminal can display the others.

A cell has only one colour. Where two lines meet, the symbol takes the
colour of the line from above. With the box drawing characters,
highlighting the lines of the selected commit clears the cells they run
through. The branch drawing symbols keep the other lines. A cell in
which a highlighted line bends is highlighted as a whole. Where a
highlighted line crosses the vertical line of another commit, it is
drawn over that line, so that it reads as one line. In a cell in which
another line bends, the symbol keeps the colour of that line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 18:47:01 +02:00
Stefan HallerandClaude Opus 5.5 74e7572d7a Extract the choice of a graph cell's box drawing characters
This separates choosing the characters from writing them out with their
styles, so that the next commit can add another set of characters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 18:47:01 +02:00
Stefan HallerandClaude Opus 5.5 e5c5a62cd9 Record how lines run through the cells of the commit graph
A cell of the commit graph only records which of its edges are touched
by lines. That's all the box drawing characters can show, but it
doesn't say how the lines connect. A vertical line with a horizontal
line passing behind it touches all four edges. So does a cell in which
the line from above bends to the left, the line below comes in from the
left, and a horizontal line passes through.

For the lines at the top and bottom edges, record whether they run
straight on or bend to the left or right, and record whether a line
passes through horizontally. Nothing reads this yet. It prepares for
drawing the graph with symbols that can show these differences.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 18:47:01 +02:00
Stefan HallerandClaude Opus 5.5 91378b1e8a Stop drawing a line below root commits in the commit graph
If a root commit isn't the last one in the list, the graph draws a
vertical line below it in every row that follows. A root commit's pipe
to the empty tree never terminates, because no commit comes after it,
so it is carried on from row to row. The line also takes up its column,
and an unrelated commit after the root commit is placed to the right of
it.

Drop that pipe after the root commit's row. In its own row, it still
gives the commit symbol the style of the commit. Also, place a commit
that no line leads to next to the lines that go on into its row, not
next to the lines that ended in the row before. Their columns are free
again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 18:47:01 +02:00
Stefan HallerandClaude Opus 5.5 cee602c029 Demonstrate that the graph continues the line below a root commit
If a root commit isn't the last one in the list, the graph draws a
vertical line below it in every row that follows, although nothing
comes after a root commit. This happens in repos with more than one
root commit, for example when showing all branches of a repo with a
gh-pages branch, or after merging an unrelated history.

Also, a commit that no line leads to is placed to the right of the
lines that ended in the row before it, although their columns are free
again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 18:47:01 +02:00
Stefan Haller 4dc619c081 Fix occasionally failing integration test (#6088)
The integration test filter_and_search/search_a_long_diff fails now and
then on CI with "Expected search prompt to be focused". Pressing "/" in
the focused main view reads the rest of the diff with ReadToEnd. Its
callback opens the prompt by enqueueing the work onto the UI thread.
ReadToEnd marks its task as done before calling the callback, so for a
moment no task is busy. If the idle wait of the test driver wakes up in
that moment, the test checks for the prompt before it is open. Adding a
sleep between the two calls makes the test fail every time.

The same gap exists when going to the bottom of a view, since that
callback enqueues its work onto the UI thread too.

Call the callback first, and mark the task as done after it returns. The
work that the callback enqueues holds a task of its own from the moment
it is enqueued, so lazygit no longer counts as idle in between.
2026-10-04 16:50:49 +02:00
Stefan HallerandClaude Opus 5.5 8588cd0b4e Keep ReadToEnd's task busy until its callback has returned
The integration test filter_and_search/search_a_long_diff fails now and
then on CI with "Expected search prompt to be focused". Pressing "/" in
the focused main view reads the rest of the diff with ReadToEnd. Its
callback opens the prompt by enqueueing the work onto the UI thread.
ReadToEnd marks its task as done before calling the callback, so for a
moment no task is busy. If the idle wait of the test driver wakes up in
that moment, the test checks for the prompt before it is open. Adding a
sleep between the two calls makes the test fail every time.

The same gap exists when going to the bottom of a view, since that
callback enqueues its work onto the UI thread too.

Call the callback first, and mark the task as done after it returns. The
work that the callback enqueues holds a task of its own from the moment
it is enqueued, so lazygit no longer counts as idle in between.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:24:53 +02:00
Stefan HallerandClaude Opus 5.5 b34691777d Add a test for the task that ReadToEnd holds while it calls back
ReadToEnd marks its task as done before it calls then. Its callers
enqueue their follow-up work onto the UI thread from then, so for a
moment in between, no task is busy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:21:28 +02:00
Stefan Haller ae37d44bce Fix drawing git's diff stat after switching screen modes (#6087)
Changing the screen mode renders the main view again, and so does
anything else that changes its size along with what it shows. With git's
own diff, that render laid a commit's diffstat out to the width the view
had before. The graph then wrapped onto rows of its own in a view that
got narrower, and stopped short in one that got wider.

A diff renderer's render is created after the layout pass, since only
the layout settles the view's size, and takes its width there. git's own
diff took its width straight away instead, when the render was asked
for. Create its task after the layout as well.

This bug is going to be a little more prominent with the upcoming PR
series that moves hunk staging to the main view; in that case, when you
stage the first hunk into a custom patch, the main view can get split
side-by-side, and without this fix the left half of the main view would
render its diff stat to the old width of the view.
2026-10-04 15:42:37 +02:00
Stefan HallerandClaude Opus 5.5 0afb94e97b Lay git's own diff out to the width the layout gives the view
Changing the screen mode renders the main view again, and so does
anything else that changes its size along with what it shows. With git's
own diff, that render laid a commit's diffstat out to the width the view
had before. The graph then wrapped onto rows of its own in a view that
got narrower, and stopped short in one that got wider.

A diff renderer's render is created after the layout pass, since only
the layout settles the view's size, and takes its width there. git's own
diff took its width straight away instead, when the render was asked
for. Create its task after the layout as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:50:48 +02:00
Stefan HallerandClaude Opus 5.5 6b38e8fcde Demonstrate that git's diffstat keeps the width from before a screen mode change
Pressing + in the commits panel moves to half-screen mode and renders
the commit's diff again in the narrower main view. With git's own diff,
the diffstat graph comes out as wide as the view was before, and wraps
onto rows of its own.

ContainsViewLines asserts on the rows the view draws, after wrapping.
The existing line assertions see the lines of the content and can't tell
a wrapped line from one that fits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:42:10 +02:00
Stefan Haller ff375b124d Revert the change of InactiveBorderColor to "dim" (#6080)
This was an attempt to make lazygit's overall UI look more "elegant",
but it has too many problems. The biggest is that we have panels that
combine two views (e.g. a prompt with suggestions below, or the commit
message editor with subject and body), and showing the frame of the
inactive half of these as "dim" looks wrong; we'd have to add a new
concept of a view that doesn't have the focus but otherwise still looks
active.

Also, some terminals don't do a good job of showing lines made of dim
box drawing characters (e.g. Zed's builtin terminal). This could
probably be improved by using a grey hex color instead of dim, but then
we'd have to pick one that looks good with all possible terminal color
scheme.

That's too many questions, and it's not important enough to spend time
on this, so just revert the change.
2026-09-30 08:44:09 +02:00
Stefan Haller f2a1e178ba Revert the change of InactiveBorderColor to "dim"
This was an attempt to make lazygit's overall UI look more "elegant",
but it has too many problems. The biggest is that we have panels that
combine two views (e.g. a prompt with suggestions below, or the commit
message editor with subject and body), and showing the frame of the
inactive half of these as "dim" looks wrong; we'd have to add a new
concept of a view that doesn't have the focus but otherwise still looks
active.

Also, some terminals don't do a good job of showing lines made of dim
box drawing characters (e.g. Zed's builtin terminal). This could
probably be improved by using a grey hex color instead of dim, but then
we'd have to pick one that looks good with all possible terminal color
scheme.

That's too many questions, and it's not important enough to spend time
on this, so just revert the change.
2026-09-30 08:38:27 +02:00