Compare commits

...
Author SHA1 Message Date
Stefan HallerandClaude Opus 5 fe2ecfc60f Follow the selection with the search in the focused main view
Search the diff, then move the selection with the arrow keys, and
pressing "n" goes to the match after the one the search was last on
rather than to the one after the selection. So walking through the diff
and then picking up the search where you got to takes as many presses as
there are matches behind you.

List views and the staging view keep the current match in step with their
selection; do the same here.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 63ff603adc Draw the diff selection as a narrow bar rather than across the line
Now that a diff view always carries a selection, the highlight fights the
diff itself for the line's colours. Painting the selection across the whole
line takes over the background, and a selected hunk becomes one solid block
with no boundary between what was removed and what replaced it — the more
lines you select, the less you can read.

This isn't specific to renderers like delta that say which side of the diff
a line is on by colouring its background, though they suffer most: git's own
output puts red and green text on that background, which reads badly too.
Since there is no rendering of a diff that a full-width highlight doesn't
degrade, there is nothing here worth configuring — the bar is simply what
diff views use.

Left edge only, and two columns wide: every convention for marking a row of
coloured content — change bars, diff gutters, selection gutters — puts the
marker on the left, and bracketing both edges reads as framing instead.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 5fec6c5c8c 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 857421eaa3 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 47c1b94224 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 6144fd36ea 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 d068114cd0 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 47770ae4e2 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-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 0b16edb822 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-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 4ced072911 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-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 108b2c5aa3 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-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 7ff0977bf6 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-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 a62f213063 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-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 018304bd45 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-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 da1bbb36ee 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-04 19:01:01 +02:00
Stefan HallerandClaude Opus 5 5dded68a09 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-04 19:01:01 +02:00
70 changed files with 3442 additions and 231 deletions
+2
View File
@@ -857,6 +857,8 @@ keybinding:
main:
prevHunk: [<left>, h]
nextHunk: [<right>, l]
prevFile: "N"
nextFile: "n"
toggleSelectHunk: a
pickBothHunks: b
editSelectHunk: E
+14
View File
@@ -223,6 +223,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Scroll down | |
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Search the current view by text | |
@@ -328,6 +335,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Search the current view by text | |
+14
View File
@@ -192,6 +192,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | サイドパネルに戻る | |
| `` / `` | 現在のビューをテキストで検索 | |
@@ -305,6 +312,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 下にスクロール | |
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | サイドパネルに戻る | |
| `` / `` | 現在のビューをテキストで検索 | |
+14
View File
@@ -84,6 +84,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 검색 시작 | |
@@ -162,6 +169,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 아래로 스크롤 | |
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 검색 시작 | |
+14
View File
@@ -231,6 +231,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Scroll omlaag | |
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` v `` | Toggle drag selecteer | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
@@ -306,6 +313,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` v `` | Toggle drag selecteer | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
+14
View File
@@ -99,6 +99,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
@@ -201,6 +208,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Przewiń w dół | |
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
+14
View File
@@ -235,6 +235,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Rolar para baixo | |
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Pesquisar na visualização atual por texto | |
@@ -337,6 +344,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Pesquisar na visualização atual por texto | |
+14
View File
@@ -74,6 +74,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Найти | |
@@ -106,6 +113,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Прокрутить вниз | |
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Найти | |
@@ -286,6 +286,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到侧边面板 | |
| `` / `` | 开始搜索 | |
@@ -334,6 +341,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 向下滚动 | |
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到侧边面板 | |
| `` / `` | 开始搜索 | |
@@ -75,6 +75,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到側邊面板 | |
| `` / `` | 搜尋 | |
@@ -356,6 +363,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到側邊面板 | |
| `` / `` | 搜尋 | |
+4
View File
@@ -677,6 +677,8 @@ type KeybindingCommitFilesConfig struct {
type KeybindingMainConfig struct {
PrevHunk Keybinding `yaml:"prevHunk"`
NextHunk Keybinding `yaml:"nextHunk"`
PrevFile Keybinding `yaml:"prevFile"`
NextFile Keybinding `yaml:"nextFile"`
ToggleSelectHunk Keybinding `yaml:"toggleSelectHunk"`
PickBothHunks Keybinding `yaml:"pickBothHunks"`
EditSelectHunk Keybinding `yaml:"editSelectHunk"`
@@ -1188,6 +1190,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
Main: KeybindingMainConfig{
PrevHunk: Keybinding{"<left>", "h"},
NextHunk: Keybinding{"<right>", "l"},
PrevFile: Keybinding{"N"},
NextFile: Keybinding{"n"},
ToggleSelectHunk: Keybinding{"a"},
PickBothHunks: Keybinding{"b"},
EditSelectHunk: Keybinding{"E"},
+26 -1
View File
@@ -164,6 +164,12 @@ type View struct {
// instead of Sel{Bg,Fg}Colors for highlighting selected lines.
HighlightInactive bool
// If SelectedLineColorWidth is greater than zero, a highlighted line is painted
// in the selection colors on that many columns at its left edge only, rather
// than across its whole width, leaving the line's own colors to show through.
// For content that conveys meaning by color of its own.
SelectedLineColorWidth int
// If Frame is true, a border will be drawn around the view.
Frame bool
@@ -621,6 +627,12 @@ func (v *View) SetRangeSelectStart(rangeSelectStartY int) {
v.rangeSelectStartY = rangeSelectStartY
}
// RangeSelectStartY returns the view line the range selection is anchored on,
// or -1 when there is no range.
func (v *View) RangeSelectStartY() int {
return v.rangeSelectStartY
}
func (v *View) CancelRangeSelect() {
v.rangeSelectStartY = -1
}
@@ -863,7 +875,8 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
rangeSelectEnd = max(relativeRangeSelectStart, v.cy)
}
if y >= rangeSelectStart && y <= rangeSelectEnd {
colorWidth := v.SelectedLineColorWidth
if y >= rangeSelectStart && y <= rangeSelectEnd && (colorWidth == 0 || x < colorWidth) {
fgColor = applySelTextColor(fgColor, v.SelTextColor)
if v.HighlightInactive || !isWindowFocused {
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
@@ -2063,6 +2076,9 @@ func (v *View) ViewBufferLines() []string {
// LinesHeight is the count of view lines (i.e. lines excluding wrapping)
func (v *View) LinesHeight() int {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return len(v.buf.lines)
}
@@ -2272,6 +2288,15 @@ func (v *View) SelectedLineIdx() int {
return seletedLineIdx
}
// MiddleVisibleLineIdx returns the view line halfway down the visible content. It
// stands in for a cursor in a view that has none: of the lines on screen, the one in
// the middle is the likeliest to be the one being read.
func (v *View) MiddleVisibleLineIdx() int {
top := v.OriginY()
bottom := min(top+v.InnerHeight(), v.ViewLinesHeight())
return (top + bottom) / 2
}
// expected to only be used in tests
func (v *View) SelectedLine() string {
v.writeMutex.Lock()
+14 -3
View File
@@ -196,12 +196,12 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == ""
self.updateSelectionHighlights()
self.UpdateSelectionHighlights()
c.HandleFocus(opts)
}
// updateSelectionHighlights re-derives which views draw a selection, and which of
// UpdateSelectionHighlights re-derives which views draw a selection, and which of
// them draw theirs as the active one: a view shows a selection while its context is
// on the stack and has something to select, and the context the user is in shows the
// active selection while the ones behind it show inactive ones.
@@ -210,7 +210,7 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
// every change to the stack goes through; after a refresh, since that is when the
// contents of a list change; and from whoever tells a context that its content has
// gained or lost something to select.
func (self *ContextMgr) updateSelectionHighlights() {
func (self *ContextMgr) UpdateSelectionHighlights() {
self.RLock()
defer self.RUnlock()
@@ -399,3 +399,14 @@ func (self *ContextMgr) NextInStack(c types.Context) types.Context {
panic("context not in stack")
}
// IsInStack reports whether the given context is on the stack at all, for callers
// that can't otherwise know and would make NextInStack panic.
func (self *ContextMgr) IsInStack(c types.Context) bool {
self.RLock()
defer self.RUnlock()
return lo.ContainsBy(self.ContextStack, func(other types.Context) bool {
return other.GetKey() == c.GetKey()
})
}
+18 -26
View File
@@ -13,15 +13,14 @@ type BaseContext struct {
windowName string
onGetOptionsMap func() map[string]string
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
onClickFocusedMainViewFn onClickFocusedMainViewFn
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
focusable bool
transient bool
@@ -34,9 +33,8 @@ type BaseContext struct {
}
type (
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
)
var _ types.IBaseContext = &BaseContext{}
@@ -122,6 +120,14 @@ func (self *BaseContext) HasSelectableContent() bool {
return self.hasSelectableContent
}
// SetHasSelectableContent is for the contexts whose answer isn't fixed and isn't a
// list length either: the main panes, which can only tell by reading the diff they
// have rendered. Whoever sets it re-derives the highlights that follow from it (see
// ContextMgr.UpdateSelectionHighlights).
func (self *BaseContext) SetHasSelectableContent(value bool) {
self.hasSelectableContent = value
}
func (self *BaseContext) GetKey() types.ContextKey {
return self.key
}
@@ -153,7 +159,6 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
self.onQuitFns = nil
self.onDoubleClickFn = nil
self.onClickFn = nil
self.onClickFocusedMainViewFn = nil
self.onRenderToMainFn = nil
}
@@ -175,15 +180,6 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e
}
}
func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) {
if fn != nil {
if self.onClickFocusedMainViewFn != nil {
panic("only one controller is allowed to set an onClickFocusedMainViewFn")
}
self.onClickFocusedMainViewFn = fn
}
}
func (self *BaseContext) GetOnDoubleClick() func() error {
return self.onDoubleClickFn
}
@@ -192,10 +188,6 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
return self.onClickFn
}
func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
return self.onClickFocusedMainViewFn
}
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
if fn != nil {
if self.onRenderToMainFn != nil {
+8 -3
View File
@@ -19,11 +19,16 @@ type CommitFilesContext struct {
}
var (
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
)
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
viewModel := filetree.NewCommitFileTreeViewModel(
func() []*models.CommitFile { return c.Model().CommitFiles },
+8 -3
View File
@@ -32,11 +32,16 @@ type commitDropIndicator struct {
}
var (
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
)
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
dropIndicator := &commitDropIndicator{insertionIndex: -1}
viewModel := NewLocalCommitsViewModel(
+62 -7
View File
@@ -8,9 +8,62 @@ import (
type MainContext struct {
*SimpleContext
*SearchTrait
diffSelect types.DiffSelectState
// dragAnchorViewLine is the view line a mouse-down landed on, remembered so that a
// drag that follows can anchor its range there. The click may have selected a whole
// hunk, whose range anchor is the block's far end, so the clicked line can't be
// read back from the view.
dragAnchorViewLine int
// selectableContentRenderKey names the render whose content HasSelectableContent
// was worked out from. What there is to select is a property of the content, so an
// answer about the content of another render says nothing about this one.
selectableContentRenderKey string
}
var _ types.ISearchableContext = (*MainContext)(nil)
var (
_ types.ISearchableContext = (*MainContext)(nil)
_ types.DiffPaneContext = (*MainContext)(nil)
)
// DiffSelectState returns the focused main view's selection mode state, for the
// controllers to read and mutate directly.
func (self *MainContext) DiffSelectState() *types.DiffSelectState {
return &self.diffSelect
}
// ResetDiffSelectMode returns the pane's selection to the default mode — a single
// line, no range — for whenever it is established from scratch rather than moved. The
// view's range anchor is cleared too, so the next render highlights the cursor line
// only.
func (self *MainContext) ResetDiffSelectMode() {
self.diffSelect.Mode = types.DiffSelectModeLine
self.diffSelect.RangeIsSticky = false
self.diffSelect.UserEnabledHunkMode = false
self.GetView().CancelRangeSelect()
}
// SetDragAnchorViewLine records the view line a mouse-down landed on, so that a drag
// that follows can anchor its range there (see dragAnchorViewLine).
func (self *MainContext) SetDragAnchorViewLine(viewLine int) {
self.dragAnchorViewLine = viewLine
}
// DragAnchorViewLine returns the view line the last mouse-down landed on.
func (self *MainContext) DragAnchorViewLine() int {
return self.dragAnchorViewLine
}
// SelectableContentRenderKey returns the render HasSelectableContent describes (see
// selectableContentRenderKey).
func (self *MainContext) SelectableContentRenderKey() string {
return self.selectableContentRenderKey
}
// SetSelectableContentRenderKey records which render HasSelectableContent describes.
func (self *MainContext) SetSelectableContentRenderKey(key string) {
self.selectableContentRenderKey = key
}
func NewMainContext(
view *gocui.View,
@@ -21,12 +74,11 @@ func NewMainContext(
ctx := &MainContext{
SimpleContext: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{
Kind: types.MAIN_CONTEXT,
View: view,
WindowName: windowName,
Key: key,
Focusable: true,
HasSelectableContent: false,
Kind: types.MAIN_CONTEXT,
View: view,
WindowName: windowName,
Key: key,
Focusable: true,
})),
SearchTrait: NewSearchTrait(c),
}
@@ -38,5 +90,8 @@ func (self *MainContext) ModelSearchResults(searchStr string, caseSensitive bool
return nil
}
// When selecting a search result, collapse a range selection (whether sticky or not)
// or a hunk selection to just the matching line.
func (self *MainContext) OnSearchSelect(int) {
self.ResetDiffSelectMode()
}
+7 -2
View File
@@ -14,10 +14,15 @@ type ReflogCommitsContext struct {
}
var (
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
_ types.DiffMainViewContext = (*ReflogCommitsContext)(nil)
)
func (self *ReflogCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
viewModel := NewFilteredListViewModel(
func() []*models.Commit { return c.Model().FilteredReflogCommits },
+7 -2
View File
@@ -12,10 +12,15 @@ type StashContext struct {
}
var (
_ types.IListContext = (*StashContext)(nil)
_ types.DiffableContext = (*StashContext)(nil)
_ types.IListContext = (*StashContext)(nil)
_ types.DiffableContext = (*StashContext)(nil)
_ types.DiffMainViewContext = (*StashContext)(nil)
)
func (self *StashContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewStashContext(
c *ContextCommon,
) *StashContext {
+8 -3
View File
@@ -21,11 +21,16 @@ type SubCommitsContext struct {
}
var (
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
)
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewSubCommitsContext(
c *ContextCommon,
) *SubCommitsContext {
+7 -2
View File
@@ -15,10 +15,15 @@ type WorkingTreeContext struct {
}
var (
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
_ types.DiffMainViewContext = (*WorkingTreeContext)(nil)
)
func (self *WorkingTreeContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypeStaging
}
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
viewModel := filetree.NewFileTreeViewModel(
func() []*models.File { return c.Model().Files },
-3
View File
@@ -178,7 +178,6 @@ func (gui *Gui) resetHelpersAndControllers() {
contextLinesController := controllers.NewContextLinesController(common)
renameSimilarityThresholdController := controllers.NewRenameSimilarityThresholdController(common)
verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common)
viewSelectionControllerFactory := controllers.NewViewSelectionControllerFactory(common)
branchesController := controllers.NewBranchesController(common)
gitFlowController := controllers.NewGitFlowController(common)
@@ -315,13 +314,11 @@ func (gui *Gui) resetHelpersAndControllers() {
controllers.AttachControllers(gui.State.Contexts.Normal,
mainViewController,
verticalScrollControllerFactory.Create(gui.State.Contexts.Normal),
viewSelectionControllerFactory.Create(gui.State.Contexts.Normal),
)
controllers.AttachControllers(gui.State.Contexts.NormalSecondary,
secondaryViewController,
verticalScrollControllerFactory.Create(gui.State.Contexts.NormalSecondary),
viewSelectionControllerFactory.Create(gui.State.Contexts.NormalSecondary),
)
controllers.AttachControllers(gui.State.Contexts.Files,
-1
View File
@@ -8,7 +8,6 @@ func AttachControllers(context types.Context, controllers ...types.IController)
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
context.AddOnClickFn(controller.GetOnClick())
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
context.AddOnFocusFn(controller.GetOnFocus())
context.AddOnFocusLostFn(controller.GetOnFocusLost())
-4
View File
@@ -19,10 +19,6 @@ func (self *baseController) GetOnDoubleClick() func() error {
return nil
}
func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return nil
}
func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
return nil
}
@@ -606,16 +606,6 @@ func (self *CommitFilesController) expandAll() error {
return nil
}
func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return func(mainViewName string, clickedLineIdx int) error {
node := self.getSelectedItem()
if node != nil && node.File != nil {
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
}
return nil
}
}
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
return diffPathsForNode(
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering())
-10
View File
@@ -408,16 +408,6 @@ func (self *FilesController) GetOnDoubleClick() func() error {
})
}
func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return func(mainViewName string, clickedLineIdx int) error {
node := self.getSelectedItem()
if node != nil && node.File != nil {
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
}
return nil
}
}
// if we are dealing with a status for which there is no key in this map,
// then we won't optimistically render: we'll just let `git status` tell
// us what the new status is.
@@ -45,13 +45,8 @@ func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (
}
if renderingStatesDiffLines(contents) {
// A row can carry more than one record, when the rendering puts two diff
// lines on it; the first one is the row's identity, and the leftmost record
// is the one a reader would call the row's own.
if metadata := contents[bufferLineIdx].Metadata; len(metadata) > 0 {
if parsed, ok := parseDiffLineMetadata(metadata[0]); ok {
return self.diffLineInfo(parsed), true
}
if info, ok := self.diffLineInfoFromRecords(contents[bufferLineIdx].Metadata); ok {
return info, true
}
return types.DiffLineInfo{}, false
}
@@ -64,6 +59,56 @@ func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (
return self.diffLineInfo(parsed), true
}
// diffLineInfoFromRecords recovers a row's identity from the records the diff
// renderer stated for it. ok is false when the row carries no record we understand.
//
// A row can carry more than one record, when the rendering puts two diff lines on it
// (a side-by-side row shows a deletion and the addition replacing it); the leftmost
// is the one a reader would call the row's own, so it is the row's identity.
func (self *DiffLineHelper) diffLineInfoFromRecords(metadata []string) (types.DiffLineInfo, bool) {
if len(metadata) == 0 {
return types.DiffLineInfo{}, false
}
parsed, ok := parseDiffLineMetadata(metadata[0])
if !ok {
return types.DiffLineInfo{}, false
}
return self.diffLineInfo(parsed), true
}
// resolvedDiffLine is one rendered row's recovered identity, plus whether it could
// be recovered at all — the element of the table resolveDiffLines produces.
type resolvedDiffLine struct {
info types.DiffLineInfo
ok bool
}
// resolveDiffLines recovers the identity of every row of a rendered diff in one
// pass, indexed 1:1 with contents. It is the batch form of GetDiffLineInfo, for the
// whole-buffer scans (which change lines are where, which file each row belongs
// to), and reads the rendering the same way: by the renderer's records, or by
// parsing it as a unified diff (see renderingStatesDiffLines). Resolving row by row
// would re-run the buffer parser's whole-section parse once per row — O(n²) on a
// large single-file diff — so the buffer parser runs once for the whole buffer.
func (self *DiffLineHelper) resolveDiffLines(contents []gocui.DiffLineContent) []resolvedDiffLine {
resolved := make([]resolvedDiffLine, len(contents))
if renderingStatesDiffLines(contents) {
for i, content := range contents {
if info, ok := self.diffLineInfoFromRecords(content.Metadata); ok {
resolved[i] = resolvedDiffLine{info, true}
}
}
return resolved
}
for i, parsed := range parseAllDiffLinesFromBuffer(diffLineTexts(contents)) {
if parsed.ok {
resolved[i] = resolvedDiffLine{self.diffLineInfo(parsed.parsed), true}
}
}
return resolved
}
// diffLineInfo turns a parser's result into the absolute-path identity consumers
// work with. The path arrives repo-relative from the diff header, but a renderer
// states it however it likes, absolute paths included.
@@ -0,0 +1,359 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// The questions a diff view can be asked about what it is showing — where the change
// lines are, which block or file a row belongs to — answered in the view-line terms a
// cursor and a click speak. They are all built on the identities recovered in
// diff_line_helper.go, which is where the answering stops and the recovering starts.
// changeLines resolves view's rendered diff to one flag per buffer line: whether
// that row is a change line (an addition or a deletion), as opposed to context, a
// header, or a row whose identity couldn't be recovered. Those are the rows a
// selection is anchored on and navigation moves between.
func (self *DiffLineHelper) changeLines(view *gocui.View) []bool {
resolved := self.resolveDiffLines(view.DiffLineContents())
isChange := make([]bool, len(resolved))
for i, r := range resolved {
isChange[i] = r.ok && r.info.IsChange()
}
return isChange
}
// FirstChangeLineInView returns the view line of the first change line on screen. It
// is where the selection goes when the main view is focused by keyboard: focusing a
// diff you are reading points at something in it without moving it, so the search
// stops at the bottom of the viewport rather than going after a change further down.
// ok is false when the viewport holds no change line — scrolled into a long stretch
// of context, or past the last change.
func (self *DiffLineHelper) FirstChangeLineInView(view *gocui.View) (int, bool) {
top, bottom, ok := visibleBufferLines(view)
if !ok {
return 0, false
}
isChange := self.changeLines(view)
for i := top; i <= min(bottom, len(isChange)-1); i++ {
if isChange[i] {
return view.ViewLineForBufferLine(i)
}
}
return 0, false
}
// FirstChangeBlockInView returns the view line of the first change block on screen:
// the first one that *begins* in the viewport, and failing that the one that reaches
// into the viewport from above, whose start is off screen. Hunk mode wants that order
// for the block it offers up on focus: preferably a block whose beginning the user can
// see, rather than the tail of one they have scrolled past the start of. The block
// bleeding in from above is kept as the answer for a change too long to fit on screen,
// where there is no other. ok is false when the viewport shows no change line.
func (self *DiffLineHelper) FirstChangeBlockInView(view *gocui.View) (int, bool) {
top, bottom, ok := visibleBufferLines(view)
if !ok {
return 0, false
}
isChange := self.changeLines(view)
for i := top; i <= min(bottom, len(isChange)-1); i++ {
if isChange[i] && (i == 0 || !isChange[i-1]) {
return view.ViewLineForBufferLine(i)
}
}
// A block covering the top line is one that began above it: nothing else can put a
// change there once no block starts on screen.
if top < len(isChange) && isChange[top] {
return view.ViewLineForBufferLine(top)
}
return 0, false
}
// visibleBufferLines returns the first and last line of view's content that the
// viewport shows any part of, for the queries that only care about what the user can
// see. The last line is the one at the bottom edge, or the content's last when the
// content ends above it. ok is false for a view showing no content at all.
func visibleBufferLines(view *gocui.View) (int, int, bool) {
top, ok := view.BufferLineForViewLine(view.OriginY())
if !ok {
return 0, 0, false
}
lastVisible := min(view.OriginY()+view.InnerHeight(), view.ViewLinesHeight()) - 1
bottom, ok := view.BufferLineForViewLine(lastVisible)
if !ok {
return top, top, true
}
return top, bottom, true
}
// ViewHasChangeLines reports whether view's rendered diff holds any change line at
// all, i.e. whether there is anything to select. It is false over a non-diff
// placeholder, and over a diff with nothing in it — an empty commit, a binary file —
// which are the cases where the focused main view shows no selection.
func (self *DiffLineHelper) ViewHasChangeLines(view *gocui.View) bool {
return lo.Contains(self.changeLines(view), true)
}
// IsChangeLine reports whether the given view line of view's rendered diff is a
// change line rather than context, a header, or an unresolvable row — i.e. whether
// pointing at it points at something a patch could be built from.
func (self *DiffLineHelper) IsChangeLine(view *gocui.View, viewLineIdx int) bool {
info, ok := self.GetDiffLineInfo(view, viewLineIdx)
return ok && info.IsChange()
}
// IsSingleHunkForWholeFile reports whether the file the given change line belongs to
// is shown as one solid block of changes — every row of its diff a change of the same
// kind, no context — which is what a newly added or deleted file looks like. That is
// the case where widening the selection to the change block would select the file
// entire, so hunk mode drops to a single line there instead. It asks of a rendered
// diff the question patch.Patch.IsSingleHunkForWholeFile asks of a patch.
//
// It says false while the diff is still being read in, since the rows that would
// answer otherwise — a context line, a change of the other kind — may not have
// arrived yet. That errs towards hunk mode, which is what the user asked for.
func (self *DiffLineHelper) IsSingleHunkForWholeFile(view *gocui.View, changeViewLine int) bool {
if manager := self.c.GetViewBufferManagerForView(view); manager != nil && manager.IsLoading() {
return false
}
anchor, ok := view.BufferLineForViewLine(changeViewLine)
if !ok {
return false
}
resolved := self.resolveDiffLines(view.DiffLineContents())
if anchor >= len(resolved) || !resolved[anchor].ok {
return false
}
// The question is per file: a commit's diff may hold a newly added file next to an
// edited one.
path := resolved[anchor].info.Path
kind := resolved[anchor].info.Type
for _, row := range resolved {
if !row.ok || row.info.Path != path {
continue
}
if row.info.Type == types.DiffLineContext {
return false
}
if row.info.IsChange() && row.info.Type != kind {
return false
}
}
return true
}
// ChangeBlockBounds returns the inclusive view-line range of the change block to
// select in hunk mode around anchorViewLine. A change block is lazygit's notion of a
// hunk — a run of consecutive added or deleted lines bounded by context, of which a
// single git @@ hunk may hold several. When the anchor is context, the block used is
// the first at or below it, or — with nothing below, the cursor sitting past the last
// change — the nearest above, so that hunk mode always has a block to select. ok is
// false only when the diff holds no change line at all.
func (self *DiffLineHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine int) (int, int, bool) {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, 0, false
}
isChange := self.changeLines(view)
start := anchor
for start < len(isChange) && !isChange[start] {
start++
}
if start >= len(isChange) {
for start = min(anchor, len(isChange)-1); start >= 0 && !isChange[start]; start-- {
}
if start < 0 {
return 0, 0, false
}
}
end := start
for start > 0 && isChange[start-1] {
start--
}
for end < len(isChange)-1 && isChange[end+1] {
end++
}
startView, startOk := view.ViewLineForBufferLine(start)
// The block's last line goes to its last view line, so that a line the view
// wrapped is highlighted to its end rather than only where it begins.
endView, endOk := view.LastViewLineForBufferLine(end)
if !startOk || !endOk {
return 0, 0, false
}
return startView, endView, true
}
// SelectedHunkBounds returns the change block selected in hunk mode. The range
// anchor stays on the block's far end when a click moves the cursor before its
// handler runs, so it still identifies the selected block.
func (self *DiffLineHelper) SelectedHunkBounds(view *gocui.View) (int, int, bool) {
anchor := view.RangeSelectStartY()
if anchor < 0 {
return 0, 0, false
}
return self.ChangeBlockBounds(view, anchor)
}
// AdjacentChangeBlock returns the view line to move to for next/previous change-block
// navigation in view's rendered diff, starting from anchorViewLine. A change block is
// lazygit's notion of a hunk (see ChangeBlockBounds). forward=true targets the start
// of the next block, forward=false the start of the previous one — from mid-block that
// means the previous block, rather than the one we are in. ok is false when there's no
// further block, so the caller leaves the view where it is.
func (self *DiffLineHelper) AdjacentChangeBlock(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, false
}
target, ok := changeBlockStart(self.changeLines(view), anchor, forward)
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(target)
}
// AdjacentFile returns the view line to move to for next/previous file navigation in
// view's (possibly multi-file) rendered diff, starting from anchorViewLine: the first
// located row of the neighbouring file, found where the rows' file changes. ok is
// false at the first or last file.
func (self *DiffLineHelper) AdjacentFile(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, false
}
target, ok := fileStart(self.filePaths(view), anchor, forward)
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(target)
}
// filePaths resolves view's rendered diff to the path each buffer line belongs to,
// empty for a row whose identity couldn't be recovered.
func (self *DiffLineHelper) filePaths(view *gocui.View) []string {
resolved := self.resolveDiffLines(view.DiffLineContents())
paths := make([]string, len(resolved))
for i, row := range resolved {
if row.ok {
paths[i] = row.info.Path
}
}
return paths
}
// fileStart finds, in a diff whose lines carry the file path they belong to (empty for
// a row no backend could place), the first located row of the file adjacent to `from`
// in the given direction — the row file navigation lands on. It is the pure index
// arithmetic behind AdjacentFile.
//
// A file is identified by its path, so we look for where the path changes, skipping
// rows that carry none: those are the blank separator rows between files, or the
// header rows of a diff renderer that doesn't state which file its headers belong to.
// So the landing row is the file's header wherever the source says so — a parseable
// buffer, or a renderer that tags its headers — and the file's first content line
// otherwise, which is an accepted degradation.
func fileStart(paths []string, from int, forward bool) (int, bool) {
anchorPath, ok := anchorFilePath(paths, from)
if !ok {
return 0, false
}
if forward {
for i := from; i < len(paths); i++ {
if paths[i] != "" && paths[i] != anchorPath {
return i, true
}
}
return 0, false
}
// Walk back past the current file (its rows and any unlocated ones) to the previous
// file's last located row, then back over that whole file, landing on its first.
i := from
for i >= 0 && (paths[i] == "" || paths[i] == anchorPath) {
i--
}
if i < 0 {
return 0, false
}
prevPath := paths[i]
for i > 0 && (paths[i-1] == "" || paths[i-1] == prevPath) {
i--
}
for paths[i] != prevPath {
i++
}
return i, true
}
// anchorFilePath returns the path of the file the anchor sits in: the first row at or
// below it that carries a path — the file whose content is at or below the top of the
// view — falling back to the nearest above when there is nothing below. Scanning down
// first matters because the anchor is often a file-header row that carries no path of
// its own, whose nearest tagged row above is the *previous* file's content; taking
// that would make next-file navigation jump back into the file just left, so a second
// press wouldn't advance. ok is false when no row carries a path.
func anchorFilePath(paths []string, from int) (string, bool) {
if from < 0 {
return "", false
}
for i := from; i < len(paths); i++ {
if paths[i] != "" {
return paths[i], true
}
}
for i := min(from, len(paths)) - 1; i >= 0; i-- {
if paths[i] != "" {
return paths[i], true
}
}
return "", false
}
// changeBlockStart finds, in a diff whose lines are flagged by isChange, the first
// line of the change block adjacent to `from` in the given direction. It is the pure
// index arithmetic behind AdjacentChangeBlock.
func changeBlockStart(isChange []bool, from int, forward bool) (int, bool) {
if from < 0 || from >= len(isChange) {
return 0, false
}
if forward {
i := from
for i < len(isChange) && isChange[i] { // leave the current block
i++
}
for i < len(isChange) && !isChange[i] { // skip the separating context
i++
}
if i == len(isChange) {
return 0, false
}
return i, true
}
i := from
for i >= 0 && isChange[i] { // leave the current block
i--
}
for i >= 0 && !isChange[i] { // skip context, landing on the previous block's last line
i--
}
if i < 0 {
return 0, false
}
for i > 0 && isChange[i-1] { // walk back to that block's first line
i--
}
return i, true
}
@@ -0,0 +1,104 @@
package helpers
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestChangeBlockStart(t *testing.T) {
// A diff with three change blocks separated by context:
// 0 file header 1 hunk header 2 context
// 3 + 4 + (block A)
// 5 context
// 6 - (block B)
// 7 context
// 8 + (block C)
isChange := []bool{false, false, false, true, true, false, true, false, true}
scenarios := []struct {
name string
from int
forward bool
expected int
found bool
}{
{"forward from a header lands on the first block", 0, true, 3, true},
{"forward from separating context lands on the next block", 5, true, 6, true},
{"forward from the start of a block skips to the next", 3, true, 6, true},
{"forward from inside a block skips the rest of it", 4, true, 6, true},
{"forward from the last block finds nothing", 8, true, 0, false},
{"backward from a later block lands on the previous one's start", 8, false, 6, true},
{"backward from a block start lands on the previous block's start", 6, false, 3, true},
{"backward from inside the first block finds nothing", 4, false, 0, false},
{"backward from the first block's start finds nothing", 3, false, 0, false},
{"backward from context lands on the preceding block's start", 7, false, 6, true},
{"an anchor past the end finds nothing", 9, true, 0, false},
{"a negative anchor finds nothing", -1, true, 0, false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
got, found := changeBlockStart(isChange, s.from, s.forward)
assert.Equal(t, s.found, found)
if s.found {
assert.Equal(t, s.expected, got)
}
})
}
}
func TestFileStart(t *testing.T) {
// A parseable two-file diff: every row carries its file's path, headers included,
// as the buffer parser reports it.
parseable := []string{"a", "a", "a", "a", "b", "b", "b", "b"}
// The same diff as a renderer that doesn't say which file its headers belong to
// emits it: only content lines carry the path, so navigation can land no higher
// than each file's first content line.
contentOnly := []string{"", "", "a", "a", "", "", "b", "b"}
// Three such files, to exercise navigating from one file's untagged header to the
// next: the row just above b's header is a's content, so the anchor's file has to
// be found by scanning down (b) rather than up (a) — otherwise next-file would
// jump back into b and a second press couldn't advance.
contentOnlyThree := []string{"", "", "a", "a", "", "", "b", "b", "", "", "c", "c"}
// A renderer that does tag its header rows: the file header and the hunk-header box
// carry the file's path, but the blank separator rows around them carry nothing.
// Navigation must land on the header's first row, not the blank line above it.
// 0 blank 1-2 file hdr 3 blank 4-6 hunk hdr box 7 content
// 8 blank 9-10 file hdr 11 blank 12-13 hunk hdr box 14 content
headerTagged := []string{"", "a", "a", "", "a", "a", "a", "a", "", "b", "b", "", "b", "b", "b"}
scenarios := []struct {
name string
paths []string
from int
forward bool
expected int
found bool
}{
{"forward lands on the next file's header", parseable, 1, true, 4, true},
{"forward from the last file finds nothing", parseable, 5, true, 0, false},
{"backward lands on the previous file's header", parseable, 5, false, 0, true},
{"backward from the first file finds nothing", parseable, 1, false, 0, false},
{"forward lands on the next file's first content line", contentOnly, 2, true, 6, true},
{"backward lands on the previous file's first content line", contentOnly, 7, false, 2, true},
{"forward from an untagged header advances past it", contentOnly, 0, true, 6, true},
{"a second forward press advances again", contentOnlyThree, 4, true, 10, true},
{"forward lands on a tagged file header", headerTagged, 7, true, 9, true},
{"backward lands on a tagged file header", headerTagged, 14, false, 1, true},
{"a diff with no located rows finds nothing", []string{"", ""}, 0, true, 0, false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
got, found := fileStart(s.paths, s.from, s.forward)
assert.Equal(t, s.found, found)
if s.found {
assert.Equal(t, s.expected, got)
}
})
}
}
+771 -10
View File
@@ -3,7 +3,9 @@ package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type MainViewController struct {
@@ -12,6 +14,9 @@ type MainViewController struct {
context *context.MainContext
otherContext *context.MainContext
dragAutoscroller *helpers.DragAutoscroller
draggingWithMouse bool
}
var _ types.IController = &MainViewController{}
@@ -21,12 +26,19 @@ func NewMainViewController(
context *context.MainContext,
otherContext *context.MainContext,
) *MainViewController {
return &MainViewController{
controller := &MainViewController{
baseController: baseController{},
c: c,
context: context,
otherContext: otherContext,
}
controller.dragAutoscroller = helpers.NewDragAutoscroller(
c.HelperCommon,
context,
controller.canDragAutoscroll,
controller.handleDragAutoscroll,
)
return controller
}
func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
@@ -38,10 +50,68 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
Tooltip: self.c.Tr.ToggleStagingViewTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk),
Handler: self.toggleSelectHunk,
DescriptionFunc: self.diffSelectionDescription(func() string {
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
return self.c.Tr.SelectLineByLine
}
return self.c.Tr.SelectHunk
}),
Description: self.c.Tr.ToggleSelectHunk,
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.ToggleSelectHunkTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect),
Handler: self.toggleRangeSelect,
Description: self.c.Tr.ToggleRangeSelect,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.ToggleRangeSelect),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Edit),
Handler: self.editLine,
Description: self.c.Tr.EditFile,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.EditFile),
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.EditFileTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Main.PrevHunk),
Handler: self.prevChangeBlock,
Description: self.c.Tr.PrevHunk,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevHunk),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Main.NextHunk),
Handler: self.nextChangeBlock,
Description: self.c.Tr.NextHunk,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextHunk),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Main.PrevFile),
Handler: self.prevFile,
Description: self.c.Tr.PrevFileInDiff,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevFileInDiff),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Main.NextFile),
Handler: self.nextFile,
Description: self.c.Tr.NextFileInDiff,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextFileInDiff),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Return),
Handler: self.escape,
Description: self.c.Tr.ExitFocusedMainView,
DescriptionFunc: self.escapeDescription,
DisplayOnScreen: true,
},
{
@@ -51,6 +121,28 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
Description: self.c.Tr.StartSearch,
Tag: "navigation",
},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectUp),
Handler: self.extendRangeUp,
Description: self.c.Tr.RangeSelectUp,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectUp),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown),
Handler: self.extendRangeDown,
Description: self.c.Tr.RangeSelectDown,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectDown),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom},
}
}
@@ -68,6 +160,19 @@ func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts)
Handler: self.onClickInOtherViewOfMainViewPair,
FocusedView: self.otherContext.GetViewName(),
},
{
// Dragging after a click extends a range selection from the clicked line.
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModMotion,
Handler: self.onDragInFocusedView,
FocusedView: self.context.GetViewName(),
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseRelease,
Handler: self.onDragRelease,
},
}
}
@@ -76,35 +181,691 @@ func (self *MainViewController) Context() types.Context {
}
func (self *MainViewController) togglePanel() error {
if self.otherContext.GetView().Visible {
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
if !self.otherContext.GetView().Visible {
return nil
}
// Whether the pair holds a diff is decided by the side panel beneath, which
// NextInStack only finds while our context is still the focused main view, so
// read it before pushing the other pane.
isDiff := self.isDiffView()
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
if isDiff {
establishDiffSelection(self.c, self.otherContext, -1)
}
return nil
}
// escape dismisses the selection a step at a time before leaving the view: a range
// collapses to its cursor line, and hunk mode the user turned on goes back to
// line-by-line. Hunk mode that is merely the configured default is not something to
// escape from, so there escape leaves.
func (self *MainViewController) escape() error {
if self.selectingRange() || self.selectingHunkEnabledByUser() {
self.context.ResetDiffSelectMode()
return nil
}
self.c.Context().Pop()
return nil
}
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
sidePanelContext := self.c.Context().NextInStack(self.context)
if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil {
return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y)
func (self *MainViewController) escapeDescription() string {
if self.selectingRange() {
return self.c.Tr.DismissRangeSelect
}
if self.selectingHunkEnabledByUser() {
return self.c.Tr.SelectLineByLine
}
return self.c.Tr.ExitFocusedMainView
}
// selectingHunkEnabledByUser reports whether we are in hunk mode because the user
// asked for it, as opposed to it being the configured default.
func (self *MainViewController) selectingHunkEnabledByUser() bool {
return self.diffSelectState().Mode == types.DiffSelectModeHunk && self.diffSelectState().UserEnabledHunkMode
}
// isDiffView reports whether the focused main view currently shows a diff, and so
// shows a selection. See types.DiffMainViewContext.
func (self *MainViewController) isDiffView() bool {
return self.diffMainViewType() != types.DiffMainViewTypeNone
}
// diffMainViewType reports what the diff in the focused main view belongs to, taken
// from the side panel beneath it, or DiffMainViewTypeNone when this pane isn't on the
// stack or has no diff panel beneath it. The IsInStack guard is essential:
// NextInStack panics for a context that isn't in the stack, and GetKeybindings (which
// leads here) also runs for off-stack panes — at startup and while generating the
// cheatsheets, where the stack is empty.
func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
if !self.c.Context().IsInStack(self.context) {
return types.DiffMainViewTypeNone
}
if diffContext, ok := self.c.Context().NextInStack(self.context).(types.DiffMainViewContext); ok {
return diffContext.GetDiffMainViewType()
}
return types.DiffMainViewTypeNone
}
// diffSelectState returns this pane's diff selection mode state.
func (self *MainViewController) diffSelectState() *types.DiffSelectState {
return self.context.DiffSelectState()
}
// diffSelectionDescription qualifies the description of a command that acts on the
// selection, so that it is listed only where it applies: the main view also shows
// content with nothing to select in it — a branch's commit log, the status dashboard —
// and a command with no description is left out of the keybindings menu.
//
// The static Description stays as it is: the cheatsheets are generated from that, and
// they document what a key does rather than when it applies.
func (self *MainViewController) diffSelectionDescription(describe func() string) func() string {
return func() string {
if !self.isDiffView() {
return ""
}
return describe()
}
}
func (self *MainViewController) diffSelectionDescriptionText(description string) func() string {
return self.diffSelectionDescription(func() string { return description })
}
// diffSelectionDisabledReason disables the commands that act on the selection while
// there is none to act on: a diff view whose diff holds nothing selectable (a binary
// file, an empty commit) or which is showing a placeholder message.
func (self *MainViewController) diffSelectionDisabledReason() *types.DisabledReason {
if !self.context.GetView().Highlight {
return &types.DisabledReason{Text: self.c.Tr.NothingToSelectInDiff}
}
return nil
}
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
self.selectClickedDiffLine(opts.Y)
return nil
}
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
self.c.Context().Push(self.context, types.OnFocusOpts{
ClickedWindowName: self.context.GetWindowName(),
ClickedViewLineIdx: opts.Y,
// Carry the select mode over from the pane we're leaving, so that clicking into
// the other pane keeps hunk mode even the first time we enter it — its own mode
// would otherwise still be the default single line until it had been focused at
// least once. selectClickedDiffLine then keeps or collapses that mode depending on
// where the click landed.
*self.context.DiffSelectState() = *self.otherContext.DiffSelectState()
self.c.Context().Push(self.context, types.OnFocusOpts{})
self.selectClickedDiffLine(opts.Y)
return nil
}
// onDragInFocusedView extends a range selection as the mouse is dragged after a
// click, anchored at the line the click landed on rather than wherever the click left
// the selection — a click can select a whole hunk, whose far end would otherwise
// become the anchor. Dragging turns hunk mode off: you get a plain range from the
// clicked line to the line under the cursor, which gocui has already moved here.
func (self *MainViewController) onDragInFocusedView(opts gocui.ViewMouseBindingOpts) error {
view := self.context.GetView()
if !self.isDiffView() || !view.Highlight {
return nil
}
sel := self.diffSelectState()
sel.Mode = types.DiffSelectModeRange
sel.RangeIsSticky = false
sel.UserEnabledHunkMode = false
view.SetRangeSelectStart(self.context.DragAnchorViewLine())
// A drag that reaches the edge of the view keeps going: mouse capture means the
// pointer can be dragged past the edge, and there is more diff down there than
// fits on screen. opts.Y is where the pointer is in the content, which the
// autoscroller wants relative to the viewport.
self.draggingWithMouse = true
originY, _ := self.context.GetViewTrait().ViewPortYBounds()
self.dragAutoscroller.Update(opts.Y - originY)
return nil
}
func (self *MainViewController) onDragRelease(gocui.ViewMouseBindingOpts) error {
self.draggingWithMouse = false
self.dragAutoscroller.Cancel()
// The drag moved the selection without going through showSelectionAtLine: gocui
// moves the cursor for it. Let the search catch up with where it ended.
self.context.GetView().SetNearestSearchPosition()
return nil
}
// GetOnFocusLost stops an autoscroll that is still running when the view loses focus
// mid-drag, e.g. because a popup appeared, and gives up the mouse capture with it —
// otherwise the pointer would keep driving a view that no longer has focus.
func (self *MainViewController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(types.OnFocusLostOpts) {
self.dragAutoscroller.Cancel()
if self.draggingWithMouse {
self.draggingWithMouse = false
self.c.GocuiGui().CancelMouseCapture()
}
}
}
// canDragAutoscroll reports whether the autoscroller should run: only while a drag is
// actually extending a range in a diff. Scrolling down also has to keep the lazily
// loaded content ahead of the scroll, or it would stop at the loaded edge.
func (self *MainViewController) canDragAutoscroll(direction int) bool {
if !self.draggingWithMouse || !self.isDiffView() {
return false
}
view := self.context.GetView()
if !view.Highlight || self.diffSelectState().Mode != types.DiffSelectModeRange {
return false
}
if direction > 0 {
self.c.ReadLinesToFillView(view)
}
return true
}
// handleDragAutoscroll extends the selection to the line the pointer ends up over
// after the autoscroller has scrolled, leaving the range anchored where the drag
// started. It reports whether the autoscroll should carry on.
//
// The pointer is usually outside the view by now — that is what mouse capture is for —
// so the line it is over is clamped to the visible ones, leaving the selection's far
// end at the edge the scroll is moving towards.
func (self *MainViewController) handleDragAutoscroll(viewLine int) bool {
if !self.canDragAutoscroll(0) {
return false
}
view := self.context.GetView()
originY, viewportHeight := self.context.GetViewTrait().ViewPortYBounds()
target := lo.Clamp(viewLine, 0, max(0, view.ViewLinesHeight()-1))
view.SetCursorY(lo.Clamp(target-originY, 0, max(0, viewportHeight-1)))
return true
}
// selectClickedDiffLine sets the focused main view's selection from a click at the
// given view line. In hunk mode, clicking inside the selected block collapses it to
// that line; clicking a change line outside it keeps hunk mode and selects that block.
// A click on context, or any click outside hunk mode, selects just that line too.
func (self *MainViewController) selectClickedDiffLine(viewLine int) {
if !self.isDiffView() {
return
}
view := self.context.GetView()
// Remember where the click landed so that a drag that follows anchors its range
// there, even when this click selects a whole hunk.
self.context.SetDragAnchorViewLine(viewLine)
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
if start, end, ok := self.c.Helpers().DiffLine.SelectedHunkBounds(view); ok &&
viewLine >= start && viewLine <= end {
self.context.ResetDiffSelectMode()
showSelectionAtLine(view, viewLine, false)
return
}
if self.c.Helpers().DiffLine.IsChangeLine(view, viewLine) {
self.selectHunkAround(viewLine, false)
return
}
}
self.context.ResetDiffSelectMode()
showSelectionAtLine(view, viewLine, false)
}
// establishDiffSelection turns on the focused main view's selection once the view has
// been focused. clickedViewLine is the view line a click pointed at, or -1 for
// keyboard focus, which points at no particular line and so starts at the first
// change line on screen.
//
// Focusing never moves the view: you focus the diff you are reading in order to point
// at something in it, so the selection goes where you are looking rather than the
// view going where the selection would like to be. With no change line on screen at
// all — a long stretch of context — it lands on the middle visible line, the likeliest
// one to be the one being read.
//
// With hunk mode configured as the default the selection widens to the whole change
// block: keyboard focus lands on the first block on screen, and a click on a change
// line selects that line's block, ready to act on. A click on context still selects
// just that line — the click points at it precisely, so it stays editable.
func establishDiffSelection(c *ControllerCommon, mainContext *context.MainContext, clickedViewLine int) {
mainContext.ResetDiffSelectMode()
view := mainContext.GetView()
// The panel beneath renders a diff, but that diff may hold nothing to act on: a
// binary file, or an empty commit. Rendering it worked that out, so the pane is
// already showing no selection and there is nowhere to put one.
if !c.Helpers().DiffLine.ViewHasChangeLines(view) {
return
}
if clickedViewLine >= 0 {
// Remember where the click landed so that a drag that follows anchors its range
// there, even when this click selects a whole hunk.
mainContext.SetDragAnchorViewLine(clickedViewLine)
if hunkModeApplies(c, view, clickedViewLine) &&
c.Helpers().DiffLine.IsChangeLine(view, clickedViewLine) {
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
selectDiffHunk(c, mainContext, clickedViewLine, false)
return
}
showSelectionAtLine(view, clickedViewLine, false)
return
}
target, ok := changeToSelectOnScreen(c, view)
if !ok {
showSelectionAtLine(view, view.MiddleVisibleLineIdx(), false)
return
}
if hunkModeApplies(c, view, target) {
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
selectDiffHunk(c, mainContext, target, false)
return
}
showSelectionAtLine(view, target, false)
}
// changeToSelectOnScreen returns the change line keyboard focus establishes the
// selection on. In hunk mode that is the first block that begins on screen, so that
// the block being offered up is one the user can see the extent of, falling back to a
// block that reaches into the view from above — a change longer than the screen, where
// there is nothing else to offer. Line by line it is simply the first change line on
// screen. ok is false when the viewport shows no change at all.
func changeToSelectOnScreen(c *ControllerCommon, view *gocui.View) (int, bool) {
if c.UserConfig().Gui.UseHunkModeInStagingView {
return c.Helpers().DiffLine.FirstChangeBlockInView(view)
}
return c.Helpers().DiffLine.FirstChangeLineInView(view)
}
// hunkModeApplies reports whether an established selection should start out as the
// whole change block around the given change line. That's what the config asks for,
// except over a file shown as one solid block of changes, where it would select the
// whole file — see DiffLineHelper.IsSingleHunkForWholeFile.
func hunkModeApplies(c *ControllerCommon, view *gocui.View, changeViewLine int) bool {
return c.UserConfig().Gui.UseHunkModeInStagingView &&
!c.Helpers().DiffLine.IsSingleHunkForWholeFile(view, changeViewLine)
}
// showSelectionAtLine moves the focused main view's selection to the given view line,
// clamped to the content. scrollIntoView scrolls the line into view when it's
// off-screen, for navigating to it; a click leaves it false, the clicked line being on
// screen already.
func showSelectionAtLine(view *gocui.View, lineIdx int, scrollIntoView bool) {
view.FocusPoint(0, lo.Clamp(lineIdx, 0, max(0, view.ViewLinesHeight()-1)), scrollIntoView)
// A search carries on from where the selection now is, so that stepping to the
// next match goes to the one after it rather than the one after the match the
// user last stepped to.
view.SetNearestSearchPosition()
}
func (self *MainViewController) selectHunkAround(changeViewLine int, scrollIntoView bool) {
selectDiffHunk(self.c, self.context, changeViewLine, scrollIntoView)
}
// selectDiffHunk selects the whole change block around the given change line, for
// hunk mode: the cursor goes to the block's first line and the range anchor to its
// last, so the native range highlight spans the block. With no block to be found —
// a diff with no changes in it — it falls back to a single-line selection.
//
// scrollIntoView brings the block's first line on screen, for the commands that mean
// to go there; a click leaves it false, so that the view doesn't move under the mouse
// when the block the click landed in starts above the viewport.
func selectDiffHunk(
c *ControllerCommon, mainContext *context.MainContext, changeViewLine int, scrollIntoView bool,
) {
view := mainContext.GetView()
start, end, ok := c.Helpers().DiffLine.ChangeBlockBounds(view, changeViewLine)
if !ok {
mainContext.DiffSelectState().Mode = types.DiffSelectModeLine
view.CancelRangeSelect()
showSelectionAtLine(view, changeViewLine, scrollIntoView)
return
}
view.SetRangeSelectStart(end)
showSelectionAtLine(view, start, scrollIntoView)
}
// navigate moves the focused main view to the row find locates from the current
// anchor — the selected line when a selection is showing, otherwise the top visible
// line. With a selection we move it there and scroll it into view, re-selecting the
// whole block in hunk mode; with none we stay in scroll mode, bringing the target
// to the top without selecting anything.
// alignTop says what a jump does with a target it has to scroll to: bring it to the
// top of the view, or leave the scrolling to place it as it sees fit.
func (self *MainViewController) navigate(find findDiffRowFn, forward bool, alignTop bool) {
v := self.context.GetView()
anchor := v.OriginY()
if v.Highlight {
anchor = v.SelectedLineIdx()
}
if target, ok := find(v, anchor, forward); ok {
self.placeNavigationTarget(target, alignTop)
return
}
if !forward {
// Everything above the anchor has loaded, so a backward target that wasn't
// found doesn't exist.
return
}
// The diff loads lazily, so a target below the loaded portion isn't there to be
// found yet. Read the rest of it in and look again before concluding there is none.
manager := self.c.GetViewBufferManagerForView(v)
if manager == nil {
return
}
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
if target, ok := find(v, anchor, forward); ok {
self.placeNavigationTarget(target, alignTop)
}
return nil
})
})
}
// findDiffRowFn locates a row of the rendered diff to navigate to, given the view,
// the anchor view line to start from, and the direction.
type findDiffRowFn func(view *gocui.View, anchorViewLine int, forward bool) (int, bool)
func (self *MainViewController) nextChangeBlock() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, true, false)
return nil
}
func (self *MainViewController) prevChangeBlock() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, false, false)
return nil
}
// nextFile and prevFile bring the file they go to to the top of the view, since what
// you are going there for is the file, and the more of it is on screen the better.
func (self *MainViewController) nextFile() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, true, true)
return nil
}
func (self *MainViewController) prevFile() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, false, true)
return nil
}
// placeNavigationTarget moves the selection to the row a jump found, bringing it on
// screen if it isn't already.
//
// alignTop asks for the target to become the view's top line, so that everything that
// begins there is on screen. It only applies to a target the view has to scroll to: a
// jump to something already on screen leaves the view alone, there being nothing to
// gain from moving what the user is looking at. In hunk mode what ends up selected is
// the first change block at or below the target, which a large context size can put
// further down than a screenful; the selection is then scrolled into view as any other
// jump's is, and the alignment gives way to that.
func (self *MainViewController) placeNavigationTarget(target int, alignTop bool) {
v := self.context.GetView()
if !v.Highlight {
v.SetOrigin(0, target)
return
}
if alignTop {
self.scrollTargetToTop(target)
}
// Jumping to another block or file moves the cursor without shift held, so a
// range that grows only while shift is held collapses rather than stretching all
// the way to the target. A sticky range stretches instead; this is the point of
// being sticky.
self.collapseNonStickyRange()
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
self.selectHunkAround(target, true)
return
}
// Line mode leaves a single-line selection at the target; an active range extends
// to it, the anchor being untouched.
showSelectionAtLine(v, target, true)
}
// scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving
// the view where it is when that row is on screen already. The last screenful of the
// diff is as far as it goes, so that the view doesn't scroll past the end of what it is
// showing.
func (self *MainViewController) scrollTargetToTop(target int) {
view := self.context.GetView()
originY, height := self.context.GetViewTrait().ViewPortYBounds()
if target >= originY && target < originY+height {
return
}
view.SetOriginY(min(target, max(0, view.ViewLinesHeight()-height)))
}
// moveCursor moves the selection cursor by delta view lines (negative = up), with the
// configured scroll-off margin, reading more content in first when moving down. The
// range anchor is left untouched, so this extends or contracts a range and just moves
// the selected line otherwise.
func (self *MainViewController) moveCursor(delta int) {
v := self.context.GetView()
if delta > 0 {
self.c.ReadLinesToFillView(v)
}
before := v.SelectedLineIdx()
after := lo.Clamp(before+delta, 0, v.ViewLinesHeight()-1)
if delta == -1 {
checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
} else if delta == 1 {
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
}
showSelectionAtLine(v, after, true)
}
// collapseForLineMove drops hunk mode, and a non-sticky range, back to a single-line
// selection — what a plain (non-shift, non-hunk-step) move does before moving. A
// sticky range is kept, so the move extends it.
func (self *MainViewController) collapseForLineMove() {
sel := self.diffSelectState()
if sel.Mode == types.DiffSelectModeHunk {
sel.Mode = types.DiffSelectModeLine
self.context.GetView().CancelRangeSelect()
return
}
self.collapseNonStickyRange()
}
// collapseNonStickyRange drops a range that only grows while shift is held back to a
// single line at the cursor.
func (self *MainViewController) collapseNonStickyRange() {
sel := self.diffSelectState()
if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky {
sel.Mode = types.DiffSelectModeLine
self.context.GetView().CancelRangeSelect()
}
}
// adjustSelection moves the selection by delta view lines, for the plain up/down and
// page keys. In hunk mode a single-line step jumps to the adjacent block, while a
// larger page step drops out of hunk mode first. A non-sticky range collapses back to
// a single line on a plain move. With no selection — non-diff content — it scrolls.
func (self *MainViewController) adjustSelection(delta int) {
if !self.context.GetView().Highlight {
self.handleLineChange(delta)
return
}
if self.diffSelectState().Mode == types.DiffSelectModeHunk && (delta == 1 || delta == -1) {
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, delta > 0, false)
return
}
self.collapseForLineMove()
self.moveCursor(delta)
}
// selectAbsoluteLine moves the selection to a specific view line — the top or bottom
// of the diff — dropping hunk mode and a non-sticky range like a plain move does.
func (self *MainViewController) selectAbsoluteLine(target int) {
self.collapseForLineMove()
showSelectionAtLine(self.context.GetView(), target, true)
}
// selectingRange reports whether a range selection is currently active: we're in
// range mode and either it's sticky or the anchor and cursor differ, i.e. a
// non-sticky range that has actually been extended.
func (self *MainViewController) selectingRange() bool {
if self.diffSelectState().Mode != types.DiffSelectModeRange {
return false
}
start, end := self.context.GetView().SelectedLineRange()
return self.diffSelectState().RangeIsSticky || start != end
}
// toggleSelectHunk switches between selecting the change block around the cursor and
// a single line.
func (self *MainViewController) toggleSelectHunk() error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.diffSelectState()
if sel.Mode == types.DiffSelectModeHunk {
sel.Mode = types.DiffSelectModeLine
v.CancelRangeSelect()
} else {
sel.Mode = types.DiffSelectModeHunk
sel.UserEnabledHunkMode = true
self.selectHunkAround(v.SelectedLineIdx(), true)
}
return nil
}
// toggleRangeSelect starts or cancels a sticky range selection, which the plain
// up/down keys extend.
func (self *MainViewController) toggleRangeSelect() error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.diffSelectState()
if self.selectingRange() {
sel.Mode = types.DiffSelectModeLine
sel.RangeIsSticky = false
v.CancelRangeSelect()
} else {
sel.Mode = types.DiffSelectModeRange
sel.RangeIsSticky = true
v.SetRangeSelectStart(v.SelectedLineIdx())
}
return nil
}
// extendRange grows a non-sticky range selection by one line in response to
// shift+up/down, starting one at the cursor if there isn't one yet.
func (self *MainViewController) extendRange(forward bool) error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.diffSelectState()
if !self.selectingRange() {
sel.Mode = types.DiffSelectModeRange
v.SetRangeSelectStart(v.SelectedLineIdx())
}
sel.RangeIsSticky = false
if forward {
self.moveCursor(1)
} else {
self.moveCursor(-1)
}
return nil
}
func (self *MainViewController) extendRangeUp() error {
return self.extendRange(false)
}
func (self *MainViewController) extendRangeDown() error {
return self.extendRange(true)
}
func (self *MainViewController) handleLineChange(delta int) {
v := self.context.GetView()
if delta < 0 {
v.ScrollUp(-delta)
} else {
v.ScrollDown(delta)
self.c.ReadLinesToFillView(v)
}
}
func (self *MainViewController) handlePrevLine() error {
self.adjustSelection(-1)
return nil
}
func (self *MainViewController) handleNextLine() error {
self.adjustSelection(1)
return nil
}
func (self *MainViewController) handlePrevPage() error {
self.adjustSelection(-self.context.GetViewTrait().PageDelta())
return nil
}
func (self *MainViewController) handleNextPage() error {
self.adjustSelection(self.context.GetViewTrait().PageDelta())
return nil
}
func (self *MainViewController) handleGotoTop() error {
v := self.context.GetView()
if !v.Highlight {
self.handleLineChange(-v.ViewLinesHeight())
return nil
}
self.selectAbsoluteLine(0)
return nil
}
func (self *MainViewController) handleGotoBottom() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
v := self.context.GetView()
if !v.Highlight {
self.handleLineChange(v.ViewLinesHeight())
return nil
}
self.selectAbsoluteLine(v.ViewLinesHeight() - 1)
return nil
})
})
}
return nil
}
func (self *MainViewController) editLine() error {
view := self.context.GetView()
if !view.Highlight {
return nil
}
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(view, view.SelectedLineIdx())
if !ok {
return nil
}
// A file-header row points at the file as a whole rather than at a line in it, so
// it opens the file without jumping anywhere — as pressing edit on a file in a side
// panel does.
if info.Type == types.DiffLineFileHeader {
return self.c.Helpers().Files.EditFiles([]string{info.Path})
}
// The diff may be of an older commit, whose line numbers aren't the file's current
// ones, so they have to be carried forward before we can point an editor at them.
lineNumber := self.c.Helpers().Diff.AdjustLineNumber(info.Path, info.NewLine, self.context.GetViewName())
return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber)
}
func (self *MainViewController) openSearch() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
@@ -2,6 +2,7 @@ package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -61,21 +62,24 @@ func (self *SwitchToFocusedMainViewController) Context() types.Context {
}
func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
return self.focusMainView(self.c.Contexts().Normal)
return self.focusMainView(self.c.Contexts().Normal, opts.Y)
}
func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error {
return self.focusMainView(self.c.Contexts().NormalSecondary)
return self.focusMainView(self.c.Contexts().NormalSecondary, opts.Y)
}
func (self *SwitchToFocusedMainViewController) handleFocusMainView() error {
return self.focusMainView(self.c.Contexts().Normal)
return self.focusMainView(self.c.Contexts().Normal, -1)
}
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error {
if context, ok := mainViewContext.(types.ISearchableContext); ok {
context.ClearSearchString()
}
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *context.MainContext, clickedLineIdx int) error {
mainViewContext.ClearSearchString()
self.c.Context().Push(mainViewContext, types.OnFocusOpts{})
if _, ok := self.context.(types.DiffMainViewContext); ok {
establishDiffSelection(self.c, mainViewContext, clickedLineIdx)
}
return nil
}
@@ -1,100 +0,0 @@
package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type ViewSelectionControllerFactory struct {
c *ControllerCommon
}
func NewViewSelectionControllerFactory(c *ControllerCommon) *ViewSelectionControllerFactory {
return &ViewSelectionControllerFactory{
c: c,
}
}
func (self *ViewSelectionControllerFactory) Create(context types.Context) types.IController {
return &ViewSelectionController{
baseController: baseController{},
c: self.c,
context: context,
}
}
type ViewSelectionController struct {
baseController
c *ControllerCommon
context types.Context
}
func (self *ViewSelectionController) Context() types.Context {
return self.context
}
func (self *ViewSelectionController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
return []*types.Binding{
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom},
}
}
func (self *ViewSelectionController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
return []*gocui.ViewMouseBinding{}
}
func (self *ViewSelectionController) handleLineChange(delta int) {
v := self.Context().GetView()
if delta < 0 {
v.ScrollUp(-delta)
} else {
v.ScrollDown(delta)
self.c.ReadLinesToFillView(v)
}
}
func (self *ViewSelectionController) handlePrevLine() error {
self.handleLineChange(-1)
return nil
}
func (self *ViewSelectionController) handleNextLine() error {
self.handleLineChange(1)
return nil
}
func (self *ViewSelectionController) handlePrevPage() error {
self.handleLineChange(-self.context.GetViewTrait().PageDelta())
return nil
}
func (self *ViewSelectionController) handleNextPage() error {
self.handleLineChange(self.context.GetViewTrait().PageDelta())
return nil
}
func (self *ViewSelectionController) handleGotoTop() error {
v := self.Context().GetView()
self.handleLineChange(-v.ViewLinesHeight())
return nil
}
func (self *ViewSelectionController) handleGotoBottom() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
v := self.Context().GetView()
self.handleLineChange(v.ViewLinesHeight())
return nil
})
})
}
return nil
}
+153 -1
View File
@@ -117,7 +117,7 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
if opts.Secondary != nil {
gui.RefreshMainView(opts.Secondary, opts.Pair.Secondary)
} else if opts.Pair.Secondary != nil {
opts.Pair.Secondary.GetView().Clear()
gui.clearMainView(opts.Pair.Secondary)
}
// Reset the scroll positions of all the other main views. We do this after
@@ -137,6 +137,158 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
gui.splitMainPanel(opts.Secondary != nil)
}
// clampDiffSelectionToContent brings the focused main view's selection back onto the
// content when the render that just finished left the diff with fewer lines than the
// selection was on — a diff renderer that renders the same diff more compactly, a
// smaller context size. That selection lives in the view rather than in a model, so
// nothing else re-derives it, and past the end of the content it isn't drawn at all,
// which reads as having no selection until an arrow key brings it back.
//
// Called at end of input, when the content is final: doing it while the render is
// still loading would drag the selection to a line that only looks like the last one.
// Only these two views need it; every other view's selection is derived from a model
// as it renders, and so is clamped along with it.
func (gui *Gui) clampDiffSelectionToContent(view *gocui.View) {
if gui.mainContextForView(view) == nil {
return
}
if !view.Highlight {
return
}
if lastLine := view.ViewLinesHeight() - 1; view.SelectedLineIdx() > lastLine {
view.FocusPoint(0, max(0, lastLine), true)
}
}
// clearMainView empties a pane that is being given nothing to show, selection and all.
func (gui *Gui) clearMainView(mainContext types.Context) {
mainContext.GetView().Clear()
mainContext.SetHasSelectableContent(false)
gui.State.ContextMgr.UpdateSelectionHighlights()
}
// updateDiffSelectionVisibility works out whether a main pane holds anything for a
// selection to sit on, from what it is now showing: only beneath a panel whose main
// view is a diff, and only while that diff holds something to select — never over a
// message like "No changed files", and never over a diff with nothing in it, such as a
// binary file's or an empty commit's. Whether the selection is then drawn, and drawn as
// the active one, follows from the context stack.
//
// It is asked wherever the pane's content changes: as a string is rendered, at the
// paint that reveals a command's output, with every further batch of that output, and
// once it has been read to the end. contentIsComplete tells those apart, since a render
// still being read can leave the question open (see diffPaneHasSomethingToSelect). The
// pane never answers from the render before it, and a render that leaves the question
// open is read on until it doesn't, so the answer is always about what is there.
func (gui *Gui) updateDiffSelectionVisibility(view *gocui.View, contentIsComplete bool) {
mainContext := gui.mainContextForView(view)
if mainContext == nil {
return
}
gui.dropAnAnswerAboutAnotherRender(mainContext, view)
if hasSomethingToSelect, known := gui.diffPaneHasSomethingToSelect(
mainContext, view, contentIsComplete,
); known {
mainContext.SetHasSelectableContent(hasSomethingToSelect)
gui.State.ContextMgr.UpdateSelectionHighlights()
} else {
gui.readOnUntilTheDiffPaneCanTell(view)
}
}
// dropAnAnswerAboutAnotherRender takes away what the pane worked out about the content
// of an earlier render, so that this one starts from no answer rather than inheriting
// one. An answer about other content says nothing about this content: carried over, it
// shows a selection over a diff that may have nothing to select, or hides one over a
// diff that has.
//
// A re-render of the same content keeps its answer, and with it the selection drawn
// over it, since that answer is still about what the pane is showing.
func (gui *Gui) dropAnAnswerAboutAnotherRender(mainContext *context.MainContext, view *gocui.View) {
manager := gui.getViewBufferManagerForView(view)
if manager == nil {
return
}
if key := manager.GetTaskKey(); key != mainContext.SelectableContentRenderKey() {
mainContext.SetSelectableContentRenderKey(key)
mainContext.SetHasSelectableContent(false)
gui.State.ContextMgr.UpdateSelectionHighlights()
}
}
// readOnUntilTheDiffPaneCanTell keeps a render going past the lines that were asked of
// it, while the pane still can't say whether there is anything in it to select.
//
// A render is asked for as many lines as the scrollbar needs (see
// linesToReadFromCmdTask), and what a commit's diff opens with can run past that: the
// diffstat of a commit touching thousands of files, or a commit message thousands of
// lines long. Without this the pane would be left with no answer until the user
// scrolled far enough to ask for the rest themselves, which is no way to find out
// whether a diff can be acted on. Another render's worth is asked for each time, so the
// reading stops soon after the first change line, and only runs to the end of a diff
// that has none.
func (gui *Gui) readOnUntilTheDiffPaneCanTell(view *gocui.View) {
manager := gui.getViewBufferManagerForView(view)
if manager == nil {
return
}
step := gui.linesToReadFromCmdTask(view).Total
if step < 0 {
// A view that is being searched is already being read to the end.
return
}
manager.ReadLinesAndWait(view.LinesHeight() + step)
}
// diffPaneHasSomethingToSelect answers whether the given main pane holds anything for a
// selection to sit on, from what it is showing so far. known is false while a render
// still being read leaves the question open.
func (gui *Gui) diffPaneHasSomethingToSelect(
mainContext *context.MainContext, view *gocui.View, contentIsComplete bool,
) (bool, bool) {
if _, showsDiff := gui.State.ContextMgr.CurrentSide().(types.DiffMainViewContext); !showsDiff {
// Under a panel that shows no diff there is nothing to select whatever the pane
// ends up holding, so this needs no content to answer. Answering it now matters,
// because a render may never reach an end. The rest of a long commit log is read
// only as far as the user scrolls, and until then the pane would go on showing
// the selection it was left with under the panel before.
return false, true
}
if !contentIsComplete && mainContext.HasSelectableContent() {
// This render has already found something to select, and its content only grows
// from here, so there is nothing to ask again — nor to read the diff for. An
// answer the render before it gave has been dropped by now (see
// dropAnAnswerAboutAnotherRender), so this really is about the content in hand.
return true, true
}
hasChangeLines := gui.helpers.DiffLine.ViewHasChangeLines(view)
// One change line among those read settles it. Finding none in a render that is
// still going may only mean the changes are in the part still to come. A commit's
// diff opens with a diffstat, and for a commit touching hundreds of files that runs
// well past the screenful the first paint reveals, so that answer waits.
return hasChangeLines, contentIsComplete || hasChangeLines
}
// mainContextForView returns the context of the main pane the given view is, or nil for
// any other view.
func (gui *Gui) mainContextForView(view *gocui.View) *context.MainContext {
switch view {
case gui.Views.Main:
return gui.State.Contexts.Normal
case gui.Views.Secondary:
return gui.State.Contexts.NormalSecondary
}
return nil
}
func (gui *Gui) splitMainPanel(splitMainPanel bool) {
gui.State.SplitMainPanel = splitMainPanel
}
+27 -5
View File
@@ -97,6 +97,7 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
f := func(tasks.TaskOpts) error {
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.SetViewContent(view, str)
gui.updateDiffSelectionVisibility(view, true)
gui.reApplySearch(view)
})
}
@@ -115,6 +116,7 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.SetViewContent(view, str)
view.SetOrigin(originX, originY)
gui.updateDiffSelectionVisibility(view, true)
gui.reApplySearch(view)
})
}
@@ -133,6 +135,7 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.ResetViewOrigin(view)
gui.c.SetViewContent(view, str)
gui.updateDiffSelectionVisibility(view, true)
gui.reApplySearch(view)
})
}
@@ -160,10 +163,20 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
func() {
// As the task reads more lines, the only thing that changes is the
// view's content (and its scrollbar); the window layout doesn't. So a
// content-only render is enough, and it's much cheaper than a full
// layout-and-redraw on every read - which matters a lot when reading
// a long diff, where reads happen repeatedly as the user scrolls.
gui.renderContentOnly()
// content-only render is enough — it skips the layout pass and redraws
// only the cells that differ — and it's much cheaper than a full
// layout-and-redraw on every read, which matters a lot when reading a
// long diff, where reads happen repeatedly as the user scrolls.
//
// What this draws is more of the content than the pane held a moment
// ago, so it is also where what is drawn over that content is worked
// out again. The screenful the first paint reveals may not be enough
// to say whether there is anything to select, and for a diff that
// opens with a long diffstat it isn't.
gui.c.OnUIThreadContentOnly(func() error {
gui.updateDiffSelectionVisibility(view, false)
return nil
})
},
func() {
// The content is fully loaded now, so let the scrollbar track it
@@ -180,13 +193,22 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
view.SetOrigin(0, newOriginY)
}
gui.updateDiffSelectionVisibility(view, true)
gui.clampDiffSelectionToContent(view)
gui.reApplySearch(view)
},
func() {
view.SetOrigin(0, 0)
},
view.BeginOffscreenRender,
view.SwapInOffscreenRender,
func() {
view.SwapInOffscreenRender()
// The content the pane is being given is on display from here on, so
// what is drawn over it is settled against that content rather than
// against the render before it.
gui.updateDiffSelectionVisibility(view, false)
},
func() gocui.Task {
// A background task: rendering content into a view is display
// work, not lazygit driving a git operation, so it must not
+45 -7
View File
@@ -78,6 +78,7 @@ type IBaseContext interface {
// true if the context holds something for a selection to sit on. Contexts that
// don't show a selection at all say false, and so do lists with nothing in them.
HasSelectableContent() bool
SetHasSelectableContent(bool)
// the total height of the content that the view is currently showing
TotalContentHeight() int
@@ -102,9 +103,6 @@ type IBaseContext interface {
// that the generic ListController can be specialized by view-specific controllers.
// We'll need to think of a better way to do this.
AddOnDoubleClickFn(func() error)
// Likewise for the focused main view: we need this to communicate between a
// side panel controller and the focused main view controller.
AddOnClickFocusedMainViewFn(func(mainViewName string, clickedLineIdx int) error)
// Adding on to the above, this is so that a list-specific handler can register
// a hook for doing additional click handling
AddOnClickFn(func(opts gocui.ViewMouseBindingOpts) error)
@@ -180,6 +178,48 @@ type DiffableContext interface {
RefForAdjustingLineNumberInDiff() string
}
// DiffMainViewContext is implemented by the side panel contexts whose focused
// main view shows a unified diff — files, local commits, sub-commits, reflog,
// stash, and commit files — as opposed to a commit log or other non-diff content
// (branches, tags, status, …). It is distinct from DiffableContext, which is
// about producing a diff between two refs for the diff menu. The focused main
// view shows a selection only for a context that implements this: a selection is
// only meaningful where there are diff lines to act on (edit one, copy some, jump
// by hunk or file). The returned type additionally classifies what acting on that
// selection means.
type DiffMainViewContext interface {
Context
GetDiffMainViewType() DiffMainViewType
}
// DiffMainViewType classifies what the focused main view's diff belongs to, which
// decides what acting on a selection in it means.
type DiffMainViewType int
const (
// DiffMainViewTypeNone: the main view holds no diff, so there is nothing to
// select. A side panel that doesn't implement DiffMainViewContext counts as
// this; no panel returns it itself.
DiffMainViewTypeNone DiffMainViewType = iota
// DiffMainViewTypeStaging: the diff is the working tree's, so the selection can
// be staged or unstaged (the files panel).
DiffMainViewTypeStaging
// DiffMainViewTypePatchBuilding: the diff belongs to a commit, so the selection
// can be taken into a custom patch (the commit files / commits / sub-commits /
// reflog / stash panels).
DiffMainViewTypePatchBuilding
)
// DiffPaneContext is one of the two panes the main section can show, as the thing
// that holds a diff with a selection in it. The panels that act on such a selection
// are handed the pane it was made in, and speak to it through this.
type DiffPaneContext interface {
Context
DiffSelectState() *DiffSelectState
}
type IListContext interface {
Context
@@ -275,10 +315,6 @@ type HasKeybindings interface {
// HandleFocus has already been called (so the main view is up to date). Should return nil if it
// decides not to do anything with the click.
GetOnClick() func(opts gocui.ViewMouseBindingOpts) error
// Implement this in a side-panel controller to get called when there's a click in the main view
// that belongs to your panel while the main view is already focused.
GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error
}
type IController interface {
@@ -339,6 +375,8 @@ type IContextMgr interface {
CurrentSide() Context
CurrentPopup() []Context
NextInStack(context Context) Context
IsInStack(context Context) bool
UpdateSelectionHighlights()
IsCurrent(c Context) bool
IsCurrentOrParent(c Context) bool
ForEach(func(Context))
+28
View File
@@ -0,0 +1,28 @@
package types
// DiffSelectMode is how the focused main view's diff selection extends from the
// cursor: a single line, a range from a fixed anchor, or the change block (hunk)
// around the cursor.
type DiffSelectMode int
const (
DiffSelectModeLine DiffSelectMode = iota
DiffSelectModeRange
DiffSelectModeHunk
)
// DiffSelectState holds the *mode* of the focused main view's diff selection. The
// selected line and the range anchor themselves live in the gocui view (its cursor
// and range-select start), so only the mode lives here. It's on the context rather
// than on the controller because the controller that drives the selection, the
// controller that establishes it on focus, and the pane-toggle that seeds it on the
// other pane all reach the pane through its context.
type DiffSelectState struct {
Mode DiffSelectMode
// When a range is sticky, moving the cursor without holding shift extends the
// range; otherwise it collapses the range back to a single line.
RangeIsSticky bool
// Whether hunk mode was turned on by the user rather than being the configured
// default. This decides whether escape leaves hunk mode or leaves the view.
UserEnabledHunkMode bool
}
+1 -9
View File
@@ -133,14 +133,6 @@ func (gui *Gui) render() {
gui.c.OnUIThread(func() error { return nil })
}
// renderContentOnly triggers a re-render that skips the layout pass and only
// redraws the views whose content changed (relying on tcell's cell-level dirty
// tracking to emit just the cells that actually differ). Use it when only a
// view's content changed, not the window layout.
func (gui *Gui) renderContentOnly() {
gui.c.OnUIThreadContentOnly(func() error { return nil })
}
// postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed
// if the context's view is set to another context we do nothing.
// if the context's view is the current view we trigger a focus; re-selecting the current item.
@@ -154,7 +146,7 @@ func (gui *Gui) postRefreshUpdate(c types.Context, opts types.OnFocusOpts) {
// The render may have given the context its first item, or taken its last one
// away, which decides whether its view draws a selection at all.
gui.State.ContextMgr.updateSelectionHighlights()
gui.State.ContextMgr.UpdateSelectionHighlights()
if gui.currentViewName() == c.GetInputViewName() {
c.HandleFocus(opts)
+9
View File
@@ -168,6 +168,15 @@ func (gui *Gui) createAllViews() error {
gui.Views.Snake.FgColor = gocui.ColorGreen
// The main views show diffs, whose own colors say what each line is: which side of
// the diff it's on, and often its syntax highlighting too. A selection painted
// across the whole line takes those colors over, which for a whole selected hunk
// leaves one unreadable block; so mark the selection with a narrow bar at the left
// edge instead, and leave the rest of the line to the diff. Two columns, enough to
// read as a marker rather than as an artefact.
gui.Views.Main.SelectedLineColorWidth = 2
gui.Views.Secondary.SelectedLineColorWidth = 2
return nil
}
+6
View File
@@ -310,6 +310,7 @@ type TranslationSet struct {
DiscardSelectionTooltip string
ToggleSelectHunk string
SelectHunk string
NothingToSelectInDiff string
SelectLineByLine string
ToggleSelectHunkTooltip string
HunkStagingHint string
@@ -410,6 +411,8 @@ type TranslationSet struct {
AskQuestion string
PrevHunk string
NextHunk string
PrevFileInDiff string
NextFileInDiff string
PrevConflict string
NextConflict string
SelectPrevHunk string
@@ -1493,6 +1496,7 @@ func EnglishTranslationSet() *TranslationSet {
ToggleRangeSelect: "Toggle range select",
DismissRangeSelect: "Dismiss range select",
ToggleSelectHunk: "Toggle hunk selection",
NothingToSelectInDiff: "There is nothing to select here",
SelectHunk: "Select hunks",
SelectLineByLine: "Select line-by-line",
ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.",
@@ -1598,6 +1602,8 @@ func EnglishTranslationSet() *TranslationSet {
AskQuestion: "Ask Question",
PrevHunk: "Go to previous hunk",
NextHunk: "Go to next hunk",
PrevFileInDiff: "Go to previous file",
NextFileInDiff: "Go to next file",
PrevConflict: "Previous conflict",
NextConflict: "Next conflict",
SelectPrevHunk: "Previous hunk",
+18
View File
@@ -174,6 +174,24 @@ func (self *ViewDriver) ContainsViewLines(matchers ...*TextMatcher) *ViewDriver
return self
}
// Asserts on the line the view shows at the top of its viewport, i.e. on where the
// view is scrolled to. It is a view line, so a wrapped line above it doesn't throw the
// count off.
func (self *ViewDriver) TopVisibleLine(matcher *TextMatcher) *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
view := self.getView()
lines := view.ViewBufferLines()
originY := view.OriginY()
if originY >= len(lines) {
return false, fmt.Sprintf("%s: the view is scrolled to line %d, but it has only %d lines",
self.context, originY, len(lines))
}
return matcher.context(fmt.Sprintf("%s top visible line", self.context)).test(lines[originY])
})
return self
}
// asserts that somewhere in the view there are consecutive lines matching the given matchers.
func (self *ViewDriver) ContainsLines(matchers ...*TextMatcher) *ViewDriver {
self.validateMatchersPassed(matchers)
@@ -0,0 +1,40 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ClickSelectsDiffLine = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Clicking a line of the main view's diff focuses the view and selects that line",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused()
// The click both focuses the view and points at a line, so that line is selected
// rather than the first change.
t.Views().Main().
Click(0, 4).
IsFocused().
SelectionIsActive().
SelectedLines(
Contains("@@ -1,5 +1,5 @@"),
).
// A click in the already-focused view moves the selection to the clicked line.
Click(0, 7).
SelectedLines(
Contains("-three"),
)
},
})
@@ -0,0 +1,43 @@
package main_view
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DragRangeWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Keep scrolling while dragging a range selection at the edge of the focused main view",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
fileContent := "base\n"
shell.CreateFileAndAdd("file1", fileContent)
shell.Commit("one")
for i := 1; i <= 40; i++ {
fileContent += fmt.Sprintf("line %d\n", i)
}
shell.UpdateFile("file1", fileContent)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// The diff is longer than the view, so holding the pointer at the bottom edge has
// to keep scrolling and extending the selection rather than stopping there.
t.Views().Main().
IsFocused().
ClickAndHold(1, 6).
MouseMoveToBottom(1).
OriginYAtLeast(3).
SelectedLineIdxAtLeast(9).
MouseRelease()
},
})
@@ -0,0 +1,55 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DragSelectsDiffLineRange = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Dragging in the main view's diff selects the range from the line the drag started on",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nFOUR\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// Hunk mode is on, so the mouse-down alone selects the whole block; the drag
// anchors the range where the mouse went down instead, one line at a time.
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
Contains("-four"),
Contains("+THREE"),
Contains("+FOUR"),
).
ClickAndHold(0, 8).
MouseMove(0, 9).
SelectedLines(
Contains("-four"),
Contains("+THREE"),
).
MouseMove(0, 10).
SelectedLines(
Contains("-four"),
Contains("+THREE"),
Contains("+FOUR"),
).
MouseRelease().
SelectedLines(
Contains("-four"),
Contains("+THREE"),
Contains("+FOUR"),
)
},
})
@@ -0,0 +1,45 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var EditSelectedDiffLine = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Open the selected line of the main view's diff in the editor, at that line of the file",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command"
config.GetUserConfig().OS.Edit = "echo {{filename}} > edit-command"
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// The addition is the third line of the file as it now stands.
t.Views().Main().
IsFocused().
NavigateToLine(Contains("+THREE")).
Press(keys.Universal.Edit)
// The editor is pointed at the file by absolute path.
t.FileSystem().FileContent("edit-command", Contains("/repo/file1:3\n"))
// A file header points at the file rather than at a line in it, so it opens the
// file with no line to jump to.
t.Views().Main().
NavigateToLine(Contains("diff --git a/file1 b/file1")).
Press(keys.Universal.Edit)
t.FileSystem().FileContent("edit-command", Contains("/repo/file1\n"))
},
})
@@ -0,0 +1,59 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var EscapeDismissesSelection = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Escape gives up a range selection, then hunk mode, before leaving the focused main view",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\nTWO\nTHREE\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// A sticky range: escape collapses it to the cursor line rather than leaving.
t.Views().Main().
IsFocused().
Press(keys.Universal.ToggleRangeSelect).
Press(keys.Universal.NextItem).
SelectedLines(
Contains("-two"),
Contains("-three"),
).
Press(keys.Universal.Return).
IsFocused().
SelectedLines(
Contains("-three"),
).
// Hunk mode the user asked for: escape goes back to line-by-line.
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-two"),
Contains("-three"),
Contains("+TWO"),
Contains("+THREE"),
).
Press(keys.Universal.Return).
IsFocused().
SelectedLines(
Contains("-two"),
).
// With nothing left to give up, escape leaves.
Press(keys.Universal.Return)
t.Views().Files().
IsFocused()
},
})
@@ -0,0 +1,88 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FileNavigationScrollsToTheTop = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Going to a file of a diff brings it to the top of the view, unless it is on screen already",
ExtraCmdArgs: []string{},
Skip: false,
Width: 100,
Height: 20,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
// More context than fits on screen, so that a long file's change is further
// down than a screenful from the header naming the file.
config.GetUserConfig().Git.DiffContextSize = 30
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 100)
for i := range lines {
lines[i] = fmt.Sprintf("line%03d", i+1)
}
long := strings.Join(lines, "\n") + "\n"
longChanged := strings.Replace(long, "line100", "LINE100", 1)
// Two long files with two short ones between them: the short ones are on screen
// together, and there is enough diff below them to scroll to.
shell.CreateFileAndAdd("aaa.txt", long)
shell.CreateFileAndAdd("bbb.txt", "one\ntwo\nthree\n")
shell.CreateFileAndAdd("ccc.txt", "one\ntwo\nthree\n")
shell.CreateFileAndAdd("ddd.txt", long)
shell.Commit("first commit")
shell.UpdateFileAndAdd("aaa.txt", longChanged)
shell.UpdateFileAndAdd("bbb.txt", "one\nTWO\nthree\n")
shell.UpdateFileAndAdd("ccc.txt", "one\nTWO\nthree\n")
shell.UpdateFileAndAdd("ddd.txt", longChanged)
shell.Commit("second commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Press(keys.Universal.FocusMainView)
// A file below the viewport becomes the top of it, so that as much of the file
// as possible is on screen.
t.Views().Main().
IsFocused().
SelectionIsActive().
Press(keys.Main.NextFile).
TopVisibleLine(Contains("diff --git a/bbb.txt b/bbb.txt")).
SelectedLines(
Contains("diff --git a/bbb.txt b/bbb.txt"),
).
// The next file is on screen already, so the view stays where it is and
// only the selection moves.
Press(keys.Main.NextFile).
TopVisibleLine(Contains("diff --git a/bbb.txt b/bbb.txt")).
SelectedLines(
Contains("diff --git a/ccc.txt b/ccc.txt"),
).
// Going back to a file above the viewport brings that one to the top.
Press(keys.Main.PrevFile).
Press(keys.Main.PrevFile).
TopVisibleLine(Contains("diff --git a/aaa.txt b/aaa.txt")).
SelectedLines(
Contains("diff --git a/aaa.txt b/aaa.txt"),
).
// In hunk mode the selection is the file's first change rather than the row
// the file begins at, and with this much context that change is further down
// than a screenful. The selection has to be on screen, so the alignment gives
// way and the selection is scrolled into view as any other jump's is.
Press(keys.Main.ToggleSelectHunk).
Press(keys.Main.NextFile).
Press(keys.Main.NextFile).
Press(keys.Main.NextFile).
SelectedLines(
Contains("-line100"),
Contains("+LINE100"),
).
SelectedLineIsVisible()
},
})
@@ -0,0 +1,47 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var HideSelectionWhenChangesVanish = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The main view's selection disappears along with the changes it was on",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsActive().
Press(keys.Universal.Return)
// Discarding the change leaves the main view with a placeholder to show, so the
// selection that was on the change goes with it rather than lingering over the
// message.
t.Views().Files().
IsFocused().
Press(keys.Universal.Remove).
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Discard changes")).
Select(Contains("Discard all changes")).
Confirm()
}).
IsEmpty()
t.Views().Main().
Content(Contains("No changed files")).
SelectionIsHidden()
},
})
@@ -0,0 +1,58 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepSelectionVisibleWhenDiffShrinks = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The selection stays on the content when a re-render leaves the diff with fewer lines than the selection was on",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Command: "cat"},
// The same diff in fewer lines, as a renderer that collapses or elides
// parts of it would give us: the addition at the end goes, and the hunk
// header says so, since a diff that contradicts its own header can't be
// read as one. (It has to read all of its input: one that exits early
// leaves the render looking like it is still loading, which holds off the
// clamping this test is about.)
{Name: "shrinking", Command: `sed -e 's/@@ -1,5 +1,5 @@/@@ -1,5 +1,4 @@/' -e '$d'`},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nFIVE\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Universal.GotoBottom).
SelectedLines(
Contains("+FIVE"),
).
Press(keys.Universal.CycleDiffRenderers).
Tap(func() {
t.ExpectToast(Equals("Diff renderer: shrinking (2 of 2)"))
})
// The selection has nowhere to be but the last line there is. Asserting on the
// index first waits for that to happen: the re-render and the clamp that
// follows it are a frame apart, and reading the selected line's text in
// between would be reading past the content.
t.Views().Main().
SelectionIsActive().
SelectedLineIdx(10).
SelectedLines(
Contains("-five"),
)
},
})
@@ -0,0 +1,88 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var NavigateByHunkAndFile = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Jump from hunk to hunk and from file to file in the focused main view of a commit's diff",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
shell.CreateFileAndAdd("file2", "one\ntwo\nthree\n")
shell.Commit("one")
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n")
shell.UpdateFileAndAdd("file2", "one\ntwo\nTHREE\n")
shell.Commit("two")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("two").IsSelected(),
Contains("one"),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
).
// Hunk navigation moves between change blocks. A hunk, in lazygit's sense,
// is a run of changes bounded by context; one @@ hunk may hold several.
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-nine"),
).
Press(keys.Main.PrevHunk).
SelectedLines(
Contains("-three"),
).
// File navigation lands on the top of the next file's diff, which for a
// parseable diff is its header.
Press(keys.Main.NextFile).
SelectedLines(
Contains("diff --git a/file2 b/file2"),
).
Press(keys.Main.NextFile).
SelectedLines(
Contains("diff --git a/file2 b/file2"),
).
Press(keys.Main.PrevFile).
SelectedLines(
Contains("diff --git a/file1 b/file1"),
).
// A range that only grows while shift is held is a plain selection again once
// we jump elsewhere, rather than stretching to wherever we land.
Press(keys.Main.NextHunk).
Press(keys.Universal.RangeSelectDown).
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-nine"),
).
// A sticky range does stretch to it.
Press(keys.Universal.ToggleRangeSelect).
Press(keys.Main.PrevHunk).
SelectedLines(
Contains("-three"),
Contains("+THREE"),
Contains(" four"),
Contains(" five"),
Contains(" six"),
Contains(" seven"),
Contains(" eight"),
Contains("-nine"),
)
},
})
@@ -0,0 +1,36 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var NoSelectionOverABinaryDiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A diff with nothing selectable in it shows no selection, and a refresh doesn't bring one",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("binary", "\x00one\x00two\x00")
shell.Commit("one")
shell.UpdateFile("binary", "\x00one\x00TWO\x00")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// git says only that the file differs, so there is nothing to select — and a
// refresh, which renders the same diff again, doesn't make one appear.
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsHidden().
Tap(func() {
t.GlobalPress(keys.Universal.Refresh)
}).
SelectionIsHidden()
},
})
@@ -0,0 +1,48 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var NoSelectionOverACommitLog = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view over a branch's commit log shows no selection, even for a log too long to be read in one go",
ExtraCmdArgs: []string{},
Skip: false,
// A short terminal, so that the log below is longer than the initial read of it.
Width: 100,
Height: 20,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(60)
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("add file1")
shell.UpdateFile("file1", "one\ntwo modified\nthree\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// Leave a selection behind in the main view, on the file's diff.
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsActive().
PressEscape()
t.Views().Branches().
Focus()
t.Views().Main().
Content(Contains("commit-60"))
// A commit log holds nothing to point at, so focusing it shows no selection —
// not even the one the pane was left with under the files panel.
t.Views().Branches().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsHidden()
},
})
@@ -0,0 +1,31 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var NoSelectionWhenNoChanges = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view when there are no changes shows no selection, and navigating doesn't conjure one",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
IsEmpty().
Press(keys.Universal.FocusMainView)
// There's nothing to act on, so the placeholder is shown with no selection — and
// a navigation key just scrolls rather than conjuring one.
t.Views().Main().
IsFocused().
Content(Contains("No changed files")).
SelectionIsHidden().
Press(keys.Universal.GotoTop).
SelectionIsHidden()
},
})
@@ -0,0 +1,59 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RangeSelectDiffLines = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Select a range of diff lines in the focused main view, both sticky and with shift",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nFOUR\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// A sticky range is extended by the plain arrow keys, and pressing the key again
// collapses it back to the cursor line.
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
).
Press(keys.Universal.ToggleRangeSelect).
Press(keys.Universal.NextItem).
SelectedLines(
Contains("-three"),
Contains("-four"),
).
Press(keys.Universal.ToggleRangeSelect).
SelectedLines(
Contains("-four"),
).
// A non-sticky range only grows while shift is held, and a plain move
// collapses it again.
Press(keys.Universal.RangeSelectDown).
SelectedLines(
Contains("-four"),
Contains("+THREE"),
).
Press(keys.Universal.RangeSelectUp).
SelectedLines(
Contains("-four"),
).
Press(keys.Universal.NextItem).
SelectedLines(
Contains("+THREE"),
)
},
})
@@ -0,0 +1,51 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SearchCollapsesTheSelection = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Searching the focused main view leaves a single line selected at the match, whatever was selected before",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
// The match is in another block entirely, so the hunk selection the cursor
// has just left goes with it.
FilterOrSearch("NINE").
SelectedLines(
Contains("+NINE"),
).
// And the same for a hunk selected while a search is on: the next match is
// not part of it either.
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-nine"),
Contains("+NINE"),
).
Press(keys.Universal.NextMatch).
SelectedLines(
Contains("+NINE"),
)
},
})
@@ -0,0 +1,44 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SearchFollowsTheSelection = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Stepping to the next match in the focused main view carries on from the selection",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "")
shell.Commit("one")
shell.UpdateFile("file1", "NEEDLE a\ntwo\nNEEDLE b\nfour\nNEEDLE c\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
FilterOrSearch("NEEDLE").
SelectedLines(Contains("+NEEDLE a")).
Tap(func() {
t.Views().Search().Content(Contains("matches for 'NEEDLE' (1 of 3)"))
}).
// Move the selection past the second match by hand.
SelectNextItem().
SelectNextItem().
SelectNextItem().
SelectedLines(Contains("+four")).
Tap(func() {
t.Views().Search().Content(Contains("matches for 'NEEDLE' (2 of 3)"))
}).
// So the next match is the one after where the selection is, not the one
// after the match it was last on.
Press(keys.Universal.NextMatch).
SelectedLines(Contains("+NEEDLE c"))
},
})
@@ -0,0 +1,64 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectBelowALongCommitMessage = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view over a commit whose diff begins below a very long commit message still shows a selection",
ExtraCmdArgs: []string{},
Skip: false,
// A short terminal, so that the message below is longer than a render is asked to
// read, and the diff under it is only reached by reading on.
Width: 100,
Height: 20,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("first commit")
body := make([]string, 1000)
for i := range body {
body[i] = fmt.Sprintf("message line %d", i+1)
}
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
shell.Commit("a commit with a great deal to say\n\n" + strings.Join(body, "\n"))
shell.EmptyCommit("nothing to see here")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// A commit with nothing to select leaves the pane showing no selection.
t.Views().Commits().
Focus().
Lines(
Contains("nothing to see here").IsSelected(),
Contains("a commit with a great deal to say"),
Contains("first commit"),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsHidden().
PressEscape()
// The commit below it has a change, a thousand lines further down than a render
// reads by itself. The pane reads on until it knows, rather than taking the
// answer from the commit before it or waiting for the user to scroll.
t.Views().Commits().
IsFocused().
SelectNextItem().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
// The change is in the pane, a thousand lines below where the render stopped
// reading of its own accord.
Content(Contains("-two")).
SelectionIsActive()
},
})
@@ -0,0 +1,61 @@
package main_view
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectBelowALongDiffstat = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view over a commit whose diff begins below a long diffstat still shows a selection",
ExtraCmdArgs: []string{},
Skip: false,
// A short terminal, so that the diffstat below fills more than the screenful the
// first paint reveals, and the diff itself is longer than the initial read of it.
Width: 100,
Height: 20,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
for i := range 40 {
shell.CreateFileAndAdd(fmt.Sprintf("file%02d", i+1), "one\ntwo\nthree\n")
}
shell.Commit("first commit")
for i := range 40 {
shell.UpdateFileAndAdd(fmt.Sprintf("file%02d", i+1), "one\nTWO\nthree\n")
}
shell.Commit("touch every file")
shell.EmptyCommit("nothing to see here")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// A commit with nothing to select leaves the pane showing no selection.
t.Views().Commits().
Focus().
Lines(
Contains("nothing to see here").IsSelected(),
Contains("touch every file"),
Contains("first commit"),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsHidden().
PressEscape()
// The commit below it has plenty to select, even though none of it is among
// the diffstat the first paint shows.
t.Views().Commits().
IsFocused().
SelectNextItem()
t.Views().Main().Content(Contains("40 files changed"))
t.Views().Commits().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsActive()
},
})
@@ -0,0 +1,54 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectDiffLines = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view selects the first visible change line, and the arrow keys move the selection",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Contains("file1").IsSelected(),
).
Press(keys.Universal.FocusMainView)
// The selection starts on the first change line rather than at the top of the
// diff, so that it lands on something to act on without the view jumping.
t.Views().Main().
IsFocused().
SelectionIsActive().
SelectedLines(
Contains("-three"),
).
Press(keys.Universal.NextItem).
SelectedLines(
Contains("+THREE"),
).
Press(keys.Universal.PrevItem).
SelectedLines(
Contains("-three"),
).
Press(keys.Universal.GotoTop).
SelectedLines(
Contains("diff --git a/file1 b/file1"),
).
Press(keys.Universal.Return)
t.Views().Files().
IsFocused()
},
})
@@ -0,0 +1,37 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectHunkBelowLastChange = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Toggling hunk selection while below the last change selects the last change block",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\nTWO\nthree\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// Below the last change there is no block ahead to select, so hunk mode takes
// the one behind rather than doing nothing.
t.Views().Main().
IsFocused().
NavigateToLine(Contains(" five")).
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-two"),
Contains("+TWO"),
)
},
})
@@ -0,0 +1,59 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectHunkInDiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Toggle hunk selection in the focused main view, and step from hunk to hunk",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nFOUR\nfive\nsix\nseven\neight\nNINE\nten\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// Hunk mode widens the selection to the whole change block around the cursor —
// which is lazygit's notion of a hunk, so the two changed lines and their
// replacements are one block, and the isolated change further down is another.
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
).
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-three"),
Contains("-four"),
Contains("+THREE"),
Contains("+FOUR"),
).
// In hunk mode the arrow keys step from block to block rather than by line.
Press(keys.Universal.NextItem).
SelectedLines(
Contains("-nine"),
Contains("+NINE"),
).
Press(keys.Universal.PrevItem).
SelectedLines(
Contains("-three"),
Contains("-four"),
Contains("+THREE"),
Contains("+FOUR"),
).
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-three"),
)
},
})
@@ -0,0 +1,67 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectHunkOnFocusingMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "When hunk mode is the default, focusing the main view selects the first whole change block",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// No key press needed: the whole block is selected just by focusing.
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
// Hunk mode being the configured default, it isn't something escape gives up:
// escape leaves the view.
Press(keys.Universal.Return)
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
// A click on a change line keeps hunk mode and selects that line's block.
Click(0, 14).
SelectedLines(
Contains("-nine"),
Contains("+NINE"),
).
// A click inside the selected block collapses hunk mode to that line.
Click(0, 15).
SelectedLines(
Contains("+NINE"),
).
// Switch back to hunk mode so the context click below proves that it gives
// hunk mode up, rather than merely keeping line mode.
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-nine"),
Contains("+NINE"),
).
// A click on a context line points at it precisely, so it selects that line.
Click(0, 12).
SelectedLines(
Contains(" seven"),
)
},
})
@@ -0,0 +1,36 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectInADiffReadInPart = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view over a diff longer than the part of it that has been read still shows a selection",
ExtraCmdArgs: []string{},
Skip: false,
// A short terminal, so that the file below is longer than the initial read of its diff.
Width: 100,
Height: 20,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
lines := make([]string, 600)
for i := range lines {
lines[i] = fmt.Sprintf("line%03d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one big commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsActive()
},
})
@@ -0,0 +1,43 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectLineWhenWholeFileIsOneHunk = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Hunk mode falls back to a single line for a file that is one solid block of changes, rather than selecting all of it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
shell.CreateFileAndAdd("added", "one\ntwo\nthree\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Contains("added").IsSelected(),
).
Press(keys.Universal.FocusMainView)
// Every line of the file is an addition, so widening to the change block would
// select the file entire; hunk mode gives way to a single line.
t.Views().Main().
IsFocused().
SelectedLines(
Contains("+one"),
).
// Toggling hunk mode on explicitly still selects the whole block: the fallback
// is about what the default does, not about forbidding the selection.
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("+one"),
Contains("+two"),
Contains("+three"),
)
},
})
@@ -0,0 +1,77 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectVisibleChangeOnFocusingMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view selects a change that is already on screen, leaving the diff where it is",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
// Enough context around each change to scroll into a stretch of the diff that
// holds none.
cfg.GetUserConfig().Git.DiffContextSize = 20
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 60)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
lines[4] = strings.ToUpper(lines[4])
lines[54] = strings.ToUpper(lines[54])
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain)
t.Views().Main().
OriginY(4).
Tap(func() {
t.Views().Files().Press(keys.Universal.FocusMainView)
}).
IsFocused().
// The first change is on screen, so it is the one to point at — and the view
// hasn't moved to point at it.
SelectedLines(
Contains("-line05"),
).
OriginY(4).
PressEscape()
t.Views().Files().
IsFocused().
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain)
t.Views().Main().
OriginY(12).
Tap(func() {
t.Views().Files().Press(keys.Universal.FocusMainView)
}).
IsFocused().
// Now the whole screen is context: the changes are above and below it. The
// selection goes to the middle of what is on screen rather than to a change
// the user would have to be scrolled to.
SelectedLines(
Contains(" line19"),
).
SelectedLineIdx(24).
OriginY(12)
},
})
@@ -0,0 +1,93 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectVisibleHunkOnFocusingMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view in hunk mode picks a block that begins on screen, leaving the diff where it is",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = true
// One line per scroll, so that the test can put the top of the view exactly
// where it wants it, and enough context to scroll about within one hunk.
cfg.GetUserConfig().Gui.ScrollHeight = 1
cfg.GetUserConfig().Git.DiffContextSize = 20
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 80)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
for _, i := range []int{10, 20, 70} {
lines[i-1] = strings.ToUpper(lines[i-1])
}
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
scrollDown := func(lines int) {
for range lines {
t.Views().Files().Press(keys.Universal.ScrollDownMain)
}
}
t.Views().Files().IsFocused()
// The top of the view is the second line of the first change block, so that
// block is only half on screen; the second one begins below it, in full.
scrollDown(15)
t.Views().Main().
OriginY(15).
Tap(func() {
t.Views().Files().Press(keys.Universal.FocusMainView)
}).
IsFocused().
SelectedLines(
Contains("-line20"),
Contains("+LINE20"),
).
OriginY(15).
PressEscape()
// Now nothing begins on screen: the second block starts just above the top and
// the third change is far below. Only the half-visible block is on screen, so
// it is selected, with its first line off screen, since the view stays put.
scrollDown(11)
t.Views().Main().
OriginY(26).
Tap(func() {
t.Views().Files().Press(keys.Universal.FocusMainView)
}).
IsFocused().
SelectedLines(
Contains("-line20"),
Contains("+LINE20"),
).
SelectedLineIdx(25).
OriginY(26).
PressEscape()
// And a click inside a block that begins above the viewport selects the whole
// block without pulling the view up to its start either.
t.Views().Main().
Click(0, 0).
IsFocused().
SelectedLines(
Contains("-line20"),
Contains("+LINE20"),
).
OriginY(26)
},
})
@@ -0,0 +1,58 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectionCommandsOnlyWhereTheyApply = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The keybindings menu offers the commands that act on a diff selection only in a main view that has one",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("one")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// A branch's commit log has nothing to select, so the commands that act on a
// selection have no business being listed there.
t.Views().Branches().
Focus().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
t.Views().Menu().
Content(DoesNotContain("Select hunks")).
Content(DoesNotContain("Toggle range select")).
Content(DoesNotContain("Go to next hunk")).
Content(DoesNotContain("Go to next file"))
}).
Cancel()
// A diff view does list them, and with nothing changed to select they're
// offered but disabled.
t.Views().Files().
Focus().
IsEmpty().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsHidden().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Select(Contains("Select hunks")).
Confirm()
t.ExpectToast(Contains("There is nothing to select here"))
},
})
@@ -0,0 +1,47 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectionOverTheCustomPatch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The pane showing a custom patch keeps its selection across a refresh, its content being a diff like any other",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("one")
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
shell.Commit("two")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
NavigateToLine(Contains("two")).
PressEnter()
t.Views().CommitFiles().
IsFocused().
PressPrimaryAction().
Press(keys.Universal.FocusMainView)
// The patch built from the commit is shown in the other pane, and it is a diff,
// so it has a selection of its own — one that a refresh doesn't take away.
t.Views().Main().
IsFocused().
Press(keys.Universal.TogglePanel)
t.Views().Secondary().
IsFocused().
Title(Equals("Custom patch")).
SelectionIsActive().
Tap(func() {
t.GlobalPress(keys.Universal.Refresh)
}).
SelectionIsActive()
},
})
+28
View File
@@ -18,6 +18,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/integration/tests/filter_by_author"
"github.com/jesseduffield/lazygit/pkg/integration/tests/filter_by_path"
"github.com/jesseduffield/lazygit/pkg/integration/tests/interactive_rebase"
"github.com/jesseduffield/lazygit/pkg/integration/tests/main_view"
"github.com/jesseduffield/lazygit/pkg/integration/tests/misc"
"github.com/jesseduffield/lazygit/pkg/integration/tests/patch_building"
"github.com/jesseduffield/lazygit/pkg/integration/tests/reflog"
@@ -363,6 +364,33 @@ var tests = []*components.IntegrationTest{
interactive_rebase.SwapInRebaseWithConflictAndEdit,
interactive_rebase.SwapWithConflict,
interactive_rebase.ViewFilesOfTodoEntries,
main_view.ClickSelectsDiffLine,
main_view.DragRangeWithAutoscroll,
main_view.DragSelectsDiffLineRange,
main_view.EditSelectedDiffLine,
main_view.EscapeDismissesSelection,
main_view.FileNavigationScrollsToTheTop,
main_view.HideSelectionWhenChangesVanish,
main_view.KeepSelectionVisibleWhenDiffShrinks,
main_view.NavigateByHunkAndFile,
main_view.NoSelectionOverABinaryDiff,
main_view.NoSelectionOverACommitLog,
main_view.NoSelectionWhenNoChanges,
main_view.RangeSelectDiffLines,
main_view.SearchCollapsesTheSelection,
main_view.SearchFollowsTheSelection,
main_view.SelectBelowALongCommitMessage,
main_view.SelectBelowALongDiffstat,
main_view.SelectDiffLines,
main_view.SelectHunkBelowLastChange,
main_view.SelectHunkInDiff,
main_view.SelectHunkOnFocusingMainView,
main_view.SelectInADiffReadInPart,
main_view.SelectLineWhenWholeFileIsOneHunk,
main_view.SelectVisibleChangeOnFocusingMainView,
main_view.SelectVisibleHunkOnFocusingMainView,
main_view.SelectionCommandsOnlyWhereTheyApply,
main_view.SelectionOverTheCustomPatch,
misc.ConfirmOnQuit,
misc.CopyConfirmationMessageToClipboard,
misc.CopyToClipboard,
+17 -5
View File
@@ -212,10 +212,22 @@ func (self *ViewBufferManager) StartLoading() {
}
func (self *ViewBufferManager) ReadToEnd(then func()) {
// The reading happens on the task's own goroutine, and the caller hears about
// it through then, so lazygit must not count as idle in between. The task is
// done only once then has returned, because then typically hands its work to
// the UI thread, and that work counts as busy only once it is enqueued.
self.readHoldingATask(-1, then)
}
// ReadLinesAndWait is ReadLines for lines lazygit is itself waiting on, rather than
// reading ahead of the user. It holds a gocui task until they have been read, so
// lazygit doesn't count as idle in the meantime (docs/dev/Busy.md).
func (self *ViewBufferManager) ReadLinesAndWait(totalLines int) {
self.readHoldingATask(totalLines, nil)
}
// readHoldingATask asks the task to have read totalLines lines in total (-1 for all of
// them) and calls then once it has. The reading happens on the task's own goroutine and
// the caller is waiting on the result, so lazygit must not count as idle in between.
// The task is done only once then has returned, because then typically hands its work
// to the UI thread, and that work counts as busy only once it is enqueued.
func (self *ViewBufferManager) readHoldingATask(totalLines int, then func()) {
task := self.newGocuiTask()
answered := func() {
if then != nil {
@@ -224,7 +236,7 @@ func (self *ViewBufferManager) ReadToEnd(then func()) {
task.Done()
}
request := LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: answered}
request := LinesToRead{Total: totalLines, InitialRefreshAfter: -1, Then: answered}
if !self.readRequests.enqueue(request) {
// With no task reading, everything there is to read has been read.
answered()
+28
View File
@@ -2471,6 +2471,34 @@
"l"
]
},
"prevFile": {
"oneOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"type": "array"
}
],
"default": "N"
},
"nextFile": {
"oneOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"type": "array"
}
],
"default": "n"
},
"toggleSelectHunk": {
"oneOf": [
{