[SQUASHED] replace-staging-panels-with-main-view

This commit is contained in:
Stefan Haller
2026-08-21 13:33:15 +02:00
parent ea91639546
commit 52297cd474
274 changed files with 13316 additions and 5439 deletions
+2 -2
View File
@@ -135,7 +135,7 @@ Lazygit is not my fulltime job but it is a hefty part time job so if you want to
### Stage individual lines
Press space on the selected line to stage it, or press `v` to start selecting a range of lines. You can also press `a` to select the entirety of the current hunk.
Press `<enter>` on a changed file to focus its diff in the main view. Press `<space>` on the selected line to stage it, or press `v` to start selecting a range of lines. You can also press `a` to switch to hunk selection mode. When a file has both staged and unstaged changes, use `<tab>` to move between the two diff panes; the same actions stage or unstage the selection depending on the pane.
![stage_lines](../assets/demo/stage_lines-compressed.gif)
@@ -195,7 +195,7 @@ You can create worktrees to have multiple branches going at once without the nee
You can build a custom patch from an old commit and then remove the patch from the commit, split out a new commit, apply the patch in reverse to the index, and more.
In this example we have a redundant comment that we want to remove from an old commit. We hit `<enter>` on the commit to view its files, then `<enter>` on a file to focus the patch, then `<space>` to add the comment line to our custom patch, and then `ctrl+p` to view the custom patch options; selecting to remove the patch from the current commit.
In this example we have a redundant comment that we want to remove from an old commit. We hit `<enter>` on the commit to view its files, then `<enter>` on a file to focus its diff. From there, `<space>` adds the selected comment line to the custom patch and `ctrl+p` opens the custom patch options, where we choose to remove the patch from the original commit.
Learn more in the [Rebase magic Youtube tutorial](https://youtu.be/4XaToVut_hs).
+10 -9
View File
@@ -78,7 +78,7 @@ gui:
# If true, do not show a warning when amending a commit.
skipAmendWarning: false
# If true, do not show a warning when discarding changes in the staging view.
# If true, do not show a warning when discarding changes from a focused diff.
skipDiscardChangeWarning: false
# If true, do not show warning when applying/popping the stash
@@ -148,14 +148,13 @@ gui:
# - 'top': split the window vertically (side panel on top, main view below)
enlargedSideViewLocation: left
# If true, wrap lines in the staging view to the width of the view. This makes
# it much easier to work with diffs that have long lines, e.g. paragraphs of
# If true, wrap lines in focused diffs to the width of the view. This makes it
# much easier to work with diffs that have long lines, e.g. paragraphs of
# markdown text.
wrapLinesInStagingView: true
wrapLinesInDiffView: true
# If true, hunk selection mode will be enabled by default when entering the
# staging view.
useHunkModeInStagingView: true
# If true, hunk selection mode will be enabled by default when focusing a diff.
useHunkModeInDiffView: true
# One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko'
# | 'ru' | 'pt'
@@ -817,6 +816,8 @@ keybinding:
main:
prevHunk: [<left>, h]
nextHunk: [<right>, l]
prevFile: "N"
nextFile: "n"
toggleSelectHunk: a
pickBothHunks: b
editSelectHunk: E
@@ -907,7 +908,7 @@ It is used, for example, when pasting a commit message into the commit message p
## Configuring File Editing
There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file if you invoke it from the staging panel, for example.
There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file when you invoke it from a focused diff.
To tell lazygit which editor to use for the `e` command, the easiest way to do that is to provide an editPreset config, e.g.
@@ -970,7 +971,7 @@ When the selected line gets close to the bottom of the window and you hit down-a
That's the behavior when `gui.scrollOffBehavior` is set to "margin" (the default). If you set `gui.scrollOffBehavior` to "jump", then upon reaching the last line of a view and hitting down-arrow the view will scroll by half a page so that the selection ends up in the middle of the view. This may feel a little jarring because the cursor jumps around when continuously moving down, but it has the advantage that the view doesn't scroll as often.
This setting applies both to all list views (e.g. commits and branches etc), and to the staging view.
This setting applies both to all list views (e.g. commits and branches etc), and to focused diffs.
## Filtering
-1
View File
@@ -31,7 +31,6 @@
* `pkg/gui/keybindings`: Contains code for mapping between keybindings and their labels
* `pkg/gui/mergeconflicts`: Contains code relating to the handling of merge conflicts
* `pkg/gui/modes`: Contains code relating to the state of different modes e.g. cherry picking mode, rebase mode.
* `pkg/gui/patch_exploring`: Contains code relating to the state of patch-oriented views like the staging view.
* `pkg/gui/popup`: Contains code that lets you easily raise popups
* `pkg/gui/presentation`: Contains presentation code i.e. code concerned with rendering content inside views
* `pkg/gui/services/custom_commands`: Contains code related to user-defined custom commands.
+25 -35
View File
@@ -65,7 +65,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open external diff tool (git difftool) | |
| `` <space> `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Enter file / Toggle directory collapsed | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | Toggle file tree view | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -149,7 +149,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | View stash options | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | Stage all | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Stage lines / Collapse directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` <enter> `` | Focus file diff / Collapse directory | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` d `` | Discard | View options for discarding changes to the selected file. |
| `` g `` | View upstream reset options | |
| `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). |
@@ -222,42 +222,18 @@ _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). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Search the current view by text | |
## Main panel (patch building)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` v `` | Toggle range select | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` o `` | Open file | Open file in default application. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <space> `` | Toggle lines in patch | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Exit custom patch builder | |
| `` / `` | Search the current view by text | |
## Main panel (staging)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` v `` | Toggle range select | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | Open file | Open file in default application. |
| `` e `` | Edit file | Open file in external editor. |
| `` <esc> `` | Return to files panel | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <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 | |
| `` c `` | Commit | Commit staged changes. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Commit changes using git editor | |
@@ -327,8 +303,22 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <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 | |
| `` c `` | Commit | Commit staged changes. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Commit changes using git editor | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Search the current view by text | |
## Stash
+31 -41
View File
@@ -114,7 +114,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | 外部差分ツールを開く(git difftool | |
| `` <space> `` | パッチに含めるファイルを切り替え | ファイルがカスタムパッチに含まれるかどうかを切り替えます。https://github.com/jesseduffield/lazygit#rebase-magic-custom-patchesを参照してください。 |
| `` a `` | すべてのファイルを切り替え | コミットのすべてのファイルをカスタムパッチに追加/削除します。https://github.com/jesseduffield/lazygit#rebase-magic-custom-patchesを参照してください。 |
| `` <enter> `` | ファイルに入る / ディレクトリの折りたたみを切り替える | ファイルが選択されている場合、そのファイルに入ってカスタムパッチに個々の行を追加/削除できます。ディレクトリが選択されている場合、ディレクトリを切り替えます。 |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | ファイルツリービューを切り替え | ファイル表示をフラット表示とツリー表示で切り替えます。フラット表示はすべてのファイルパスを一覧で表示し、ツリー表示はディレクトリごとにファイルをグループ化します。<br><br>デフォルトは設定ファイル内の 'gui.showFileTree' キーで変更できます。 |
| `` - `` | すべてのファイルを折りたたむ | ファイルツリー内のすべてのディレクトリを折りたたみます |
| `` = `` | すべてのファイルを展開 | ファイルツリー内のすべてのディレクトリを展開します |
@@ -191,8 +191,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | サイドパネルに戻る | |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## タグ
@@ -244,44 +258,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` 0 `` | メインビューにフォーカス | |
| `` / `` | 現在のビューをテキストでフィルタリング | |
## メインパネル(ステージング)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` v `` | 範囲選択を切り替え | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <esc> `` | ファイルパネルに戻る | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## メインパネル(パッチ作成)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` v `` | 範囲選択を切り替え | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | パッチ内の行を切り替え | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | カスタムパッチビルダーを終了 | |
| `` / `` | 現在のビューをテキストで検索 | |
## メインパネル(マージ中)
| Key | Action | Info |
@@ -304,8 +280,22 @@ _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> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | サイドパネルに戻る | |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## メニュー
+25 -35
View File
@@ -83,8 +83,22 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 검색 시작 | |
## Stash
@@ -161,42 +175,18 @@ _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). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 검색 시작 | |
## 메인 패널 (Patch Building)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` v `` | 드래그 선택 전환 | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` o `` | 파일 닫기 | Open file in default application. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <space> `` | Line(s)을 패치에 추가/삭제 | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Exit custom patch builder | |
| `` / `` | 검색 시작 | |
## 메인 패널 (Staging)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` v `` | 드래그 선택 전환 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | 파일 닫기 | Open file in default application. |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <esc> `` | 파일 목록으로 돌아가기 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | |
@@ -345,7 +335,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open external diff tool (git difftool) | |
| `` <space> `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Toggle all files included in patch | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Enter file to add selected lines to the patch (or toggle directory collapsed) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | 파일 트리뷰로 전환 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -395,7 +385,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | Stash 옵션 보기 | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | 모든 변경을 Staged/unstaged으로 전환 | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Stage individual hunks/lines for file, or collapse/expand for directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` <enter> `` | Stage individual hunks/lines for file, or collapse/expand for directory | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` d `` | View 'discard changes' options | View options for discarding changes to the selected file. |
| `` g `` | View upstream reset options | |
| `` D `` | 초기화 | View reset options for working tree (e.g. nuking the working tree). |
+25 -35
View File
@@ -72,7 +72,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | Bekijk stash opties | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | Toggle staged alle | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Stage individuele hunks/lijnen | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` <enter> `` | Stage individuele hunks/lijnen | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` d `` | Bekijk 'veranderingen ongedaan maken' opties | View options for discarding changes to the selected file. |
| `` g `` | Bekijk upstream reset opties | |
| `` D `` | Resetten | View reset options for working tree (e.g. nuking the working tree). |
@@ -144,7 +144,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open externe diff applicatie (git difftool) | |
| `` <space> `` | Toggle bestand inbegrepen in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Enter bestand om geselecteerde regels toe te voegen aan de patch | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Vouw alle bestanden uit | Vouw alle mappen in de bestandsstructuur uit |
@@ -230,24 +230,22 @@ _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). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
## Patch bouwen
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` v `` | Toggle drag selecteer | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` v `` | Toggle drag selecteer | |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` o `` | Open bestand | Open bestand in standaardapplicatie. |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <space> `` | Voeg toe/verwijder lijn(en) in patch | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Sluit lijn-bij-lijn modus | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. |
| `` w `` | Commit veranderingen zonder pre-commit hook | |
| `` C `` | Commit veranderingen met de git editor | |
| `` <ctrl+f> `` | Find base commit for fixup | Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Start met zoeken | |
## Reflog
@@ -305,26 +303,18 @@ _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). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
## Staging
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` v `` | Toggle drag selecteer | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` v `` | Toggle drag selecteer | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | Open bestand | Open bestand in standaardapplicatie. |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <esc> `` | Ga terug naar het bestanden paneel | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <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 | |
| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. |
| `` w `` | Commit veranderingen zonder pre-commit hook | |
| `` C `` | Commit veranderingen met de git editor | |
+31 -41
View File
@@ -98,8 +98,22 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` <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 | |
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
| `` C `` | Zatwierdź zmiany używając edytora git | |
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Drzewa pracy
@@ -132,22 +146,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <enter> `` | Pokaż commity | |
| `` / `` | Filtruj bieżący widok po tekście | |
## Główny panel (budowanie łatki)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <space> `` | Przełącz linie w łatce | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Wyjdź z budowniczego niestandardowej łatki | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Input prompt
| Key | Action | Info |
@@ -200,8 +198,22 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` <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 | |
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
| `` C `` | Zatwierdź zmiany używając edytora git | |
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Panel główny (scalanie)
@@ -220,28 +232,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | Wróć do panelu plików | |
## Panel główny (zatwierdzanie)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <esc> `` | Wróć do panelu plików | |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. |
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
| `` C `` | Zatwierdź zmiany używając edytora git | |
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Panel potwierdzenia
| Key | Action | Info |
@@ -296,7 +286,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | |
| `` <space> `` | Przełącz plik włączony w łatkę | Przełącz, czy plik jest włączony w niestandardową łatkę. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Przełącz wszystkie pliki | Dodaj/usuń wszystkie pliki commita do niestandardowej łatki. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Wejdź do pliku / Przełącz zwiń katalog | Jeśli plik jest wybrany, wejdź do pliku, aby móc dodawać/usuwać poszczególne linie do niestandardowej łatki. Jeśli wybrany jest katalog, przełącz katalog. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | Przełącz widok drzewa plików | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
+25 -35
View File
@@ -148,7 +148,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Abrir ferramenta de diff externa (git difftool) | |
| `` <space> `` | Alternar entre o arquivo incluído no patch | Alternar se o arquivo está incluído no patch personalizado. Veja https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Alternar todos os arquivos | Adicionar/remover todos os arquivos de commit para atualização personalizada. Consulte https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Insira o arquivo / Alternar diretório recolhido | Se um arquivo estiver selecionado, insira o arquivo para que você possa adicionar/remover linhas individuais no patch personalizado. Se um diretório for selecionado, ative o diretório. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | Alternar exibição de árvore de arquivo | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos |
| `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo |
@@ -234,26 +234,18 @@ _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). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Pesquisar na visualização atual por texto | |
## Painel Principal (preparação)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` v `` | Toggle range select | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` v `` | Toggle range select | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <esc> `` | Retornar ao painel de arquivos | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` E `` | Editar hunk | Editar o local selecionado no editor externo. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` <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 | |
| `` c `` | Commit | Submeter mudanças em staging |
| `` w `` | Fazer commit de alterações sem pré-commit | |
| `` C `` | Enviar alteração usando um editor Git | |
@@ -284,22 +276,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | Retornar ao painel de arquivos | |
## Painel principal (patch build)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` v `` | Toggle range select | |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <space> `` | Alternar linhas no caminho | |
| `` d `` | Remover linhas do commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Sair do construtor de patch personalizado | |
| `` / `` | Pesquisar na visualização atual por texto | |
## Reflog
| Key | Action | Info |
@@ -336,8 +312,22 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` <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 | |
| `` c `` | Commit | Submeter mudanças em staging |
| `` w `` | Fazer commit de alterações sem pré-commit | |
| `` C `` | Enviar alteração usando um editor Git | |
| `` <ctrl+f> `` | Encontrar commit da base para corrigir | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado<br>Veja a documentação:<br><https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Pesquisar na visualização atual por texto | |
## Stash
+26 -36
View File
@@ -73,26 +73,18 @@ _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). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Найти | |
## Главная панель (Индексирование)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` v `` | Переключить выборку перетаскивания | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | Открыть файл | Open file in default application. |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <esc> `` | Вернуться к панели файлов | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` E `` | Изменить эту часть | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Сохранить изменения | Commit staged changes. |
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
| `` C `` | Сохранить изменения с помощью редактора git | |
@@ -105,8 +97,22 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Сохранить изменения | Commit staged changes. |
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
| `` C `` | Сохранить изменения с помощью редактора git | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Найти | |
## Главная панель (Слияние)
@@ -125,22 +131,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | Вернуться к панели файлов | |
## Главная панель (сборка патчей)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` v `` | Переключить выборку перетаскивания | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` o `` | Открыть файл | Open file in default application. |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <space> `` | Добавить/удалить строку(и) для патча | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Выйти из сборщика пользовательских патчей | |
| `` / `` | Найти | |
## Журнал ссылок (Reflog)
| Key | Action | Info |
@@ -304,7 +294,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open external diff tool (git difftool) | |
| `` <space> `` | Переключить файлы включённые в патч | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Переключить все файлы, включённые в патч | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Введите файл, чтобы добавить выбранные строки в патч (или свернуть каталог переключения) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | Переключить вид дерева файлов | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -389,7 +379,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | Просмотреть параметры хранилища | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | Все проиндексированные/непроиндексированные | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Проиндексировать отдельные части/строки для файла или свернуть/развернуть для каталога | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` <enter> `` | Проиндексировать отдельные части/строки для файла или свернуть/развернуть для каталога | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` d `` | Просмотреть параметры «отмены изменении» | View options for discarding changes to the selected file. |
| `` g `` | Просмотреть параметры сброса upstream-ветки | |
| `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). |
+31 -41
View File
@@ -178,7 +178,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | 使用外部差异比较工具(git difftool) | |
| `` <space> `` | 补丁中包含的切换文件 | 切换文件是否包含在自定义补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` a `` | 操作所有文件 | 添加或删除所有提交中的文件到自定义的补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` <enter> `` | 输入文件以将所选行添加到补丁中(或切换目录折叠) | 如果已选择一个文件,则Enter进入该文件,以便您可以向自定义补丁添加/删除单独的行。如果选择了目录,则切换目录。 |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | 切换文件树视图 | 在平面布局和树布局之间切换文件视图。平面布局在单个列表中显示所有文件路径,树布局按目录分组文件。<br><br>可以在配置文件中使用 'gui.showFileTree' 键更改默认设置。 |
| `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 |
| `` = `` | 展开全部文件 | 展开文件树中的全部目录 |
@@ -249,22 +249,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <enter> `` | 查看提交 | |
| `` / `` | 通过文本过滤当前视图 | |
## 构建补丁中
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` v `` | 切换拖动选择 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` o `` | 打开文件 | 使用默认程序打开该文件 |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 添加/移除 行到补丁 | |
| `` d `` | 从提交中移除行 | 从本次提交中移除所选行。此操作会在后台运行交互式变基,因此如果后续提交也修改了这些行,您可能会遇到合并冲突。 |
| `` <esc> `` | 退出逐行模式 | |
| `` / `` | 开始搜索 | |
## 标签
| Key | Action | Info |
@@ -285,8 +269,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到侧边面板 | |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 正在合并
@@ -305,36 +303,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | 查看合并冲突选项 | 查看用于解决合并冲突的选项。 |
| `` <esc> `` | 返回文件面板 | |
## 正在暂存
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` v `` | 切换拖动选择 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` o `` | 打开文件 | 使用默认程序打开该文件 |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <esc> `` | 返回文件面板 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 正常
| Key | Action | Info |
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 向下滚动 | |
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到侧边面板 | |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 状态
+32 -42
View File
@@ -59,30 +59,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <enter> `` | 確認 | |
| `` <esc> `` | 關閉/取消 | |
## 主面板 (補丁生成)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 向 (或從) 補丁中添加/刪除行 | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | 退出自訂補丁建立器 | |
| `` / `` | 搜尋 | |
## 主面板(一般)
| Key | Action | Info |
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 主面板(合併)
@@ -101,28 +99,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | 返回檔案面板 | |
## 主面板(預存)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <esc> `` | 返回檔案面板 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` E `` | 編輯程式碼塊 | Edit selected hunk in external editor. |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 功能表
| Key | Action | Info |
@@ -237,7 +213,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` <space> `` | 切換檔案是否包含在補丁中 | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | 切換所有檔案是否包含在補丁中 | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -345,7 +321,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | 收藏 | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | 檢視收藏選項 | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | 全部預存/取消預存 | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` d `` | 捨棄 | 檢視選中變動進行捨棄復原 |
| `` g `` | 檢視遠端重設選項 | |
| `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). |
@@ -362,8 +338,22 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 狀態
-3
View File
@@ -143,9 +143,6 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes
if integrationTest != nil {
integrationTest.SetupConfig(appConfig)
// Set this to true so that integration tests don't have to explicitly deal with the hunk
// staging hint:
appConfig.GetAppState().DidShowHunkStagingHint = true
// Preserve the changes that the test setup just made to the config, so
// they don't get lost when we reload the config while running the test
+1 -3
View File
@@ -119,9 +119,7 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string {
"prompt": tr.PromptTitle,
"information": tr.InformationTitle,
"main": tr.NormalTitle,
"patchBuilding": tr.PatchBuildingTitle,
"mergeConflicts": tr.MergingTitle,
"staging": tr.StagingTitle,
"menu": tr.MenuTitle,
"search": tr.SearchTitle,
"secondary": tr.SecondaryTitle,
@@ -140,7 +138,7 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string {
}
func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*bindingSection {
excludedViews := []string{"stagingSecondary", "patchBuildingSecondary"}
excludedViews := []string{}
bindingsToDisplay := lo.Filter(bindings, func(binding *types.Binding, _ int) bool {
if lo.Contains(excludedViews, binding.ViewName) {
return false
+9 -2
View File
@@ -135,8 +135,15 @@ func NewGitCommandAux(
rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands)
stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands)
patchBuilder := patch.NewPatchBuilder(cmn.Log,
func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, plain)
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
// A patch is built from git's own diff: what a diff renderer would make of it
// is a picture of it, not something that can be applied.
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, git_commands.DiffModePlain)
},
func() (string, error) {
// Under lazygit's own temp dir, so that it honours the configured location
// and is cleaned up with everything else when we exit.
return os.MkdirTemp(osCommand.GetTempDir(), "custom-patch-")
})
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
bisectCommands := git_commands.NewBisectCommands(gitCommon)
+3 -3
View File
@@ -240,12 +240,12 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj {
return self.cmd.New(cmdArgs)
}
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string) *oscommands.CmdObj {
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string, mode DiffMode) *oscommands.CmdObj {
cmdArgs := NewGitCmd("show").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
Arg("--submodule").
Arg("--color=" + self.diffRendererConfigManager.GetColorArg()).
Arg("--color=" + mode.colorArg(self.diffRendererConfigManager)).
Arg("--stat").
Arg("--decorate").
Arg("-p").
+1 -1
View File
@@ -341,7 +341,7 @@ func TestCommitShowCmdObj(t *testing.T) {
}
instance := buildCommitCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, runner: runner, repoPaths: &repoPaths})
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths).Run())
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths, DiffModeRendered).Run())
runner.CheckForMissingCalls()
})
}
+142 -3
View File
@@ -2,10 +2,128 @@ package git_commands
import (
"fmt"
"os"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/mgutz/str"
)
// metadataHandshake is what a diff renderer that speaks the OSC 1717 protocol emits
// before anything else, to announce that it does: a version-only record, with none of
// the fields a line's record has. See ProbeDiffRendererEmitsMetadata, and gocui's
// escape interpreter for how it is kept off the screen on a real render.
const metadataHandshake = "\x1b]1717"
// ProbeDiffRendererEmitsMetadata reports whether the configured diff renderer states
// which line of which file it is rendering, by running it on empty input and looking
// for the handshake. That is what decides whether a diff the renderer produced can be
// acted on at all, or has to be replaced by git's own when the user wants to act on it
// (see DiffLineHelper.MainViewDiffMode).
//
// Asking rather than watching a real render: the handshake is the renderer's first
// output whatever the diff, so the answer is a property of the renderer, known before
// we render anything — where watching would have to see a diff go by first, and would
// be fooled by a diff with no lines to describe.
//
// No terminal is needed. git only invokes a stdin filter when it thinks it is talking
// to one, but the renderer itself doesn't care: it announces itself whenever OSC1717 is
// set, so it can be run directly with empty input.
func (self *DiffCommands) ProbeDiffRendererEmitsMetadata() bool {
manager := self.diffRendererConfigManager
switch manager.GetDiffRendererType() {
case config.DiffRendererType_StdinFilter:
if command := manager.GetStdinFilterCommand(0); command != "" {
return self.probeEmitsMetadata(self.cmd.NewShell(command, ""))
}
case config.DiffRendererType_ExtDiff:
// An empty command means git's own diff.external config, which picks a driver
// per file through .gitattributes: there is no one renderer to ask, and a single
// diff can be produced by several, so we take it that it says nothing.
if command := manager.GetExternalDiffCommand(3); command != "" {
return self.externalDiffEmitsMetadata(command)
}
case config.DiffRendererType_RawGit:
// git describes only the formats whose output can't be read back as a diff, and
// asked with the renderer's own arguments it answers for exactly the format
// those select: a handshake for a word diff, silence for a unified one. With no
// arguments there is nothing to fall back to anyway, since this already is git's
// own diff.
if args := manager.GetRawGitArgs(); len(args) > 0 {
return self.rawGitEmitsMetadata(args)
}
}
return false
}
// rawGitEmitsMetadata asks git itself, run with the diff renderer's own arguments.
func (self *DiffCommands) rawGitEmitsMetadata(rawGitArgs []string) bool {
oldPath, newPath, cleanup, ok := self.probeFiles()
if !ok {
return false
}
defer cleanup()
return self.probeEmitsMetadata(self.cmd.New(
NewGitCmd("diff").
Arg("--no-index").
Arg(rawGitArgs...).
Arg(oldPath, newPath).
ToArgv(),
))
}
// externalDiffEmitsMetadata asks an external diff command, invoking it the way git
// invokes one — with the seven positional arguments of git's diff.external convention —
// over two empty files, so that it announces itself without having a diff to render.
func (self *DiffCommands) externalDiffEmitsMetadata(externalDiffCommand string) bool {
oldPath, newPath, cleanup, ok := self.probeFiles()
if !ok {
return false
}
defer cleanup()
args := append(str.ToArgv(externalDiffCommand),
"probe", oldPath, "0000000", "100644", newPath, "0000000", "100644")
return self.probeEmitsMetadata(self.cmd.New(args))
}
// probeFiles makes the two empty files a probe stands a diff up from, and the cleanup
// that removes them. Empty, because what the probe wants is for the renderer to announce
// itself, not for it to have anything to say.
func (self *DiffCommands) probeFiles() (string, string, func(), bool) {
tempDir := self.os.GetTempDir()
oldFile, err := os.CreateTemp(tempDir, "lazygit-probe-old-*")
if err != nil {
return "", "", nil, false
}
oldFile.Close()
newFile, err := os.CreateTemp(tempDir, "lazygit-probe-new-*")
if err != nil {
os.Remove(oldFile.Name())
return "", "", nil, false
}
newFile.Close()
return oldFile.Name(), newFile.Name(), func() {
os.Remove(oldFile.Name())
os.Remove(newFile.Name())
}, true
}
func (self *DiffCommands) probeEmitsMetadata(cmdObj *oscommands.CmdObj) bool {
cmdObj.AddEnvVars("OSC1717=V1")
// A renderer may well object to being handed nothing to render; what it said before
// objecting is what we are after, and that is captured either way.
output, _ := cmdObj.RunWithOutput()
return strings.Contains(output, metadataHandshake)
}
type DiffCommands struct {
*GitCommon
}
@@ -18,19 +136,40 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
// This is for generating diffs to be shown in the UI (e.g. rendering a range
// diff to the main view). It uses a custom diff renderer if one is configured.
func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj {
func (self *DiffCommands) DiffCmdObj(diffArgs []string, mode DiffMode) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(diffArgs...).
Dir(self.repoPaths.worktreePath).
ToArgv(),
)
}
// CustomPatchDiffCmdObj is the command that renders the custom patch being built: a diff
// of the two trees the patch was materialized into (PatchCommands.WriteCustomPatchDiffTrees),
// under the directory holding them. It goes through the same wiring as any other diff we
// show, so the patch is rendered by whatever renders the rest of them, and git works out
// how much context to give it.
//
// git's own path prefixes are suppressed because the trees are named a and b themselves,
// which leaves the paths reading like an ordinary diff's over the repo's own paths.
func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, mode DiffMode) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
Arg("--no-index").
Arg("--no-prefix").
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg("a", "b").
Dir(dir).
ToArgv(),
)
}
// This is a basic generic diff command that can be used for any diff operation
// (e.g. copying a diff to the clipboard). It will not use a custom diff renderer,
// and does not use user configs such as ignore whitespace.
+35
View File
@@ -0,0 +1,35 @@
package git_commands
import (
"github.com/jesseduffield/lazygit/pkg/config"
)
// DiffMode says what a diff command's output is for, which is what decides whether the
// configured diff renderer produces it and whether it is coloured.
type DiffMode int
const (
// DiffModeRendered is the diff as the user has arranged for it to look: through the
// diff renderer, with the renderer's own arguments and its preference about colour.
DiffModeRendered DiffMode = iota
// DiffModeRaw is git's own coloured diff, for showing a diff whose rendered form
// couldn't be acted on.
DiffModeRaw
// DiffModePlain is git's own uncoloured diff, which is what patches are built from and
// text is copied out of, rather than anything to look at.
DiffModePlain
)
// colorArg is what to pass to git's --color for this mode. Rendered output is coloured
// however the renderer wants its input; a raw diff is git's own colour, that being the
// point of it; a plain one is for reading as text, not for looking at.
func (self DiffMode) colorArg(diffRendererConfigManager *config.DiffRendererConfigManager) string {
switch self {
case DiffModeRendered:
return diffRendererConfigManager.GetColorArg()
case DiffModeRaw:
return "always"
default:
return "never"
}
}
@@ -123,18 +123,23 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand
return self
}
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder {
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, mode DiffMode) *GitCommandBuilder {
contextSize := userConfig.Git.DiffContextSize
extDiffCmd := diffRendererConfigManager.GetExternalDiffCommand(contextSize)
useExtDiff := forUI && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
useExtDiff := mode == DiffModeRendered && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
return self.
ConfigIf(forUI && extDiffCmd != "", "diff.external="+extDiffCmd).
ConfigIf(mode == DiffModeRendered && extDiffCmd != "", "diff.external="+extDiffCmd).
ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff").
Arg(fmt.Sprintf("--unified=%d", contextSize)).
ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
// Ignoring whitespace is about what the user wants to see, so it holds for a raw
// diff as much as for a rendered one; a plain diff is what a patch is built
// from, where a diff that leaves changes out would apply to nothing.
ArgIf(mode != DiffModePlain && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
Arg(fmt.Sprintf("--find-renames=%d%%", userConfig.Git.RenameSimilarityThreshold)).
ArgIf(forUI, diffRendererConfigManager.GetRawGitArgs()...)
// The renderer's own arguments to git — a word diff, say — are part of the
// rendering, so they go with it.
ArgIf(mode == DiffModeRendered, diffRendererConfigManager.GetRawGitArgs()...)
}
func (self *GitCommandBuilder) ToArgv() []string {
+85
View File
@@ -2,13 +2,16 @@ package git_commands
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/app/daemon"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo"
)
@@ -20,6 +23,10 @@ type PatchCommands struct {
stash *StashCommands
PatchBuilder *patch.PatchBuilder
// The version of the patch the diff trees were last written for, so that they are
// written again when, and only when, the patch has changed since.
treesWrittenForGeneration int
}
func NewPatchCommands(
@@ -40,6 +47,84 @@ func NewPatchCommands(
}
}
// EnsureCustomPatchDiffTrees writes the custom patch's diff trees if what is there no
// longer describes the patch. Call it before rendering the patch, which is often — every
// time the panel showing it re-renders — while the patch itself changes rarely.
func (self *PatchCommands) EnsureCustomPatchDiffTrees() error {
if self.PatchBuilder.Generation() == self.treesWrittenForGeneration {
return nil
}
if err := self.WriteCustomPatchDiffTrees(); err != nil {
return err
}
self.treesWrittenForGeneration = self.PatchBuilder.Generation()
return nil
}
// WriteCustomPatchDiffTrees materializes the custom patch as two file trees under the
// directory the patch builder keeps for it: `a` holds each of the patch's files as it is
// before the patch, `b` as it is after. Diffing those two trees against each other
// (DiffCommands.CustomPatchDiffCmdObj) turns the patch into a diff of real files, which
// can then be rendered exactly as any other diff is — through a diff renderer of any
// kind, and with git's own idea of how much context to show.
//
// The trees are named a and b so that the diff's paths, with git's own prefixes
// suppressed, come out reading like the a/ and b/ of an ordinary diff, over the real
// repo-relative paths.
func (self *PatchCommands) WriteCustomPatchDiffTrees() error {
dir := self.PatchBuilder.TempDir()
if dir == "" {
return nil
}
before := filepath.Join(dir, "a")
after := filepath.Join(dir, "b")
for _, tree := range []string{before, after} {
if err := os.RemoveAll(tree); err != nil {
return err
}
if err := os.MkdirAll(tree, 0o700); err != nil {
return err
}
}
for _, file := range self.PatchBuilder.FilesInPatch() {
content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, file.SourcePath).RunWithOutput()
// A file the patch adds has no content on the before side, so git has nothing to
// show for it.
added := err != nil
// The before side holds an added file as an empty file rather than not at all, so
// that the diff pairs the two sides up and states the file's real path, instead of
// reporting a file that only one of the trees has.
if err := self.os.CreateFileWithContent(filepath.Join(before, file.SourcePath),
lo.Ternary(added, "", content)); err != nil {
return err
}
// The after side is seeded with the same content, for the patch to change; a file
// the patch adds is left absent, for the patch to create.
if !added {
if err := self.os.CreateFileWithContent(filepath.Join(after, file.SourcePath), content); err != nil {
return err
}
}
}
// Added files as the creations they are, rather than as diffs against an empty file:
// the patch is applied in one go, so a file it expects to be there already would make
// the whole of it fail.
patchText := self.PatchBuilder.PatchToApply(false, false)
if strings.TrimSpace(patchText) == "" {
// Nothing in the patch, so the two trees are alike and the diff is empty.
return nil
}
patchFilePath, err := self.SaveTemporaryPatch(patchText)
if err != nil {
return err
}
return self.cmd.New(NewGitCmd("apply").Arg(patchFilePath).Dir(after).ToArgv()).Run()
}
type ApplyPatchOpts struct {
ThreeWay bool
Cached bool
+3 -3
View File
@@ -80,14 +80,14 @@ func (self *StashCommands) Hash(index int) (string, error) {
return strings.Trim(hash, "\r\n"), err
}
func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj {
func (self *StashCommands) ShowStashEntryCmdObj(index int, mode DiffMode) *oscommands.CmdObj {
// "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason
cmdArgs := NewGitCmd("stash").Arg("show").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
Arg("-p").
Arg("--stat").
Arg("-u").
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(fmt.Sprintf("refs/stash@{%d}", index)).
Dir(self.repoPaths.worktreePath).
ToArgv()
+1 -1
View File
@@ -174,7 +174,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
}
instance := buildStashCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
cmdStr := instance.ShowStashEntryCmdObj(s.index).Args()
cmdStr := instance.ShowStashEntryCmdObj(s.index, DiffModeRendered).Args()
assert.Equal(t, s.expected, cmdStr)
})
}
+10 -20
View File
@@ -383,9 +383,9 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
}
// WorktreeFileDiff returns the diff of a file
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, mode DiffMode, cached bool) string {
// for now we assume an error means the file was deleted
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, file.Names()).RunWithOutput()
s, _ := self.WorktreeFileDiffCmdObj(file, mode, cached, file.Names()).RunWithOutput()
return s
}
@@ -393,18 +393,13 @@ func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool,
// in the working tree. node is the item they belong to; all it decides is
// whether git has to compare against /dev/null, which is the case for a file
// that isn't in the index yet.
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, paths []string) *oscommands.CmdObj {
colorArg := self.diffRendererConfigManager.GetColorArg()
if plain {
colorArg = "never"
}
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, mode DiffMode, cached bool, paths []string) *oscommands.CmdObj {
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
cmdArgs := NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", colorArg)).
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
ArgIf(cached, "--cached").
ArgIf(noIndex, "--no-index").
Arg("--").
@@ -420,25 +415,20 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
// but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode.
// For a renamed file, previousPath is the path it was renamed from (empty otherwise);
// both paths must be passed to git for the rename to be detected.
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, plain bool) (string, error) {
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, mode DiffMode) (string, error) {
fileNames := []string{fileName}
if previousPath != "" {
fileNames = append(fileNames, previousPath)
}
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain).RunWithOutput()
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, mode).RunWithOutput()
}
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj {
colorArg := self.diffRendererConfigManager.GetColorArg()
if plain {
colorArg = "never"
}
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, mode DiffMode) *oscommands.CmdObj {
cmdArgs := NewGitCmd("diff").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", colorArg)).
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(from).
Arg(to).
ArgIf(reverse, "-R").
+15 -15
View File
@@ -197,7 +197,7 @@ func TestWorkingTreeDiff(t *testing.T) {
type scenario struct {
testName string
file *models.File
plain bool
mode DiffMode
cached bool
ignoreWhitespace bool
contextSize uint64
@@ -215,7 +215,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
plain: false,
mode: DiffModeRendered,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -230,7 +230,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
plain: false,
mode: DiffModeRendered,
cached: true,
ignoreWhitespace: false,
contextSize: 3,
@@ -245,7 +245,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
plain: true,
mode: DiffModePlain,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -260,7 +260,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: false,
},
plain: false,
mode: DiffModeRendered,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -275,7 +275,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
plain: false,
mode: DiffModeRendered,
cached: false,
ignoreWhitespace: true,
contextSize: 3,
@@ -290,7 +290,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
plain: false,
mode: DiffModeRendered,
cached: false,
ignoreWhitespace: false,
contextSize: 17,
@@ -305,7 +305,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
plain: false,
mode: DiffModeRendered,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -326,7 +326,7 @@ func TestWorkingTreeDiff(t *testing.T) {
}
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
result := instance.WorktreeFileDiff(s.file, s.plain, s.cached)
result := instance.WorktreeFileDiff(s.file, s.mode, s.cached)
assert.Equal(t, expectedResult, result)
s.runner.CheckForMissingCalls()
})
@@ -341,7 +341,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
reverse bool
fileName string
previousPath string
plain bool
mode DiffMode
ignoreWhitespace bool
contextSize uint64
runner *oscommands.FakeCmdObjRunner
@@ -356,7 +356,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
to: "0987654321",
reverse: false,
fileName: "test.txt",
plain: false,
mode: DiffModeRendered,
ignoreWhitespace: false,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
@@ -368,7 +368,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
to: "0987654321",
reverse: false,
fileName: "test.txt",
plain: false,
mode: DiffModeRendered,
ignoreWhitespace: false,
contextSize: 123,
runner: oscommands.NewFakeRunner(t).
@@ -380,7 +380,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
to: "0987654321",
reverse: false,
fileName: "test.txt",
plain: false,
mode: DiffModeRendered,
ignoreWhitespace: true,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
@@ -393,7 +393,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
reverse: false,
fileName: "new.txt",
previousPath: "old.txt",
plain: false,
mode: DiffModeRendered,
ignoreWhitespace: false,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
@@ -412,7 +412,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.plain)
result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.mode)
assert.NoError(t, err)
assert.Equal(t, expectedResult, result)
s.runner.CheckForMissingCalls()
+5
View File
@@ -16,6 +16,11 @@ type Hunk struct {
newStart int
// the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example)
headerContext string
// the lengths declared in the header line ('2' and '3' in the above example),
// kept so that we can check the parsed body against them (see
// Patch.IsWellFormed). Only set by Parse.
declaredOldLength int
declaredNewLength int
// the body of the hunk, excluding the header line
bodyLines []*PatchLine
}
+26 -11
View File
@@ -7,7 +7,9 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
)
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
// Captures, in order: the old start, the old length (omitted by git when it is
// 1), the new start, the new length (likewise), and the trailing context.
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`)
func Parse(patchStr string) *Patch {
// ignore trailing newline.
@@ -19,13 +21,15 @@ func Parse(patchStr string) *Patch {
var currentHunk *Hunk
for _, line := range lines {
if strings.HasPrefix(line, "@@") {
oldStart, newStart, headerContext := headerInfo(line)
oldStart, oldLength, newStart, newLength, headerContext := headerInfo(line)
currentHunk = &Hunk{
oldStart: oldStart,
newStart: newStart,
headerContext: headerContext,
bodyLines: []*PatchLine{},
oldStart: oldStart,
newStart: newStart,
declaredOldLength: oldLength,
declaredNewLength: newLength,
headerContext: headerContext,
bodyLines: []*PatchLine{},
}
hunks = append(hunks, currentHunk)
} else if currentHunk != nil {
@@ -41,14 +45,25 @@ func Parse(patchStr string) *Patch {
}
}
func headerInfo(header string) (int, int, string) {
func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLength int, headerContext string) {
match := hunkHeaderRegexp.FindStringSubmatch(header)
oldStart := utils.MustConvertToInt(match[1])
newStart := utils.MustConvertToInt(match[2])
headerContext := match[3]
oldStart = utils.MustConvertToInt(match[1])
oldLength = declaredLength(match[2])
newStart = utils.MustConvertToInt(match[3])
newLength = declaredLength(match[4])
headerContext = match[5]
return oldStart, newStart, headerContext
return oldStart, oldLength, newStart, newLength, headerContext
}
// declaredLength parses a length capture of a hunk header, which git omits when
// it is 1 (e.g. "@@ -0,0 +1 @@").
func declaredLength(match string) int {
if match == "" {
return 1
}
return utils.MustConvertToInt(match)
}
func newHunkLine(line string) *PatchLine {
+45
View File
@@ -79,6 +79,20 @@ func (self *Patch) HunkEndIdx(hunkIndex int) int {
return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1
}
// IsWellFormed reports whether every hunk's body matches the lengths declared in
// its header. A faithful unified diff always satisfies this; a rendering that
// restructured the diff body does not — a diff renderer that puts line numbers in
// a gutter, say, shifts the +/- marker off the start of each line, so every body
// line reads as context and the computed lengths no longer match the header. That
// makes this the test for whether a rendered diff can be parsed as a unified diff
// at all, rather than trusting a mis-parse. Only meaningful for patches produced
// by Parse, which is where the declared lengths come from.
func (self *Patch) IsWellFormed() bool {
return lo.NoneBy(self.hunks, func(hunk *Hunk) bool {
return hunk.oldLength() != hunk.declaredOldLength || hunk.newLength() != hunk.declaredNewLength
})
}
func (self *Patch) ContainsChanges() bool {
return lo.SomeBy(self.hunks, func(hunk *Hunk) bool {
return hunk.containsChanges()
@@ -114,6 +128,37 @@ func (self *Patch) LineNumberOfLine(idx int) int {
return hunk.newStart + offset
}
// Takes a line index in the patch and returns the line number in the old file.
// This is the old-file counterpart of LineNumberOfLine; for a deletion it gives
// the line's position in the old file (additions get the position they sit at).
// If the line is a header line, returns 1.
// If the line is a hunk header line, returns the first old-file line number in that hunk.
// If the line is out of range below, returns the last old-file line number in the last hunk.
func (self *Patch) OldLineNumberOfLine(idx int) int {
if idx < len(self.header) || len(self.hunks) == 0 {
return 1
}
hunkIdx := self.HunkContainingLine(idx)
// cursor out of range, return last file line number
if hunkIdx == -1 {
lastHunk := self.hunks[len(self.hunks)-1]
return lastHunk.oldStart + lastHunk.oldLength() - 1
}
hunk := self.hunks[hunkIdx]
hunkStartIdx := self.HunkStartIdx(hunkIdx)
idxInHunk := idx - hunkStartIdx
if idxInHunk == 0 {
return hunk.oldStart
}
lines := hunk.bodyLines[:idxInHunk-1]
offset := nLinesWithKind(lines, []PatchLineKind{DELETION, CONTEXT})
return hunk.oldStart + offset
}
// Returns hunk index containing the line at the given patch line index
func (self *Patch) HunkContainingLine(idx int) int {
for hunkIdx, hunk := range self.hunks {
+229 -3
View File
@@ -1,10 +1,12 @@
package patch
import (
"os"
"sort"
"strings"
"github.com/jesseduffield/generics/maps"
"github.com/jesseduffield/generics/set"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
@@ -33,7 +35,7 @@ type fileInfo struct {
}
type (
loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error)
loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string) (string, error)
)
// PatchBuilder manages the building of a patch for a commit to be applied to another commit (or the working tree, or removed from the current commit). We also support building patches from things like stashes, for which there is less flexibility
@@ -60,12 +62,27 @@ type PatchBuilder struct {
// loadFileDiff loads the diff of a file, for a given to (typically a commit hash)
loadFileDiff loadFileDiffFunc
// newTempDir makes a directory for the current patch to be materialized into, as
// two file trees that can be diffed against each other and so rendered like any
// other diff (see PatchCommands.WriteCustomPatchDiffTrees). Its lifetime is the
// patch's: made when one is started, removed when it is given up.
newTempDir func() (string, error)
tempDir string
// generation counts the changes made to the patch, so that whoever materializes it
// can tell whether what they last built still describes it — and rebuild only then,
// rather than on every render of it.
generation int
}
func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBuilder {
func NewPatchBuilder(
log *logrus.Entry, loadFileDiff loadFileDiffFunc, newTempDir func() (string, error),
) *PatchBuilder {
return &PatchBuilder{
Log: log,
loadFileDiff: loadFileDiff,
newTempDir: newTempDir,
}
}
@@ -73,6 +90,9 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.generation++
p.makeTempDir()
p.To = to
p.From = from
p.reverse = reverse
@@ -91,6 +111,86 @@ func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo {
return p.fileInfoMap
}
// TempDir is the directory the patch is materialized into for rendering, and "" when
// there is none — no patch, or a directory we failed to make.
func (p *PatchBuilder) TempDir() string {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.tempDir
}
// Generation says which version of the patch this is; see the field.
func (p *PatchBuilder) Generation() int {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.generation
}
// makeTempDir replaces the directory the patch is materialized into with a fresh one.
// Only call this with the lock held.
func (p *PatchBuilder) makeTempDir() {
p.removeTempDir()
if p.newTempDir == nil {
return
}
dir, err := p.newTempDir()
if err != nil {
p.Log.Error(err)
return
}
p.tempDir = dir
}
// removeTempDir takes the patch's materialized form away with the patch. Only call this
// with the lock held.
func (p *PatchBuilder) removeTempDir() {
if p.tempDir == "" {
return
}
if err := os.RemoveAll(p.tempDir); err != nil {
p.Log.Error(err)
}
p.tempDir = ""
}
// PatchFile is what materializing the patch needs to know about one of its files: where
// its content is to be found, and where the patch puts it.
type PatchFile struct {
// Path is the file's path in the commit the patch is built from, which for a renamed
// file is the name it was renamed to.
Path string
// SourcePath is where the patch expects to find the file: the name it had before,
// for a renamed file the patch carries the rename of, and Path for anything else —
// a patch of part of a renamed file's content keeps only that content change, under
// the new name.
SourcePath string
}
// FilesInPatch says which files the patch touches, in a stable order, and where each of
// them comes from.
func (p *PatchBuilder) FilesInPatch() []PatchFile {
fileInfoMap := p.snapshotFileInfoMap()
filenames := maps.Keys(fileInfoMap)
sort.Strings(filenames)
files := make([]PatchFile, 0, len(filenames))
for _, filename := range filenames {
info := fileInfoMap[filename]
if info.mode == UNSELECTED {
continue
}
file := PatchFile{Path: filename, SourcePath: filename}
if info.mode == WHOLE && info.previousPath != "" {
file.SourcePath = info.previousPath
}
files = append(files, file)
}
return files
}
func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string {
var patch strings.Builder
@@ -135,6 +235,7 @@ func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error
return err
}
p.generation++
p.addFileWhole(info)
return nil
@@ -146,6 +247,7 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
return err
}
p.generation++
p.removeFile(info)
return nil
@@ -162,7 +264,7 @@ func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileI
return info, nil
}
diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath, true)
diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath)
if err != nil {
return nil, err
}
@@ -182,6 +284,7 @@ func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, li
if err != nil {
return err
}
p.generation++
info.mode = PART
info.includedLineIndices = lo.Union(info.includedLineIndices, lineIndices)
@@ -193,6 +296,7 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string,
if err != nil {
return err
}
p.generation++
info.mode = PART
info.includedLineIndices, _ = lo.Difference(info.includedLineIndices, lineIndices)
if len(info.includedLineIndices) == 0 {
@@ -291,6 +395,125 @@ func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus
return info.mode
}
// LineIdentity says which change line of a file is meant — the line number it has on
// the side it belongs to, and whether it is a deletion — without reference to where
// that line sits in the file's parsed diff.
//
// It is how a diff shown in the main view speaks about its lines: what a rendered row
// resolves to is a line of a file, while the index of that line in the diff depends on
// how much of the diff is being shown and in what order a renderer laid it out.
type LineIdentity struct {
LineNumber int
IsDeletion bool
}
// ChangeLineIndexByIdentity indexes a parsed diff's change lines by their identity. An
// addition is numbered in the new file and a deletion in the old one, which is what
// keeps two consecutive deletions — one new-file position between them — apart.
func ChangeLineIndexByIdentity(parsed *Patch) map[LineIdentity]int {
byIdentity := map[LineIdentity]int{}
for idx, line := range parsed.Lines() {
switch {
case line.IsAddition():
byIdentity[LineIdentity{parsed.LineNumberOfLine(idx), false}] = idx
case line.IsDeletion():
byIdentity[LineIdentity{parsed.OldLineNumberOfLine(idx), true}] = idx
}
}
return byIdentity
}
// ChangeLineIndicesForLines maps the given change lines of a parsed diff to their
// indices in it. A line that names no change line of the diff — a context line, or a
// line that isn't in the diff at all — contributes nothing.
func ChangeLineIndicesForLines(parsed *Patch, lines []LineIdentity) []int {
byIdentity := ChangeLineIndexByIdentity(parsed)
indices := make([]int, 0, len(lines))
for _, line := range lines {
if idx, ok := byIdentity[line]; ok {
indices = append(indices, idx)
}
}
return indices
}
// PatchLineIndicesForLines maps change lines of filename to their indices in that
// file's diff, which is what the patch is built in terms of.
func (p *PatchBuilder) PatchLineIndicesForLines(
filename string, previousPath string, lines []LineIdentity,
) ([]int, error) {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
return nil, err
}
return ChangeLineIndicesForLines(Parse(info.diff), lines), nil
}
// SelectionRepresentsWholeFile says whether lines select the entirety of a diff that
// consists of one solid block of additions or deletions, with no context. These are
// the added and deleted files whose file operation must travel with their contents.
func (p *PatchBuilder) SelectionRepresentsWholeFile(
filename string, previousPath string, lines []LineIdentity,
) (bool, error) {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
return false, err
}
parsed := Parse(info.diff)
if !parsed.IsSingleHunkForWholeFile() {
return false, nil
}
all := ChangeLineIndexByIdentity(parsed)
selected := set.NewFromSlice(lines)
return lo.EveryBy(maps.Keys(all), func(identity LineIdentity) bool {
return selected.Includes(identity)
}), nil
}
// IncludedLineIdentities says which change lines of filename are in the patch, as the
// identities a diff of that file shown anywhere can be compared against. Empty for a
// file that is no part of the patch.
func (p *PatchBuilder) IncludedLineIdentities(filename string) []LineIdentity {
info, ok := p.snapshotFileInfoMap()[filename]
if !ok || info.mode == UNSELECTED {
return nil
}
included := set.NewFromSlice(info.includedLineIndices)
identities := []LineIdentity{}
for identity, idx := range ChangeLineIndexByIdentity(Parse(info.diff)) {
if included.Includes(idx) {
identities = append(identities, identity)
}
}
return identities
}
// IncludedChangeLineIndices says which of filename's change lines are in the patch, as
// their indices in the file's diff and in the order the file has them.
//
// It is how a line of the patch as it is shown names the line of the diff it came from:
// all that can be said about a line of the patch is which of the file's changes it is,
// its line numbers being the patch's own — a patch that leaves an earlier addition out
// numbers everything after it differently from the diff it was built from.
func (p *PatchBuilder) IncludedChangeLineIndices(filename string) []int {
info, ok := p.snapshotFileInfoMap()[filename]
if !ok || info.mode == UNSELECTED {
return nil
}
included := set.NewFromSlice(info.includedLineIndices)
indices := []int{}
for idx, line := range Parse(info.diff).Lines() {
if (line.IsAddition() || line.IsDeletion()) && included.Includes(idx) {
indices = append(indices, idx)
}
}
return indices
}
func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath string) ([]int, error) {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
@@ -304,6 +527,9 @@ func (p *PatchBuilder) Reset() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.generation++
p.removeTempDir()
p.To = ""
p.fileInfoMap = map[string]*fileInfo{}
}
+122
View File
@@ -0,0 +1,122 @@
package patch
import (
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
// newTestPatchBuilder returns a patch builder started for a dummy commit, in which
// every file's diff is the given one.
func newTestPatchBuilder(diff string) *PatchBuilder {
patchBuilder := NewPatchBuilder(logrus.New().WithField("test", "test"),
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
return diff, nil
},
// Nothing here renders the patch, so it needs no directory to be
// materialized into.
nil)
patchBuilder.Start("from", "to", false, true)
return patchBuilder
}
// In simpleDiff the deletion "-orange" is line index 6 of the parsed diff (line 2 of
// the old file) and the addition "+grape" is index 7 (line 2 of the new file).
func TestPatchLineIndicesForLines(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
indices, err := patchBuilder.PatchLineIndicesForLines("filename", "", []LineIdentity{
{LineNumber: 2, IsDeletion: true}, // -orange
{LineNumber: 2, IsDeletion: false}, // +grape
{LineNumber: 1, IsDeletion: false}, // " apple", a context line
})
assert.NoError(t, err)
assert.Equal(t, []int{6, 7}, indices, "the context line names no change line")
}
// A renamed file's rename header makes its change lines sit further down the diff, and
// its old-file line numbers are of the file under its previous name.
func TestPatchLineIndicesForLinesOfARenamedFile(t *testing.T) {
patchBuilder := newTestPatchBuilder(renameWithModificationDiff)
indices, err := patchBuilder.PatchLineIndicesForLines("newname", "oldname", []LineIdentity{
{LineNumber: 2, IsDeletion: true}, // -orange
{LineNumber: 2, IsDeletion: false}, // +grape
})
assert.NoError(t, err)
assert.Equal(t, []int{9, 10}, indices)
}
func TestSelectionRepresentsWholeFile(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
selected, err := patchBuilder.SelectionRepresentsWholeFile("filename", "", []LineIdentity{
{LineNumber: 2, IsDeletion: true},
{LineNumber: 2, IsDeletion: false},
})
assert.NoError(t, err)
assert.False(t, selected)
patchBuilder = newTestPatchBuilder(newFile)
selected, err = patchBuilder.SelectionRepresentsWholeFile("newfile", "", []LineIdentity{
{LineNumber: 1},
{LineNumber: 2},
})
assert.NoError(t, err)
assert.False(t, selected)
selected, err = patchBuilder.SelectionRepresentsWholeFile("newfile", "", []LineIdentity{
{LineNumber: 1},
{LineNumber: 2},
{LineNumber: 3},
})
assert.NoError(t, err)
assert.True(t, selected)
patchBuilder = newTestPatchBuilder(deletedFile)
selected, err = patchBuilder.SelectionRepresentsWholeFile("newfile", "", []LineIdentity{
{LineNumber: 1, IsDeletion: true},
{LineNumber: 2, IsDeletion: true},
{LineNumber: 3, IsDeletion: true},
})
assert.NoError(t, err)
assert.True(t, selected)
patchBuilder = newTestPatchBuilder(renameWithModificationDiff)
selected, err = patchBuilder.SelectionRepresentsWholeFile("newname", "oldname", []LineIdentity{
{LineNumber: 2, IsDeletion: true},
{LineNumber: 2, IsDeletion: false},
})
assert.NoError(t, err)
assert.False(t, selected)
}
func TestIncludedLineIdentities(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
// A file no part of the patch has nothing included.
assert.Empty(t, patchBuilder.IncludedLineIdentities("filename"))
// With only the deletion in, only its identity comes back.
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6}))
assert.Equal(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}},
patchBuilder.IncludedLineIdentities("filename"))
// With the addition in as well, both do.
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{7}))
assert.ElementsMatch(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
patchBuilder.IncludedLineIdentities("filename"))
}
// A file taken into the patch whole has every one of its change lines in it.
func TestIncludedLineIdentitiesOfAWholeFile(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
assert.NoError(t, patchBuilder.AddFileWhole("filename", ""))
assert.ElementsMatch(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
patchBuilder.IncludedLineIdentities("filename"))
}
+93
View File
@@ -120,6 +120,20 @@ index 9320895..6d79956 100644
lemon
`
// Two deletions with no line between them: they share a new-file line number
// (both sit at the same new-file position), so only their old-file line numbers
// tell them apart.
const consecutiveDeletions = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
-grape
-pear
lemon
`
const newFile = `diff --git a/newfile b/newfile
new file mode 100644
index 0000000..4e680cc
@@ -682,6 +696,85 @@ func TestLineNumberOfLine(t *testing.T) {
}
}
func TestIsWellFormed(t *testing.T) {
// The body of a diff as rendered with the +/- markers moved out of the text
// and into a gutter: every body line now reads as context, so the lengths no
// longer match the header.
const gutterMangled = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
grape
pear
lemon
`
scenarios := []struct {
testName string
patchStr string
expected bool
}{
{"simpleDiff", simpleDiff, true},
{"renameWithModificationDiff", renameWithModificationDiff, true},
{"addNewlineToEndOfFile", addNewlineToEndOfFile, true},
{"twoHunks", twoHunks, true},
{"consecutiveDeletions", consecutiveDeletions, true},
{"newFile", newFile, true},
{"deletedFile", deletedFile, true},
{"addNewlineToPreviouslyEmptyFile", addNewlineToPreviouslyEmptyFile, true},
{"exampleHunk", exampleHunk, true},
{"gutterMangled", gutterMangled, false},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormed())
})
}
}
func TestOldLineNumberOfLine(t *testing.T) {
type scenario struct {
testName string
patchStr string
indexes []int
expecteds []int
}
scenarios := []scenario{
{
testName: "twoChangesInOneHunk",
patchStr: twoChangesInOneHunk,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5, 5},
},
{
testName: "consecutiveDeletions",
patchStr: consecutiveDeletions,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 4, 4},
},
{
testName: "renameWithModificationDiff",
patchStr: renameWithModificationDiff,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
for i, idx := range s.indexes {
patch := Parse(s.patchStr)
result := patch.OldLineNumberOfLine(idx)
assert.Equal(t, s.expecteds[i], result)
}
})
}
}
func TestGetNextStageableLineIndex(t *testing.T) {
type scenario struct {
testName string
+6 -5
View File
@@ -296,6 +296,8 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
{[]string{"keybinding", "universal", "cyclePagers"}, "cycleDiffRenderers"},
{[]string{"keybinding", "universal", "cyclePagersReverse"}, "cycleDiffRenderersReverse"},
{[]string{"gui", "windowSize"}, "screenMode"},
{[]string{"gui", "wrapLinesInStagingView"}, "wrapLinesInDiffView"},
{[]string{"gui", "useHunkModeInStagingView"}, "useHunkModeInDiffView"},
{[]string{"keybinding", "files", "openMergeTool"}, "openMergeOptions"},
}
@@ -845,11 +847,10 @@ func (c *AppConfig) SaveGlobalUserConfig() {
// AppState stores data between runs of the app like when the last update check
// was performed and which other repos have been checked out
type AppState struct {
LastUpdateCheck int64
RecentRepos []string
StartupPopupVersion int
DidShowHunkStagingHint bool
LastVersion string // this is the last version the user was using, for the purpose of showing release notes
LastUpdateCheck int64
RecentRepos []string
StartupPopupVersion int
LastVersion string // this is the last version the user was using, for the purpose of showing release notes
// these are for shell commands typed in directly, not for custom commands in the lazygit config.
// For backwards compatibility we keep the old name in yaml files.
+16
View File
@@ -108,6 +108,22 @@ func TestMigrationOfRenamedKeys(t *testing.T) {
"Renamed 'gui.windowSize' to 'screenMode'",
},
},
{
name: "Rename staging view options",
input: `gui:
wrapLinesInStagingView: false
useHunkModeInStagingView: true
`,
expected: `gui:
wrapLinesInDiffView: false
useHunkModeInDiffView: true
`,
expectedDidChange: true,
expectedChanges: []string{
"Renamed 'gui.wrapLinesInStagingView' to 'wrapLinesInDiffView'",
"Renamed 'gui.useHunkModeInStagingView' to 'useHunkModeInDiffView'",
},
},
}
for _, s := range scenarios {
+11 -7
View File
@@ -94,7 +94,7 @@ type GuiConfig struct {
MouseEvents bool `yaml:"mouseEvents"`
// If true, do not show a warning when amending a commit.
SkipAmendWarning bool `yaml:"skipAmendWarning"`
// If true, do not show a warning when discarding changes in the staging view.
// If true, do not show a warning when discarding changes from a focused diff.
SkipDiscardChangeWarning bool `yaml:"skipDiscardChangeWarning"`
// If true, do not show warning when applying/popping the stash
SkipStashWarning bool `yaml:"skipStashWarning"`
@@ -129,10 +129,10 @@ type GuiConfig struct {
// - 'left': split the window horizontally (side panel on the left, main view on the right)
// - 'top': split the window vertically (side panel on top, main view below)
EnlargedSideViewLocation string `yaml:"enlargedSideViewLocation"`
// If true, wrap lines in the staging view to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.
WrapLinesInStagingView bool `yaml:"wrapLinesInStagingView"`
// If true, hunk selection mode will be enabled by default when entering the staging view.
UseHunkModeInStagingView bool `yaml:"useHunkModeInStagingView"`
// If true, wrap lines in focused diffs to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.
WrapLinesInDiffView bool `yaml:"wrapLinesInDiffView"`
// If true, hunk selection mode will be enabled by default when focusing a diff.
UseHunkModeInDiffView bool `yaml:"useHunkModeInDiffView"`
// One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru' | 'pt'
Language string `yaml:"language" jsonschema:"enum=auto,enum=en,enum=zh-TW,enum=zh-CN,enum=pl,enum=nl,enum=ja,enum=ko,enum=ru"`
// Format used when displaying time e.g. commit time.
@@ -660,6 +660,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"`
@@ -876,8 +878,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
},
MainPanelSplitMode: "flexible",
EnlargedSideViewLocation: "left",
WrapLinesInStagingView: true,
UseHunkModeInStagingView: true,
WrapLinesInDiffView: true,
UseHunkModeInDiffView: true,
Language: "auto",
TimeFormat: "02 Jan 06",
ShortTimeFormat: time.Kitchen,
@@ -1170,6 +1172,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"},
+97 -20
View File
@@ -20,6 +20,25 @@ type escapeInterpreter struct {
instruction instruction
hyperlink strings.Builder
// the digits of the OSC number seen so far, while we don't yet know which
// OSC this is
oscNumber strings.Builder
// the payload of an OSC 1717 sequence, in which a diff renderer states
// which line of which file it is about to render; accumulated like
// hyperlink, and attached to the cells that follow it
metadata strings.Builder
// whether the payload currently in metadata has reached a cell, so that one
// that never does can be recognized and kept as an orphan
metadataConsumed bool
// OSC 1717 payloads that no cell took, because the next record followed
// with nothing rendered in between. A renderer emits records back to back
// wherever two diff lines share a rendered line — the deletion and the
// addition of a modification collapsed into one column, or a banner
// announcing a file and its first hunk at once. The write loop gives these
// cells of their own, so that a line keeps every record it was given rather
// than only the last.
orphanedMetadata []string
// ConPTY emits cursor-positioning escapes (CUP) to skip over blank
// rows rather than emitting LFs for them. To convert those into row
// advances the view can act on, we track where in the pseudo-terminal
@@ -82,9 +101,9 @@ const (
stateParams
stateCSIDiscard
stateOSC
stateOSCWaitForParams
stateOSCParams
stateOSCHyperlink
stateOSCMetadata
stateOSCEndEscape
stateOSCSkipUnknown
@@ -427,27 +446,42 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
}
return true, nil
case stateOSC:
if characterEquals(ch, '8') {
ei.state = stateOSCWaitForParams
ei.hyperlink.Reset()
// Accumulate the OSC number until the ';' that terminates it, then
// dispatch on the whole number rather than on a single digit.
switch {
case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9':
ei.oscNumber.WriteByte(ch[0])
return true, nil
case characterEquals(ch, ';'):
switch ei.oscNumber.String() {
case "8":
ei.hyperlink.Reset()
ei.state = stateOSCParams
case "1717":
ei.orphanUnconsumedMetadata()
ei.state = stateOSCMetadata
default:
ei.state = stateOSCSkipUnknown
}
ei.oscNumber.Reset()
return true, nil
default:
// Not an OSC we understand — it has no number, or a character
// follows the number where the ';' should be. Rather than
// erroring, which would reset state mid-OSC and leak the rest of
// the sequence into the view as literal text, skip to its
// terminator, which this character may already be.
ei.oscNumber.Reset()
switch {
case characterEquals(ch, 0x07):
ei.state = stateNone
case characterEquals(ch, 0x1b):
ei.state = stateOSCEndEscape
default:
ei.state = stateOSCSkipUnknown
}
return true, nil
}
ei.state = stateOSCSkipUnknown
return true, nil
case stateOSCWaitForParams:
if !characterEquals(ch, ';') {
// Malformed OSC 8 (expected ';' after '8'). Rather than
// erroring — which would reset state mid-OSC and cause the
// rest of the sequence to leak as literal text — treat the
// whole OSC as one we don't understand and skip to its
// terminator.
ei.state = stateOSCSkipUnknown
return true, nil
}
ei.state = stateOSCParams
return true, nil
case stateOSCParams:
if characterEquals(ch, ';') {
ei.state = stateOSCHyperlink
@@ -463,6 +497,18 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
ei.hyperlink.Write(ch)
}
return true, nil
case stateOSCMetadata:
switch {
case characterEquals(ch, 0x07):
ei.dropMetadataIfHandshake()
ei.state = stateNone
case characterEquals(ch, 0x1b):
ei.dropMetadataIfHandshake()
ei.state = stateOSCEndEscape
default:
ei.metadata.Write(ch)
}
return true, nil
case stateOSCEndEscape:
ei.state = stateNone
return true, nil
@@ -478,6 +524,37 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
return false, nil
}
// orphanUnconsumedMetadata clears the metadata accumulator for a new OSC 1717
// record, keeping the payload it held as an orphan if no cell took it (see
// orphanedMetadata).
func (ei *escapeInterpreter) orphanUnconsumedMetadata() {
if ei.metadata.Len() > 0 && !ei.metadataConsumed {
ei.orphanedMetadata = append(ei.orphanedMetadata, ei.metadata.String())
}
ei.metadata.Reset()
ei.metadataConsumed = false
}
// takeOrphanedMetadata hands the accumulated orphaned payloads to the caller and
// clears the list.
func (ei *escapeInterpreter) takeOrphanedMetadata() []string {
result := ei.orphanedMetadata
ei.orphanedMetadata = nil
return result
}
// dropMetadataIfHandshake discards a just-completed OSC 1717 payload that
// carries nothing beyond the version. A diff renderer emits such a record ahead
// of everything else to announce that it speaks the protocol, so that a host can
// find that out by asking rather than by inspecting a rendering. It says nothing
// about any line, so it must not attach to the line that follows it; a per-line
// record always has fields, and is kept.
func (ei *escapeInterpreter) dropMetadataIfHandshake() {
if !strings.Contains(ei.metadata.String(), ";") {
ei.metadata.Reset()
}
}
func (ei *escapeInterpreter) outputCSI() error {
n := len(ei.csiParam)
for i := 0; i < n; {
+1
View File
@@ -167,6 +167,7 @@ func TestParseOneIgnoresUnknownSequences(t *testing.T) {
"\x1b[0 q", // intermediate byte after a param
"\x1b[1;;m", // malformed SGR: empty middle param
"\x1b]8bogus\x07", // OSC 8 missing ';'
"\x1b]1337;File=inline=1\x07", // OSC with a number we don't implement
"\x1b[" + strings.Repeat("0", 300) + "m", // single param overflows length cap
"\x1b[" + strings.Repeat("1;", 25) + "1m", // too many params
}
+2 -2
View File
@@ -419,7 +419,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er
v.y1 = y1
if sizeChanged {
v.ClearViewLines()
v.RewrapContent()
if v.Editable {
cursorX, cursorY := v.TextArea.GetCursorXY()
@@ -1538,7 +1538,7 @@ func (g *Gui) flush() error {
// if GUI's size has changed, we need to redraw all views
if maxX != g.maxX || maxY != g.maxY {
for _, v := range g.views {
v.ClearViewLines()
v.RewrapContent()
}
}
g.maxX, g.maxY = maxX, maxY
+426 -22
View File
@@ -157,6 +157,23 @@ type View struct {
// instead of Sel{Bg,Fg}Colors for highlighting selected lines.
HighlightInactive bool
// If SelectedLineColorWidth is greater than zero, a highlighted line is painted
// in the selection colors on that many columns at its left edge only, rather
// than across its whole width, leaving the line's own colors to show through.
// For content that conveys meaning by color of its own.
SelectedLineColorWidth int
// InclusionGutterMarker is the glyph the inclusion gutter draws on a marked line
// (see SetInclusionGutter), and InclusionGutterMarkerColor its color. Both are
// set once, when the view is created.
InclusionGutterMarker string
InclusionGutterMarkerColor Attribute
// showInclusionGutter reserves the gutter's columns at the left of every line,
// and inclusionGutterMarks, indexed by line of the content, says which lines get
// the marker. Set together, via SetInclusionGutter.
showInclusionGutter bool
inclusionGutterMarks []bool
// If Frame is true, a border will be drawn around the view.
Frame bool
@@ -243,23 +260,137 @@ type pos struct {
x, y int
}
// call this in the event of a view resize, or if you want to render new content
// without the chance of old content still appearing, or if you want to remove
// a line from the existing content
// call this if you want to render new content without the chance of old content
// still appearing, or if you want to remove a line from the existing content. For
// a view whose size has changed, whose content is the same but has to be wrapped
// afresh, call RewrapContent instead.
func (v *View) clearViewLines() {
v.tainted = true
v.viewLines = nil
v.clearHover()
}
// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on
// the UI thread (the layout pass) that touch a view whose content a task
// goroutine may be writing concurrently: viewLines/tainted/hover are all
// buffer state that writeMutex protects.
func (v *View) ClearViewLines() {
// RewrapContent wraps the view's content for the size the view has now, and puts
// the positions into that content — the scroll offset, the cursor, a range's
// anchor — back on the lines they were on. They are all view lines, which count
// the segments each line is wrapped into, so wrapping the content at another
// width leaves every one of them pointing at a different line.
//
// Call it on the UI thread whenever the view's size changes; a task goroutine may
// be writing the content concurrently, and all of this is state writeMutex
// protects.
func (v *View) RewrapContent() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
origin := v.contentPosOf(v.oy)
cursor := v.contentPosOf(v.oy + v.cy)
anchor := v.contentPosOf(v.rangeSelectStartY)
cursorRow := v.cy
v.clearViewLines()
v.refreshViewLinesIfNeeded()
if !origin.ok {
return
}
cursorLine, cursorOk := v.viewLineOf(cursor)
if anchorLine, ok := v.viewLineOf(anchor); ok {
v.rangeSelectStartY = anchorLine
if cursorOk {
// A range covers lines of content, not the segments they are drawn as,
// so its ends go back on the outermost segments of their lines: a line
// the range covered the whole of stays covered whole.
cursorLine = v.viewLineOfRangeEnd(cursor, anchor)
v.rangeSelectStartY = v.viewLineOfRangeEnd(anchor, cursor)
}
}
// The line the cursor is on keeps the row it was drawn on, so that it doesn't
// move under the user; with no cursor on screen the view keeps its own place
// in the content instead.
if v.Highlight && cursorOk && cursorRow >= 0 && cursorRow < v.InnerHeight() {
v.SetOriginY(cursorLine - cursorRow)
} else if originLine, ok := v.viewLineOf(origin); ok {
v.SetOriginY(originLine)
}
if cursorOk {
v.cy = cursorLine - v.oy
}
}
// contentPos is a position in a view's content in terms that survive the content
// being wrapped again: which line of it, and which of that line's segments.
type contentPos struct {
line, segment int
ok bool
}
// contentPosOf returns where the given view line sits in the content. Only call
// this with a lock on writeMutex, and with the view lines up to date.
func (v *View) contentPosOf(viewLine int) contentPos {
if viewLine < 0 || viewLine >= len(v.viewLines) {
return contentPos{}
}
return contentPos{
line: v.viewLines[viewLine].linesY,
segment: v.viewLines[viewLine].linesX,
ok: true,
}
}
// viewLineOf returns the view line drawing the given position in the content,
// on the nearest segment its line still has. Only call this with a lock on
// writeMutex, and with the view lines up to date.
func (v *View) viewLineOf(pos contentPos) (int, bool) {
first, last, ok := v.segmentSpanOf(pos)
if !ok {
return 0, false
}
return min(first+pos.segment, last), true
}
// viewLineOfRangeEnd returns the view line for one end of a range selection: the
// outermost segment of its line, so that the range covers that line whole. other
// is the range's other end, which says which way is outward. Both ends have to be
// positions whose lines are drawn, which viewLineOf answers.
func (v *View) viewLineOfRangeEnd(pos contentPos, other contentPos) int {
first, last, _ := v.segmentSpanOf(pos)
if pos.line <= other.line {
return first
}
return last
}
// segmentSpanOf returns the first and last view line drawing the given position's
// line of the content. ok is false when the position was never taken, or its line
// isn't drawn at all.
func (v *View) segmentSpanOf(pos contentPos) (int, int, bool) {
if !pos.ok {
return 0, 0, false
}
return v.viewLineSpanOfBufferLine(pos.line)
}
// viewLineSpanOfBufferLine returns the first and last view line drawing the given
// buffer line — the segments it is wrapped into, which are the same view line when
// it doesn't wrap. ok is false when the line isn't drawn at all. Only call this
// with a lock on writeMutex, and with the view lines up to date.
func (v *View) viewLineSpanOfBufferLine(bufferLine int) (int, int, bool) {
first, last := -1, -1
for i, vline := range v.viewLines {
if vline.linesY == bufferLine {
if first == -1 {
first = i
}
last = i
} else if first != -1 {
break
}
}
return first, last, first != -1
}
type searcher struct {
@@ -454,6 +585,13 @@ func (v *View) CancelRangeSelect() {
v.rangeSelectStartY = -1
}
// HasRangeSelect reports whether a range selection is anchored, as opposed to the
// view showing a plain cursor. A range whose ends are on the same view line is still
// one, which SelectedLineRange alone can't tell you.
func (v *View) HasRangeSelect() bool {
return v.rangeSelectStartY != -1
}
func calculateNewOrigin(selectedLine int, oldOrigin int, lineCount int, viewHeight int) int {
if viewHeight >= lineCount {
return 0
@@ -539,6 +677,9 @@ type cell struct {
width int // number of terminal cells occupied by chr (always 1 or 2)
bgColor, fgColor Attribute
hyperlink string
// the OSC 1717 payload in effect when the cell was written, i.e. what the
// diff renderer said about the diff line this cell is part of
metadata string
}
type cells []cell
@@ -650,6 +791,38 @@ func (v *View) Name() string {
return v.name
}
// SetInclusionGutter shows or hides a column reserved at the left of every line, in
// which marks — indexed by line of the content — say which lines get
// InclusionGutterMarker drawn, on every segment of a line the view wrapped. The
// content is drawn shifted past it.
//
// It is drawn over the content rather than written into it, so the content itself —
// and with it what each line of the view means, where a click lands, and how the
// lines wrap — is untouched but for the width the gutter takes.
func (v *View) SetInclusionGutter(show bool, marks []bool) {
v.writeMutex.Lock()
changed := v.showInclusionGutter != show
v.showInclusionGutter = show
v.inclusionGutterMarks = marks
v.writeMutex.Unlock()
if changed {
// The gutter takes its columns from the content, so what is left of it wraps
// differently, and everything pointing into it has to come along.
v.RewrapContent()
}
}
// inclusionGutterWidth is how many columns the inclusion gutter takes while it is
// shown — the marker plus a column of space before the content — and 0 while it is
// not. Only call this with a lock on writeMutex.
func (v *View) inclusionGutterWidth() int {
if !v.showInclusionGutter {
return 0
}
return uniseg.StringWidth(v.InclusionGutterMarker) + 1
}
// setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies
// the specified colors, taking into account if the cell must be highlighted. Also, it checks if the
// position is valid.
@@ -672,7 +845,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) {
// this ensures we use the bright variant of a colour upon highlight
fgColorComponent := fgColor & ^AttrAll
if fgColorComponent >= AttrIsValidColor && fgColorComponent < AttrIsValidColor+8 {
@@ -913,6 +1087,19 @@ func (b *viewBuffer) write(v *View, p []byte) {
finishLine := func() {
b.autoRenderHyperlinksInCurrentLine(v)
// A record that reached the line's end without covering a cell still
// belongs to the line: an orphan (see escapeInterpreter.orphanedMetadata),
// or the record of a changed line that is empty, which a renderer emits
// with nothing but the newline after it. Give each a cell of its own, so
// that the line is still recognizable as the diff line it renders rather
// than as nothing at all.
for _, payload := range b.ei.takeOrphanedMetadata() {
b.writeCells([]cell{{metadata: payload}})
}
if b.ei.metadata.Len() > 0 && !b.ei.metadataConsumed {
b.writeCells([]cell{{metadata: b.ei.metadata.String()}})
b.ei.metadataConsumed = true
}
}
advanceToNextLine := func() {
@@ -921,6 +1108,10 @@ func (b *viewBuffer) write(v *View, p []byte) {
if b.wy >= len(b.lines) {
b.lines = append(b.lines, lineType{})
}
// An OSC 1717 record describes the line it precedes and is never
// closed, so it stops applying at the line's end; a renderer emits a
// fresh one for each line it has something to say about.
b.ei.metadata.Reset()
}
if b.pendingNewline {
@@ -1065,6 +1256,15 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
truncateLine := false
isEscape, err := b.ei.parseOne(ch)
// A record that the next one superseded before any cell took it still
// belongs to this line (see escapeInterpreter.orphanedMetadata); give each
// a cell of its own, in the order they were emitted, ahead of whatever this
// character produces.
for _, payload := range b.ei.takeOrphanedMetadata() {
cells = append(cells, cell{metadata: payload})
}
if err != nil {
for _, chr := range b.ei.characters() {
c := cell{
@@ -1091,7 +1291,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
fg: b.ei.curFgColor,
bg: b.ei.curBgColor,
}
return truncateLine, []cell{}
return truncateLine, cells
} else if cf, ok := b.ei.instruction.(cursorForward); ok {
// emit `n` space cells under the parser-tracked SGR — used
// to materialize ConPTY's compressed runs of spaces (which
@@ -1101,8 +1301,12 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
ch = []byte{' '}
width = 1
} else if isEscape {
// do not output anything
return truncateLine, nil
// the escape itself outputs nothing, but any cells carrying an
// orphaned record still need writing
if len(cells) == 0 {
return truncateLine, nil
}
return truncateLine, cells
} else if characterEquals(ch, '\t') {
// fill tab-sized space
tabWidth := v.TabWidth
@@ -1117,9 +1321,13 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
fgColor: b.ei.curFgColor,
bgColor: b.ei.curBgColor,
hyperlink: b.ei.hyperlink.String(),
metadata: b.ei.metadata.String(),
chr: string(ch),
width: width,
}
if c.metadata != "" {
b.ei.metadataConsumed = true
}
for range repeatCount {
cells = append(cells, c)
}
@@ -1204,9 +1412,9 @@ func (v *View) CopyContent(from *View) {
// A background task may be streaming output into the source view's buffer
// via Write, so read it under its own lock. The source is always a
// different view than the destination (see the sole caller,
// moveMainContextToTop), and no other code holds two view write locks at
// once, so this can't deadlock.
// different view than the destination — its callers hand content from one
// view to another — and no other code holds two view write locks at once, so
// this can't deadlock.
from.writeMutex.Lock()
defer from.writeMutex.Unlock()
@@ -1476,6 +1684,8 @@ func (v *View) draw(isWindowFocused bool) {
emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault}
gutterWidth := v.inclusionGutterWidth()
for y, vline := range v.viewLines[start:] {
if y >= maxY {
break
@@ -1490,10 +1700,20 @@ func (v *View) draw(isWindowFocused bool) {
trailingCell.bgColor = attrs.bg
}
// The inclusion gutter is blank but for the marker on a marked line, and the
// content begins after it. The blanks go through setCharacter like everything
// else, so that a selection reaching the left edge covers the gutter too.
for gx := range gutterWidth {
v.setCharacter(gx, y, " ", v.FgColor, v.BgColor, isWindowFocused)
}
if gutterWidth > 0 && vline.linesY < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[vline.linesY] {
v.setCharacter(0, y, v.InclusionGutterMarker, v.InclusionGutterMarkerColor, v.BgColor, isWindowFocused)
}
// x tracks the current x position in the view, and cellIdx tracks the
// index of the cell. If we print a double-sized rune, we increment cellIdx
// by one but x by two.
x := -v.ox
x := gutterWidth - v.ox
cellIdx := 0
var c cell
@@ -1507,7 +1727,7 @@ func (v *View) draw(isWindowFocused bool) {
// no more characters to write so we're only going to be printing empty cells
// past this point
x = 0
x = gutterWidth
}
// if we're out of cells to write, we'll just print empty cells.
@@ -1542,10 +1762,11 @@ func (v *View) refreshViewLinesIfNeeded() {
return
}
maxX := v.InnerWidth()
wrap := 0
if v.Wrap {
wrap = maxX
// The inclusion gutter, while it is shown, takes its columns out of the width
// the content has to wrap in.
wrap = max(0, v.InnerWidth()-v.inclusionGutterWidth())
}
lineIdx := 0
@@ -1687,6 +1908,152 @@ func (v *View) BufferLines() []string {
return lines
}
// MarkedLines returns the lines of the view's content that the inclusion gutter is
// marking (see SetInclusionGutter), in the order they appear. Empty while the gutter
// is hidden.
func (v *View) MarkedLines() []string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if !v.showInclusionGutter {
return nil
}
lines := []string{}
for i, line := range v.buf.lines {
if i < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[i] {
lines = append(lines, line.cells.String())
}
}
return lines
}
// DiffLineContent is what one line of a rendered diff offers to a reader trying
// to recover which line of which file it came from: the line's text, which can
// be parsed as a unified diff when the rendering preserves one, and the OSC 1717
// records a diff renderer attached to it, which state it outright.
type DiffLineContent struct {
Text string
// The distinct OSC 1717 payloads carried by the line's cells, in
// left-to-right order. A single-column rendering tags every cell of a line
// with the same payload, so there is one; a side-by-side rendering tags
// each side separately, so a line showing a deletion beside the addition
// that replaces it carries both.
Metadata []string
}
// DiffLineContents returns the per-line material a diff-line reader works from
// (see DiffLineContent), indexed by unwrapped buffer line. Text and records are
// snapshotted in a single locked pass, so they stay consistent with each other
// and with the buffer they came from even while a re-render rebuilds it.
func (v *View) DiffLineContents() []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return diffLineContentsFrom(v.buf, 0)
}
// OffscreenDiffLineContents is DiffLineContents for the content of a re-render in
// progress (see BeginOffscreenRender), which is what a reader that wants to say
// where the new content should be shown has to work from: it has to answer before
// the swap, since after it the content is already on screen. Returns nil when no
// re-render is underway.
func (v *View) OffscreenDiffLineContents() []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil {
return nil
}
return diffLineContentsFrom(v.offscreen, 0)
}
// OffscreenDiffLineContentsFrom is OffscreenDiffLineContents restricted to the lines
// from index `from` on (so result[0] is buffer line `from`). It lets a reader that
// follows a re-render as it loads look at each line once, rather than snapshotting
// the whole buffer again on every line — the difference between an O(n) and an O(n²)
// scan of a large diff. Returns nil when no re-render is underway, or when `from` is
// past the lines read so far.
func (v *View) OffscreenDiffLineContentsFrom(from int) []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil || from < 0 || from >= len(v.offscreen.lines) {
return nil
}
return diffLineContentsFrom(v.offscreen, from)
}
// OffscreenLineCount returns the number of unwrapped lines a re-render in progress
// has read so far, or 0 when none is underway. It tells a reader waiting for a
// particular line, cheaply, when a screenful below it has arrived too — so that the
// swap shows that line with content under it rather than at the bottom edge of a
// half-filled view.
func (v *View) OffscreenLineCount() int {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil {
return 0
}
return len(v.offscreen.lines)
}
func diffLineContentsFrom(buf *viewBuffer, from int) []DiffLineContent {
lines := buf.lines[from:]
contents := make([]DiffLineContent, len(lines))
for i, line := range lines {
var metadata []string
for _, c := range line.cells {
if c.metadata != "" && !slices.Contains(metadata, c.metadata) {
metadata = append(metadata, c.metadata)
}
}
contents[i] = DiffLineContent{Text: line.cells.String(), Metadata: metadata}
}
return contents
}
// BufferLineForViewLine maps a view line index (which counts wrapped lines) to
// the index of the corresponding line in the unwrapped internal buffer (as
// returned by BufferLines). Several view lines map to the same buffer line when
// that line wraps. Returns false if the view line is out of range.
func (v *View) BufferLineForViewLine(y int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return v.bufferLineForViewLine(y)
}
// ViewLineForBufferLine maps an unwrapped buffer line index to the index of the
// first view line that renders it — the inverse of BufferLineForViewLine, for
// turning a line found by examining the buffer into a line to scroll to or
// select. Returns false if the buffer line isn't rendered into any view line.
func (v *View) ViewLineForBufferLine(bufferLineIdx int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
first, _, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
return first, ok
}
// LastViewLineForBufferLine maps an unwrapped buffer line index to the index of
// the last view line that renders it, which for a line that doesn't wrap is the
// same as the first. It is where the far end of a range goes: a range is over
// buffer lines, so it has to cover the last one of them to its final segment
// rather than stopping where that line begins.
func (v *View) LastViewLineForBufferLine(bufferLineIdx int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
_, last, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
return last, ok
}
// Buffer returns a string with the contents of the view's internal
// buffer.
func (v *View) Buffer() string {
@@ -1913,16 +2280,31 @@ func (v *View) SelectedLineIdx() int {
return seletedLineIdx
}
// IsLineVisible reports whether the given view line is one of those on screen.
func (v *View) IsLineVisible(viewLine int) bool {
return viewLine >= v.OriginY() && viewLine < v.OriginY()+v.InnerHeight()
}
// MiddleVisibleLineIdx returns the view line halfway down the visible content. It
// stands in for a cursor in a view that has none: of the lines on screen, the one in
// the middle is the likeliest to be the one being read.
func (v *View) MiddleVisibleLineIdx() int {
top := v.OriginY()
bottom := min(top+v.InnerHeight(), v.ViewLinesHeight())
return (top + bottom) / 2
}
// expected to only be used in tests
func (v *View) SelectedLine() string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if len(v.buf.lines) == 0 {
idx, ok := v.bufferLineForViewLine(v.SelectedLineIdx())
if !ok {
return ""
}
return v.lineContentAtIdx(v.SelectedLineIdx())
return v.lineContentAtIdx(idx)
}
// expected to only be used in tests
@@ -1937,8 +2319,17 @@ func (v *View) SelectedLines() []string {
startIdx, endIdx := v.SelectedLineRange()
lines := make([]string, 0, endIdx-startIdx+1)
previous := -1
for i := startIdx; i <= endIdx; i++ {
lines = append(lines, v.lineContentAtIdx(i))
// The selection is in view lines, which count the segments a wrapped line
// is drawn as; a line the selection covers several segments of is still
// the one line it is.
idx, ok := v.bufferLineForViewLine(i)
if !ok || idx == previous {
continue
}
previous = idx
lines = append(lines, v.lineContentAtIdx(idx))
}
return lines
@@ -1948,6 +2339,19 @@ func (v *View) lineContentAtIdx(idx int) string {
return v.buf.lines[idx].cells.String()
}
// bufferLineForViewLine maps a view line index, which counts the wrapped
// segments of the lines it draws, to the index of the line of content it is a
// segment of. Only call this with a lock on writeMutex.
func (v *View) bufferLineForViewLine(y int) (int, bool) {
v.refreshViewLinesIfNeeded()
if y < 0 || y >= len(v.viewLines) {
return 0, false
}
return v.viewLines[y].linesY, true
}
func (v *View) SelectedPoint() (int, int) {
cx, cy := v.Cursor()
ox, oy := v.Origin()
+294
View File
@@ -158,6 +158,107 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
}
// osc1717 wraps an OSC 1717 payload in the sequence a diff renderer emits it in:
// the ESC ] introducer with the OSC number, and ESC \ as the terminator.
func osc1717(payload string) string {
return "\x1b]1717;" + payload + "\x1b\\"
}
func TestDiffLineContents(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A diff renderer prefixes each line it renders with a record naming the
// file and the line's position in it: version;type;new-line;old-line;file.
v.writeString(strings.Join([]string{
osc1717("1;c;1;;foo.txt") + "line1",
osc1717("1;d;2;2;foo.txt") + "old2",
osc1717("1;a;2;;foo.txt") + "new2",
"@@ a hunk header, which carries no record @@",
}, "\n"))
assert.Equal(t, []DiffLineContent{
{Text: "line1", Metadata: []string{"1;c;1;;foo.txt"}},
{Text: "old2", Metadata: []string{"1;d;2;2;foo.txt"}},
{Text: "new2", Metadata: []string{"1;a;2;;foo.txt"}},
// The record of the line before doesn't bleed onto this one.
{Text: "@@ a hunk header, which carries no record @@"},
}, v.DiffLineContents())
}
func TestDiffLineContentsWithSideBySideRecords(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A side-by-side renderer puts two diff lines on one rendered line, and so
// emits a record before each half.
v.writeString(strings.Join([]string{
osc1717("1;c;1;;foo.txt") + "context " + osc1717("1;c;1;;foo.txt") + "context",
osc1717("1;d;2;2;foo.txt") + "old2 " + osc1717("1;a;2;;foo.txt") + "new2",
}, "\n"))
assert.Equal(t, []DiffLineContent{
// The two halves of a context line are the same diff line, stated twice.
{Text: "context context", Metadata: []string{"1;c;1;;foo.txt"}},
{Text: "old2 new2", Metadata: []string{"1;d;2;2;foo.txt", "1;a;2;;foo.txt"}},
}, v.DiffLineContents())
}
func TestDiffLineContentsOfWrappedLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A line that gocui wraps is still one buffer line, so its record covers
// every view line it is displayed on.
v.writeString(osc1717("1;a;1;;foo.txt") + "a line too long to fit")
assert.Equal(t, []DiffLineContent{
{Text: "a line too long to fit", Metadata: []string{"1;a;1;;foo.txt"}},
}, v.DiffLineContents())
assert.Equal(t, 3, v.ViewLinesHeight())
for viewLine := range 3 {
bufferLine, ok := v.BufferLineForViewLine(viewLine)
assert.True(t, ok)
assert.Equal(t, 0, bufferLine)
}
}
func TestDiffLineContentsWithRecordsCoveringNoCell(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
v.writeString(strings.Join([]string{
// A banner announcing a file and its first hunk at once carries both
// records back to back.
osc1717("1;f;;;foo.txt") + osc1717("1;h;5;;foo.txt") + "foo.txt --- Go",
// So does a modification whose deletion and addition are collapsed into
// a single rendered line.
osc1717("1;d;5;5;foo.txt") + osc1717("1;a;5;;foo.txt") + "595 new content",
// A changed line that is empty is rendered as its record and nothing else.
osc1717("1;a;6;;foo.txt"),
}, "\n") + "\n")
assert.Equal(t, []DiffLineContent{
{Text: "foo.txt --- Go", Metadata: []string{"1;f;;;foo.txt", "1;h;5;;foo.txt"}},
{Text: "595 new content", Metadata: []string{"1;d;5;5;foo.txt", "1;a;5;;foo.txt"}},
{Text: "", Metadata: []string{"1;a;6;;foo.txt"}},
}, v.DiffLineContents())
}
func TestDiffLineContentsSwallowsHandshake(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A diff renderer announces itself with a version-only record before the
// diff. It must leave no trace: no visible bytes, no line of its own, and
// above all no record on the line that follows it.
v.writeString(osc1717("1") + strings.Join([]string{
"diff --git a/foo.txt b/foo.txt",
osc1717("1;a;1;;foo.txt") + "added",
}, "\n"))
assert.Equal(t, []DiffLineContent{
{Text: "diff --git a/foo.txt b/foo.txt"},
{Text: "added", Metadata: []string{"1;a;1;;foo.txt"}},
}, v.DiffLineContents())
}
// An async re-render builds into an off-screen buffer and swaps it in once it
// has enough to paint, so readers keep seeing the previous render — coherent and
// consistent — until the new content appears in one step. See View.offscreen.
@@ -204,6 +305,61 @@ func TestViewLinesTruncatedByShorterRender(t *testing.T) {
assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines())
}
func TestBufferLineForViewLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// Buffer line 0 is short (view line 0); buffer line 1 wraps into three view
// lines (1, 2, 3); buffer line 2 is short again (view line 4).
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for viewLine, wantBufferLine := range []int{0, 1, 1, 1, 2} {
bufferLine, ok := v.BufferLineForViewLine(viewLine)
assert.True(t, ok)
assert.Equal(t, wantBufferLine, bufferLine)
}
_, ok := v.BufferLineForViewLine(5)
assert.False(t, ok)
_, ok = v.BufferLineForViewLine(-1)
assert.False(t, ok)
}
func TestViewLineForBufferLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A wrapped buffer line maps to the first of the view lines it spans.
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for bufferLine, wantViewLine := range []int{0, 1, 4} {
viewLine, ok := v.ViewLineForBufferLine(bufferLine)
assert.True(t, ok)
assert.Equal(t, wantViewLine, viewLine)
}
_, ok := v.ViewLineForBufferLine(3)
assert.False(t, ok)
}
func TestLastViewLineForBufferLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A wrapped buffer line maps to the last of the view lines it spans.
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for bufferLine, wantViewLine := range []int{0, 3, 4} {
viewLine, ok := v.LastViewLineForBufferLine(bufferLine)
assert.True(t, ok)
assert.Equal(t, wantViewLine, viewLine)
}
_, ok := v.LastViewLineForBufferLine(3)
assert.False(t, ok)
}
// While an async re-render loads, it swaps in only a partially-filled buffer at
// its first paint and keeps appending lines afterwards. The scrollbar must keep
// using the pre-load height until the load ends, so the thumb doesn't shrink and
@@ -780,3 +936,141 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
"trailing cell at (%d, 2) should have green bg", x)
}
}
// A view that wraps draws one line of its content as several view lines, and the
// cursor and the range anchor count those. What is asked about a selection is
// which lines of the content it covers, so those are what it has to be reported
// in.
func TestSelectedLinesOfWrappedContent(t *testing.T) {
v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10
v.Wrap = true
v.Highlight = true
// "a line that wraps" takes two view lines, so the four lines of content are
// drawn as five: "one", "two", "a line th", "at wraps", "four".
v.writeString("one\ntwo\na line that wraps\nfour\n")
assert.Equal(t, 5, v.ViewLinesHeight())
// The cursor on the wrapped line's second half is on that line.
v.FocusPoint(0, 3, false)
assert.Equal(t, "a line that wraps", v.SelectedLine())
// A range over both halves of the wrapped line covers one line of content.
v.SetRangeSelectStart(2)
assert.Equal(t, []string{"a line that wraps"}, v.SelectedLines())
}
// Resizing a view throws away the wrapping of its content and wraps it again for
// the new width, which moves every line of it to a different view line. The
// positions into the view count view lines, so they all have to come along.
func TestResizingAWrappingViewKeepsItsPlaceInTheContent(t *testing.T) {
g := &Gui{}
v, _ := g.SetView("name", 0, 0, 11, 10, 0) // InnerWidth 10
v.Wrap = true
v.Highlight = true
// Two wrapping lines, with a single line between them: eight view lines for
// five lines of content.
v.writeString("one\na line that wraps\ntwo\nanother wrapping line\nthree\n")
assert.Equal(t, 8, v.ViewLinesHeight())
// A range over the whole of the second wrapping line, which is drawn as view
// lines 4 to 6.
v.SetRangeSelectStart(4)
v.FocusPoint(0, 6, false)
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
// Widen the view so that nothing wraps any more.
_, _ = g.SetView("name", 0, 0, 31, 10, 0) // InnerWidth 30
assert.Equal(t, 5, v.ViewLinesHeight())
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
}
// The inclusion gutter reserves columns at the left of every line, draws its marker
// on the marked lines only, and moves the content out of the way.
func TestInclusionGutter(t *testing.T) {
WithSimulationScreen(t, 14, 6)
// InnerWidth 10; the frame puts view x=0 at screen x=1.
v := NewView("name", 0, 0, 11, 5, OutputNormal)
v.Wrap = true
v.InclusionGutterMarker = "✓"
v.writeString("aaa\nbbb\nccc\n")
// The gutter is two columns wide — the marker and a space; mark the middle line.
v.SetInclusionGutter(true, []bool{false, true, false})
v.draw(true)
chr, _, _ := Screen.Get(1, 1)
assert.Equal(t, " ", chr, "an unmarked line has no marker")
chr, _, _ = Screen.Get(1, 2)
assert.Equal(t, "✓", chr, "a marked line has one")
chr, _, _ = Screen.Get(1, 3)
assert.Equal(t, " ", chr, "an unmarked line has no marker")
// The content begins after the gutter: view x=2, i.e. screen x=3.
chr, _, _ = Screen.Get(3, 1)
assert.Equal(t, "a", chr)
chr, _, _ = Screen.Get(3, 2)
assert.Equal(t, "b", chr)
chr, _, _ = Screen.Get(3, 3)
assert.Equal(t, "c", chr)
// Hiding the gutter puts the content back at the left edge.
v.SetInclusionGutter(false, nil)
v.draw(true)
chr, _, _ = Screen.Get(1, 1)
assert.Equal(t, "a", chr)
}
// A marked line the view wraps is marked on every segment it is drawn as, so that
// the mark doesn't look like it belongs to the first part of the line alone. The
// gutter takes its columns out of the width the content wraps in.
func TestInclusionGutterMarksEverySegmentOfAWrappedLine(t *testing.T) {
WithSimulationScreen(t, 14, 6)
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10
v.Wrap = true
v.InclusionGutterMarker = "✓"
// Ten cells, wrapping at eight once the two-column gutter is shown.
v.writeString("0123456789\n")
v.SetInclusionGutter(true, []bool{true})
v.draw(true)
chr, _, _ := Screen.Get(1, 1)
assert.Equal(t, "✓", chr)
chr, _, _ = Screen.Get(3, 1)
assert.Equal(t, "0", chr)
chr, _, _ = Screen.Get(10, 1)
assert.Equal(t, "7", chr, "the content wraps at the width the gutter leaves it")
chr, _, _ = Screen.Get(1, 2)
assert.Equal(t, "✓", chr, "the line's second segment is marked too")
chr, _, _ = Screen.Get(3, 2)
assert.Equal(t, "8", chr)
}
// Showing the gutter narrows the content, so the content wraps again — and the
// positions into it, which count the segments lines are drawn as, have to come
// along, as they do for any other change of width.
func TestShowingTheInclusionGutterKeepsThePlaceInTheContent(t *testing.T) {
v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10
v.Wrap = true
v.Highlight = true
v.InclusionGutterMarker = "✓"
v.writeString("one\ntwo\nthree\nsomethingfartoolong\n")
assert.Equal(t, 5, v.ViewLinesHeight())
v.FocusPoint(0, 2, false)
assert.Equal(t, "three", v.SelectedLine())
// With eight columns left for the content, the last line wraps into three
// segments rather than two.
v.SetInclusionGutter(true, []bool{false, false, true, false})
assert.Equal(t, 6, v.ViewLinesHeight())
assert.Equal(t, "three", v.SelectedLine())
}
+40 -16
View File
@@ -3,6 +3,7 @@ package gui
import (
"sync"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
@@ -179,10 +180,6 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
self.gui.helpers.Window.SetWindowContext(c)
self.gui.helpers.Window.MoveToTopOfWindow(c)
oldView := self.gui.c.GocuiGui().CurrentView()
if oldView != nil && oldView.Name() != viewName {
oldView.HighlightInactive = true
}
if _, err := self.gui.c.GocuiGui().SetCurrentView(viewName); err != nil {
panic(err)
}
@@ -198,9 +195,37 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == ""
self.UpdateSelectionHighlights()
c.HandleFocus(opts)
}
// 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.
//
// Both of those can change, so this is called wherever they do: from Activate, which
// every change to the stack goes through, after a refresh, which is what changes the
// contents of a list, and from whoever tells a context that its content has gained or
// lost something to select.
func (self *ContextMgr) UpdateSelectionHighlights() {
self.RLock()
defer self.RUnlock()
onStack := set.NewFromSlice(lo.Map(self.ContextStack,
func(c types.Context, _ int) types.ContextKey { return c.GetKey() }))
currentKey := self.currentContextWithoutLock().GetKey()
for _, c := range self.allContexts.Flatten() {
// The global context has no view of its own.
if view := c.GetView(); view != nil {
view.Highlight = onStack.Includes(c.GetKey()) && c.HasSelectableContent()
view.HighlightInactive = c.GetKey() != currentKey
}
}
}
func (self *ContextMgr) Current() types.Context {
self.RLock()
defer self.RUnlock()
@@ -324,18 +349,6 @@ func (self *ContextMgr) AllList() []types.IListContext {
return listContexts
}
func (self *ContextMgr) AllPatchExplorer() []types.IPatchExplorerContext {
var listContexts []types.IPatchExplorerContext
for _, context := range self.allContexts.Flatten() {
if listContext, ok := context.(types.IPatchExplorerContext); ok {
listContexts = append(listContexts, listContext)
}
}
return listContexts
}
func (self *ContextMgr) ContextForKey(key types.ContextKey) types.Context {
self.RLock()
defer self.RUnlock()
@@ -373,3 +386,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()
})
}
+34 -23
View File
@@ -13,30 +13,29 @@ 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
focusedMainViewDiffSource types.FocusedMainViewDiffSource
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
focusable bool
transient bool
hasControlledBounds bool
needsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel
needsRerenderOnHeightChange bool
highlightOnFocus bool
hasSelectableContent bool
*ParentContextMgr
}
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{}
@@ -49,7 +48,7 @@ type NewBaseContextOpts struct {
Focusable bool
Transient bool
HasUncontrolledBounds bool // negating for the sake of making false the default
HighlightOnFocus bool
HasSelectableContent bool
NeedsRerenderOnWidthChange types.NeedsRerenderOnWidthChangeLevel
NeedsRerenderOnHeightChange bool
@@ -70,7 +69,7 @@ func NewBaseContext(opts NewBaseContextOpts) *BaseContext {
focusable: opts.Focusable,
transient: opts.Transient,
hasControlledBounds: hasControlledBounds,
highlightOnFocus: opts.HighlightOnFocus,
hasSelectableContent: opts.HasSelectableContent,
needsRerenderOnWidthChange: opts.NeedsRerenderOnWidthChange,
needsRerenderOnHeightChange: opts.NeedsRerenderOnHeightChange,
ParentContextMgr: &ParentContextMgr{},
@@ -114,6 +113,18 @@ func (self *BaseContext) GetKind() types.ContextKind {
return self.kind
}
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
}
@@ -145,7 +156,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
self.onQuitFns = nil
self.onDoubleClickFn = nil
self.onClickFn = nil
self.onClickFocusedMainViewFn = nil
self.focusedMainViewDiffSource = nil
self.onRenderToMainFn = nil
}
@@ -167,12 +178,12 @@ 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")
func (self *BaseContext) AddFocusedMainViewDiffSource(source types.FocusedMainViewDiffSource) {
if source != nil {
if self.focusedMainViewDiffSource != nil {
panic("only one controller is allowed to set the focused main view diff source")
}
self.onClickFocusedMainViewFn = fn
self.focusedMainViewDiffSource = source
}
}
@@ -184,8 +195,8 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
return self.onClickFn
}
func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
return self.onClickFocusedMainViewFn
func (self *BaseContext) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.focusedMainViewDiffSource
}
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
+17 -6
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 },
@@ -80,10 +85,16 @@ func (self *CommitFilesContext) RefForAdjustingLineNumberInDiff() string {
}
func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) {
if refs := self.GetRefRange(); refs != nil {
return refs.From.ParentRefName(), refs.To.RefName()
return FromAndToForDiff(self.GetRef(), self.GetRefRange())
}
// FromAndToForDiff gives the two ends to diff for a ref, or for a range of them: a
// range runs from the parent of its first ref to its last, a single ref from its own
// parent to itself.
func FromAndToForDiff(ref models.Ref, refRange *types.RefRange) (string, string) {
if refRange != nil {
return refRange.From.ParentRefName(), refRange.To.RefName()
}
ref := self.GetRef()
return ref.ParentRefName(), ref.RefName()
}
+42 -58
View File
@@ -8,27 +8,23 @@ const (
// used as a nil value when passing a context key as an arg
NO_CONTEXT types.ContextKey = "none"
GLOBAL_CONTEXT_KEY types.ContextKey = "global"
STATUS_CONTEXT_KEY types.ContextKey = "status"
SNAKE_CONTEXT_KEY types.ContextKey = "snake"
FILES_CONTEXT_KEY types.ContextKey = "files"
LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches"
REMOTES_CONTEXT_KEY types.ContextKey = "remotes"
WORKTREES_CONTEXT_KEY types.ContextKey = "worktrees"
REMOTE_BRANCHES_CONTEXT_KEY types.ContextKey = "remoteBranches"
TAGS_CONTEXT_KEY types.ContextKey = "tags"
LOCAL_COMMITS_CONTEXT_KEY types.ContextKey = "commits"
REFLOG_COMMITS_CONTEXT_KEY types.ContextKey = "reflogCommits"
SUB_COMMITS_CONTEXT_KEY types.ContextKey = "subCommits"
COMMIT_FILES_CONTEXT_KEY types.ContextKey = "commitFiles"
STASH_CONTEXT_KEY types.ContextKey = "stash"
NORMAL_MAIN_CONTEXT_KEY types.ContextKey = "normal"
NORMAL_SECONDARY_CONTEXT_KEY types.ContextKey = "normalSecondary"
STAGING_MAIN_CONTEXT_KEY types.ContextKey = "staging"
STAGING_SECONDARY_CONTEXT_KEY types.ContextKey = "stagingSecondary"
PATCH_BUILDING_MAIN_CONTEXT_KEY types.ContextKey = "patchBuilding"
PATCH_BUILDING_SECONDARY_CONTEXT_KEY types.ContextKey = "patchBuildingSecondary"
MERGE_CONFLICTS_CONTEXT_KEY types.ContextKey = "mergeConflicts"
GLOBAL_CONTEXT_KEY types.ContextKey = "global"
STATUS_CONTEXT_KEY types.ContextKey = "status"
SNAKE_CONTEXT_KEY types.ContextKey = "snake"
FILES_CONTEXT_KEY types.ContextKey = "files"
LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches"
REMOTES_CONTEXT_KEY types.ContextKey = "remotes"
WORKTREES_CONTEXT_KEY types.ContextKey = "worktrees"
REMOTE_BRANCHES_CONTEXT_KEY types.ContextKey = "remoteBranches"
TAGS_CONTEXT_KEY types.ContextKey = "tags"
LOCAL_COMMITS_CONTEXT_KEY types.ContextKey = "commits"
REFLOG_COMMITS_CONTEXT_KEY types.ContextKey = "reflogCommits"
SUB_COMMITS_CONTEXT_KEY types.ContextKey = "subCommits"
COMMIT_FILES_CONTEXT_KEY types.ContextKey = "commitFiles"
STASH_CONTEXT_KEY types.ContextKey = "stash"
NORMAL_MAIN_CONTEXT_KEY types.ContextKey = "normal"
NORMAL_SECONDARY_CONTEXT_KEY types.ContextKey = "normalSecondary"
MERGE_CONFLICTS_CONTEXT_KEY types.ContextKey = "mergeConflicts"
// these shouldn't really be needed for anything but I'm giving them unique keys nonetheless
OPTIONS_CONTEXT_KEY types.ContextKey = "options"
@@ -66,10 +62,6 @@ var AllContextKeys = []types.ContextKey{
STASH_CONTEXT_KEY,
NORMAL_MAIN_CONTEXT_KEY,
NORMAL_SECONDARY_CONTEXT_KEY,
STAGING_MAIN_CONTEXT_KEY,
STAGING_SECONDARY_CONTEXT_KEY,
PATCH_BUILDING_MAIN_CONTEXT_KEY,
PATCH_BUILDING_SECONDARY_CONTEXT_KEY,
MERGE_CONFLICTS_CONTEXT_KEY,
MENU_CONTEXT_KEY,
@@ -83,35 +75,31 @@ var AllContextKeys = []types.ContextKey{
}
type ContextTree struct {
Global types.Context
Status types.Context
Snake types.Context
Files *WorkingTreeContext
Menu *MenuContext
Branches *BranchesContext
Tags *TagsContext
LocalCommits *LocalCommitsContext
CommitFiles *CommitFilesContext
Remotes *RemotesContext
Worktrees *WorktreesContext
Submodules *SubmodulesContext
RemoteBranches *RemoteBranchesContext
ReflogCommits *ReflogCommitsContext
SubCommits *SubCommitsContext
Stash *StashContext
Suggestions *SuggestionsContext
Normal *MainContext
NormalSecondary *MainContext
Staging *PatchExplorerContext
StagingSecondary *PatchExplorerContext
CustomPatchBuilder *PatchExplorerContext
CustomPatchBuilderSecondary types.Context
MergeConflicts *MergeConflictsContext
Confirmation *ConfirmationContext
Prompt *PromptContext
CommitMessage *CommitMessageContext
CommitDescription types.Context
CommandLog types.Context
Global types.Context
Status types.Context
Snake types.Context
Files *WorkingTreeContext
Menu *MenuContext
Branches *BranchesContext
Tags *TagsContext
LocalCommits *LocalCommitsContext
CommitFiles *CommitFilesContext
Remotes *RemotesContext
Worktrees *WorktreesContext
Submodules *SubmodulesContext
RemoteBranches *RemoteBranchesContext
ReflogCommits *ReflogCommitsContext
SubCommits *SubCommitsContext
Stash *StashContext
Suggestions *SuggestionsContext
Normal *MainContext
NormalSecondary *MainContext
MergeConflicts *MergeConflictsContext
Confirmation *ConfirmationContext
Prompt *PromptContext
CommitMessage *CommitMessageContext
CommitDescription types.Context
CommandLog types.Context
// display contexts
AppStatus types.Context
@@ -149,10 +137,6 @@ func (self *ContextTree) Flatten() []types.Context {
self.CommitDescription,
self.MergeConflicts,
self.StagingSecondary,
self.Staging,
self.CustomPatchBuilderSecondary,
self.CustomPatchBuilder,
self.NormalSecondary,
self.Normal,
+4 -2
View File
@@ -32,6 +32,10 @@ type ListContextTrait struct {
func (self *ListContextTrait) IsListContext() {}
func (self *ListContextTrait) HasSelectableContent() bool {
return self.list.Len() > 0
}
func (self *ListContextTrait) FocusLine(scrollIntoView bool) {
self.Context.FocusLine(scrollIntoView)
@@ -91,8 +95,6 @@ func formatListFooter(selectedLineIdx int, length int) string {
func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) {
self.FocusLine(!opts.KeepScrollPosition)
self.GetViewTrait().SetHighlight(self.list.Len() > 0)
self.Context.HandleFocus(opts)
}
+8 -3
View File
@@ -31,11 +31,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(
+50 -7
View File
@@ -8,9 +8,47 @@ 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
}
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
}
func NewMainContext(
view *gocui.View,
@@ -21,12 +59,11 @@ func NewMainContext(
ctx := &MainContext{
SimpleContext: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{
Kind: types.MAIN_CONTEXT,
View: view,
WindowName: windowName,
Key: key,
Focusable: true,
HighlightOnFocus: false,
Kind: types.MAIN_CONTEXT,
View: view,
WindowName: windowName,
Key: key,
Focusable: true,
})),
SearchTrait: NewSearchTrait(c),
}
@@ -38,5 +75,11 @@ func (self *MainContext) ModelSearchResults(searchStr string, caseSensitive bool
return nil
}
// OnSearchSelect collapses the selection to the match the search has just moved the
// cursor to. A range or a selected hunk means "these lines here", which a jump to a
// match somewhere else in the diff has nothing to do with: extending the range to the
// match, or holding on to a hunk the cursor has left, would both leave the user
// looking at a selection they didn't make.
func (self *MainContext) OnSearchSelect(int) {
self.ResetDiffSelectMode()
}
+6 -6
View File
@@ -35,12 +35,12 @@ func NewMergeConflictsContext(
viewModel: viewModel,
Context: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{
Kind: types.MAIN_CONTEXT,
View: c.Views().MergeConflicts,
WindowName: "main",
Key: MERGE_CONFLICTS_CONTEXT_KEY,
Focusable: true,
HighlightOnFocus: true,
Kind: types.MAIN_CONTEXT,
View: c.Views().MergeConflicts,
WindowName: "main",
Key: MERGE_CONFLICTS_CONTEXT_KEY,
Focusable: true,
HasSelectableContent: true,
}),
),
c: c,
-154
View File
@@ -1,154 +0,0 @@
package context
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
"github.com/jesseduffield/lazygit/pkg/gui/types"
deadlock "github.com/sasha-s/go-deadlock"
)
type PatchExplorerContext struct {
*SimpleContext
*SearchTrait
state *patch_exploring.State
viewTrait *ViewTrait
getIncludedLineIndices func() []int
c *ContextCommon
mutex deadlock.Mutex
// true if we're inside the OnSelectItem callback; in that case we don't want to update the
// search result index.
inOnSelectItemCallback bool
}
var (
_ types.IPatchExplorerContext = (*PatchExplorerContext)(nil)
_ types.ISearchableContext = (*PatchExplorerContext)(nil)
)
func NewPatchExplorerContext(
view *gocui.View,
windowName string,
key types.ContextKey,
getIncludedLineIndices func() []int,
c *ContextCommon,
) *PatchExplorerContext {
ctx := &PatchExplorerContext{
state: nil,
viewTrait: NewViewTrait(view),
c: c,
getIncludedLineIndices: getIncludedLineIndices,
SimpleContext: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: view,
WindowName: windowName,
Key: key,
Kind: types.MAIN_CONTEXT,
Focusable: true,
HighlightOnFocus: true,
NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES,
})),
SearchTrait: NewSearchTrait(c),
}
ctx.SetHandleRenderFunc(ctx.OnViewWidthChanged)
return ctx
}
func (self *PatchExplorerContext) IsPatchExplorerContext() {}
func (self *PatchExplorerContext) GetState() *patch_exploring.State {
return self.state
}
func (self *PatchExplorerContext) SetState(state *patch_exploring.State) {
self.state = state
}
func (self *PatchExplorerContext) GetViewTrait() types.IViewTrait {
return self.viewTrait
}
func (self *PatchExplorerContext) GetIncludedLineIndices() []int {
return self.getIncludedLineIndices()
}
func (self *PatchExplorerContext) RenderAndFocus() {
self.setContent()
self.FocusSelection()
self.c.Render()
}
func (self *PatchExplorerContext) Render() {
self.setContent()
self.c.Render()
}
func (self *PatchExplorerContext) setContent() {
self.GetView().SetContent(self.GetContentToRender())
}
func (self *PatchExplorerContext) FocusSelection() {
view := self.GetView()
state := self.GetState()
bufferHeight := view.InnerHeight()
_, origin := view.Origin()
numLines := view.ViewLinesHeight()
newOriginY := state.CalculateOrigin(origin, bufferHeight, numLines)
view.SetOriginY(newOriginY)
startIdx, endIdx := state.SelectedViewRange()
// As far as the view is concerned, we are always selecting a range
view.SetRangeSelectStart(startIdx)
view.SetCursorY(endIdx - newOriginY)
if !self.inOnSelectItemCallback {
view.SetNearestSearchPosition()
}
}
func (self *PatchExplorerContext) GetContentToRender() string {
if self.GetState() == nil {
return ""
}
return self.GetState().RenderForLineIndices(self.GetIncludedLineIndices())
}
func (self *PatchExplorerContext) NavigateTo(selectedLineIdx int) {
self.GetState().SetLineSelectMode()
self.GetState().SelectLine(selectedLineIdx)
self.RenderAndFocus()
}
func (self *PatchExplorerContext) GetMutex() *deadlock.Mutex {
return &self.mutex
}
func (self *PatchExplorerContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
return nil
}
func (self *PatchExplorerContext) OnSearchSelect(selectedLineIdx int) {
self.GetMutex().Lock()
defer self.GetMutex().Unlock()
self.inOnSelectItemCallback = true
self.NavigateTo(selectedLineIdx)
self.inOnSelectItemCallback = false
}
func (self *PatchExplorerContext) OnViewWidthChanged() {
if state := self.GetState(); state != nil {
state.OnViewWidthChanged(self.GetView())
self.setContent()
self.RenderAndFocus()
}
}
+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 },
-42
View File
@@ -41,48 +41,6 @@ func NewContextTree(c *ContextCommon) *ContextTree {
Suggestions: NewSuggestionsContext(c),
Normal: NewMainContext(c.Views().Main, "main", NORMAL_MAIN_CONTEXT_KEY, c),
NormalSecondary: NewMainContext(c.Views().Secondary, "secondary", NORMAL_SECONDARY_CONTEXT_KEY, c),
Staging: NewPatchExplorerContext(
c.Views().Staging,
"main",
STAGING_MAIN_CONTEXT_KEY,
func() []int { return nil },
c,
),
StagingSecondary: NewPatchExplorerContext(
c.Views().StagingSecondary,
"secondary",
STAGING_SECONDARY_CONTEXT_KEY,
func() []int { return nil },
c,
),
CustomPatchBuilder: NewPatchExplorerContext(
c.Views().PatchBuilding,
"main",
PATCH_BUILDING_MAIN_CONTEXT_KEY,
func() []int {
file := commitFilesContext.GetSelectedFile()
if file == nil {
return nil
}
includedLineIndices, err := c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath)
if err != nil {
c.Log.Error(err)
return nil
}
return includedLineIndices
},
c,
),
CustomPatchBuilderSecondary: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{
Kind: types.MAIN_CONTEXT,
View: c.Views().PatchBuildingSecondary,
WindowName: "secondary",
Key: PATCH_BUILDING_SECONDARY_CONTEXT_KEY,
Focusable: false,
}),
),
MergeConflicts: NewMergeConflictsContext(
c,
),
-5
View File
@@ -33,10 +33,6 @@ func NewDisplayContext(key types.ContextKey, view *gocui.View, windowName string
}
func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) {
if self.highlightOnFocus {
self.GetViewTrait().SetHighlight(true)
}
for _, fn := range self.onFocusFns {
fn(opts)
}
@@ -47,7 +43,6 @@ func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) {
}
func (self *SimpleContext) HandleFocusLost(opts types.OnFocusLostOpts) {
self.GetViewTrait().SetHighlight(false)
self.view.SetOriginX(0)
for _, fn := range self.onFocusLostFns {
fn(opts)
+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 {
-5
View File
@@ -43,11 +43,6 @@ func (self *ViewTrait) SetContent(content string) {
self.view.SetContent(content)
}
func (self *ViewTrait) SetHighlight(highlight bool) {
self.view.Highlight = highlight
self.view.HighlightInactive = false
}
func (self *ViewTrait) SetFooter(value string) {
self.view.Footer = value
}
+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 },
+6 -37
View File
@@ -52,8 +52,7 @@ func (gui *Gui) resetHelpersAndControllers() {
gpgHelper := helpers.NewGpgHelper(helperCommon)
viewHelper := helpers.NewViewHelper(helperCommon, gui.State.Contexts)
patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon)
stagingHelper := helpers.NewStagingHelper(helperCommon)
customPatchHelper := helpers.NewCustomPatchHelper(helperCommon)
mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon)
searchHelper := helpers.NewSearchHelper(helperCommon)
@@ -61,13 +60,12 @@ func (gui *Gui) resetHelpersAndControllers() {
helperCommon,
refsHelper,
rebaseHelper,
patchBuildingHelper,
stagingHelper,
mergeConflictsHelper,
worktreeHelper,
searchHelper,
)
diffHelper := helpers.NewDiffHelper(helperCommon)
diffLineHelper := helpers.NewDiffLineHelper(helperCommon)
diffHelper := helpers.NewDiffHelper(helperCommon, diffLineHelper)
cherryPickHelper := helpers.NewCherryPickHelper(
helperCommon,
rebaseHelper,
@@ -77,7 +75,7 @@ func (gui *Gui) resetHelpersAndControllers() {
modeHelper := helpers.NewModeHelper(
helperCommon,
diffHelper,
patchBuildingHelper,
customPatchHelper,
cherryPickHelper,
rebaseHelper,
bisectHelper,
@@ -91,8 +89,7 @@ func (gui *Gui) resetHelpersAndControllers() {
gui.helpers = &helpers.Helpers{
Refs: refsHelper,
Host: helpers.NewHostHelper(helperCommon),
PatchBuilding: patchBuildingHelper,
Staging: stagingHelper,
CustomPatch: customPatchHelper,
Bisect: bisectHelper,
Suggestions: suggestionsHelper,
Files: helpers.NewFilesHelper(helperCommon),
@@ -110,6 +107,7 @@ func (gui *Gui) resetHelpersAndControllers() {
SuspendResume: helpers.NewSuspendResumeHelper(helperCommon),
Snake: helpers.NewSnakeHelper(helperCommon),
Diff: diffHelper,
DiffLine: diffLineHelper,
Repos: reposHelper,
RecordDirectory: recordDirectoryHelper,
Update: helpers.NewUpdateHelper(helperCommon, gui.Updater),
@@ -173,18 +171,13 @@ func (gui *Gui) resetHelpersAndControllers() {
contextLinesController := controllers.NewContextLinesController(common)
renameSimilarityThresholdController := controllers.NewRenameSimilarityThresholdController(common)
verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common)
viewSelectionControllerFactory := controllers.NewViewSelectionControllerFactory(common)
branchesController := controllers.NewBranchesController(common)
gitFlowController := controllers.NewGitFlowController(common)
stashController := controllers.NewStashController(common)
commitFilesController := controllers.NewCommitFilesController(common)
patchExplorerControllerFactory := controllers.NewPatchExplorerControllerFactory(common)
stagingController := controllers.NewStagingController(common, gui.State.Contexts.Staging, gui.State.Contexts.StagingSecondary, false)
stagingSecondaryController := controllers.NewStagingController(common, gui.State.Contexts.StagingSecondary, gui.State.Contexts.Staging, true)
mainViewController := controllers.NewMainViewController(common, gui.State.Contexts.Normal, gui.State.Contexts.NormalSecondary)
secondaryViewController := controllers.NewMainViewController(common, gui.State.Contexts.NormalSecondary, gui.State.Contexts.Normal)
patchBuildingController := controllers.NewPatchBuildingController(common)
snakeController := controllers.NewSnakeController(common)
reflogCommitsController := controllers.NewReflogCommitsController(common)
subCommitsController := controllers.NewSubCommitsController(common)
@@ -281,28 +274,6 @@ func (gui *Gui) resetHelpersAndControllers() {
)
// TODO: add scroll controllers for main panels (need to bring some more functionality across for that e.g. reading more from the currently displayed git command)
controllers.AttachControllers(gui.State.Contexts.Staging,
stagingController,
patchExplorerControllerFactory.Create(gui.State.Contexts.Staging),
verticalScrollControllerFactory.Create(gui.State.Contexts.Staging),
)
controllers.AttachControllers(gui.State.Contexts.StagingSecondary,
stagingSecondaryController,
patchExplorerControllerFactory.Create(gui.State.Contexts.StagingSecondary),
verticalScrollControllerFactory.Create(gui.State.Contexts.StagingSecondary),
)
controllers.AttachControllers(gui.State.Contexts.CustomPatchBuilder,
patchBuildingController,
patchExplorerControllerFactory.Create(gui.State.Contexts.CustomPatchBuilder),
verticalScrollControllerFactory.Create(gui.State.Contexts.CustomPatchBuilder),
)
controllers.AttachControllers(gui.State.Contexts.CustomPatchBuilderSecondary,
verticalScrollControllerFactory.Create(gui.State.Contexts.CustomPatchBuilderSecondary),
)
controllers.AttachControllers(gui.State.Contexts.MergeConflicts,
mergeConflictsController,
)
@@ -310,13 +281,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 -1
View File
@@ -8,7 +8,7 @@ func AttachControllers(context types.Context, controllers ...types.IController)
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
context.AddOnClickFn(controller.GetOnClick())
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
context.AddFocusedMainViewDiffSource(controller.GetFocusedMainViewDiffSource())
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
context.AddOnFocusFn(controller.GetOnFocus())
context.AddOnFocusLostFn(controller.GetOnFocusLost())
+2 -2
View File
@@ -19,11 +19,11 @@ func (self *baseController) GetOnDoubleClick() func() error {
return nil
}
func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
return nil
}
func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
func (self *baseController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return nil
}
+449
View File
@@ -0,0 +1,449 @@
package controllers
import (
"fmt"
"path/filepath"
"strings"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// CommitDiffActions is what a panel showing a commit's diff offers on that diff in the
// focused main view. Five panels do: the commit files panel shows the diff of one file
// of a commit, and the commits, sub-commits, stash and reflog panels the whole diff of
// whatever they have selected. What they offer is the same for all of them, differing
// only in which diff it is — so they share this, each saying what it is showing.
type CommitDiffActions struct {
c *ControllerCommon
// The panel this belongs to, and what it is showing the diff of — nil when it has
// nothing selected, and so no diff.
panel types.Context
target func() *commitDiffTarget
}
// commitDiffTarget is the diff a panel is showing: the two ends of it, and whether it
// belongs to a commit lazygit may rewrite.
type commitDiffTarget struct {
from string
to string
canRebase bool
}
var _ types.FocusedMainViewActions = &CommitDiffActions{}
func NewCommitDiffActions(
c *ControllerCommon, panel types.Context, target func() *commitDiffTarget,
) *CommitDiffActions {
return &CommitDiffActions{c: c, panel: panel, target: target}
}
// PlainDiff hands out the diff the asking pane is showing, for the given files — the
// commit's diff as in the main view, only without the commit's message and stat above it,
// or the diff the custom patch is previewed as, whose lines are the patch's own rather
// than the commit's.
//
// The patch's own diff is handed out whole: it is only ever as big as the patch, and it
// names its files under the trees the patch was materialized into rather than under the
// paths asked for.
func (self *CommitDiffActions) PlainDiff(pane types.DiffPaneContext, paths []string) string {
if self.showsCustomPatch(pane) {
return self.customPatchDiff()
}
target := self.target()
if target == nil {
return ""
}
return self.c.Helpers().Diff.PlainDiffBetweenRefs(target.from, target.to, paths)
}
// customPatchDiff is the diff the custom patch is previewed as, as git writes it — the
// diff behind what the pane previewing the patch shows, in which the lines shown there
// can be found again.
func (self *CommitDiffActions) customPatchDiff() string {
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
if treesDir == "" {
return ""
}
// An error means the two trees differ, which is what a patch with anything in it looks
// like; the diff itself is what we are after either way.
diff, _ := self.c.Git().Diff.
CustomPatchDiffCmdObj(treesDir, git_commands.DiffModePlain).
RunWithOutput()
return diff
}
// PrimaryAction takes the selected lines into the custom patch being built from this
// diff, or back out of it when the first of them is already in — the same toggling the
// commit files panel does to a whole file at a time.
//
// The commit is not touched, so the diff stays as it is: what changes is the patch
// beside it, and which of its lines are marked as being in that patch.
func (self *CommitDiffActions) PrimaryAction(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error {
// In the pane showing the patch, the lines are the patch's own, so there they only
// come back out of it.
if self.showsCustomPatch(pane) {
return self.removePatchLines(pane, firstLineIdx, lastLineIdx)
}
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
target := self.target()
if target == nil {
return nil
}
lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
if len(lines) == 0 {
return nil
}
patchBuilder := self.c.Git().Patch.PatchBuilder
from, reverse := self.patchEndpoints(target)
// A patch is built from one diff, so building from another one means giving up the
// patch there is — which the user is asked about, as entering the patch builder asks.
mustDiscardPatch := patchBuilder.Active() && patchBuilder.NewPatchRequired(from, target.to, reverse)
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
Title: self.c.Tr.DiscardPatch,
Prompt: self.c.Tr.DiscardPatchConfirm,
HandleConfirm: func() error {
if mustDiscardPatch {
patchBuilder.Reset()
}
if !patchBuilder.Active() {
patchBuilder.Start(from, target.to, reverse, target.canRebase)
}
if err := self.togglePatchLines(lines); err != nil {
return err
}
// Taking the last line back out ends the patch rather than leaving an empty
// one, so that the pane previewing it and the marks over the diff go with it.
if patchBuilder.IsEmpty() {
patchBuilder.Reset()
}
// The diff on screen is the one the marks belong to, so they can be brought up
// to date at once rather than waiting for the render below.
self.c.Helpers().DiffLine.RefreshInclusionGutter()
// The selection moves on past the lines just toggled, to the next change of
// the diff — which is still there, a toggle leaving the diff as it was, so
// hold input back until it has moved: a second press meanwhile would toggle
// the same lines straight back.
self.c.GocuiGui().BeginBlockingEvents()
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, pane, firstLineIdx, len(lines),
func() { _ = self.c.GocuiGui().EndBlockingEvents() })
// The panel's own render, which is all that is needed: the marks over the diff
// and the patch previewed beside it have changed, while the commit has not.
self.c.PostRefreshUpdate(self.panel)
return nil
},
})
}
// removePatchLines takes the selected lines of the custom patch out of it, which is what
// the primary action does in the pane showing the patch: everything shown there is in the
// patch already, so there is nothing else it could mean.
//
// A line of the patch is named by which of its file's changes it is, counted in the diff
// the patch is shown as, which is the same place that line has among the file's changes
// the patch holds. Line numbers would not do: a patch that leaves an earlier addition out
// numbers everything after it differently from the commit's diff.
func (self *CommitDiffActions) removePatchLines(
pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int,
) error {
lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
if len(lines) == 0 {
return nil
}
patchBuilder := self.c.Git().Patch.PatchBuilder
previousPaths := self.previousPaths()
for path, ordinals := range self.c.Helpers().DiffLine.ChangeLineOrdinals(self.customPatchDiff(), lines) {
filename := self.patchBuilderPath(path)
if filename == "" {
continue
}
included := patchBuilder.IncludedChangeLineIndices(filename)
indices := []int{}
for _, ordinal := range ordinals {
if ordinal < len(included) {
indices = append(indices, included[ordinal])
}
}
if len(indices) == 0 {
continue
}
if err := patchBuilder.RemoveFileLineRange(filename, previousPaths[filename], indices); err != nil {
return err
}
}
// Taking the last line out ends the patch rather than leaving an empty one, as it does
// in the diff beside this pane.
if patchBuilder.IsEmpty() {
patchBuilder.Reset()
}
self.c.Helpers().DiffLine.RefreshInclusionGutter()
// The lines are gone from the patch, so the selection carries on from where they were,
// as unstaging leaves it. Input is held until it has moved, so that a second press acts
// on the patch as it now is.
self.c.GocuiGui().BeginBlockingEvents()
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, pane, firstLineIdx, 0,
func() { _ = self.c.GocuiGui().EndBlockingEvents() })
self.c.PostRefreshUpdate(self.panel)
return nil
}
// DiscardSelection takes the selected lines out of the commit they are part of, by
// building a patch of exactly those lines and removing that patch from the commit. It is
// a rebase, so a later commit that touches the same lines can conflict with it.
//
// The patch it needs is its own, so a patch being built is given up first — which the
// prompt says, there being no way to get it back.
func (self *CommitDiffActions) DiscardSelection(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error {
target := self.target()
if target == nil {
return nil
}
lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
if len(lines) == 0 {
return nil
}
commitIndex := self.indexOfTargetCommit(target)
if commitIndex == -1 {
return nil
}
patchBuilder := self.c.Git().Patch.PatchBuilder
prompt := lo.Ternary(patchBuilder.IsEmpty(),
self.c.Tr.DiscardLinesFromCommitPrompt,
self.c.Tr.DiscardLinesFromCommitPromptWithReset)
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.DiscardLinesFromCommitTitle,
Prompt: prompt,
HandleConfirm: func() error {
from, reverse := self.patchEndpoints(target)
patchBuilder.Reset()
patchBuilder.Start(from, target.to, reverse, target.canRebase)
if err := self.togglePatchLines(lines); err != nil {
return err
}
if patchBuilder.IsEmpty() {
return nil
}
// The rebase runs on a worker, which may not read the model, so the commits
// it rewrites are taken here.
commits := self.c.Model().Commits
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.RebasingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
})
},
})
return nil
}
// DiscardSelectionDisabledReason says why the selected lines can't be taken out of the
// commit: doing so rewrites it, which is only ours to do for a commit of the branch we
// are on, and not while a rebase is already under way. In the pane previewing the custom
// patch there is nothing to discard from — the lines there are the patch's, and space
// takes them back out of it.
func (self *CommitDiffActions) DiscardSelectionDisabledReason(pane types.DiffPaneContext) *types.DisabledReason {
if self.showsCustomPatch(pane) {
return &types.DisabledReason{Text: self.c.Tr.CannotDiscardFromCustomPatchView, ShowErrorInPanel: true}
}
target := self.target()
if target == nil || !target.canRebase {
return &types.DisabledReason{Text: self.c.Tr.CanOnlyDiscardFromLocalCommits, ShowErrorInPanel: true}
}
if self.c.Git().Status.WorkingTreeState().Any() {
return &types.DisabledReason{Text: self.c.Tr.CantPatchWhileRebasingError, ShowErrorInPanel: true}
}
if self.c.UserConfig().Git.DiffContextSize == 0 {
return &types.DisabledReason{
Text: fmt.Sprintf(self.c.Tr.Actions.NotEnoughContextToRemoveLines,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView),
ShowErrorInPanel: true,
}
}
return nil
}
// PatchInclusion says which lines of the commit's diff are in the custom patch being
// built from it. nil when there is no such patch: none is being built at all, or the one
// being built is of another diff, whose lines are not these however alike they look.
func (self *CommitDiffActions) PatchInclusion() func(types.DiffLineInfo) bool {
patchBuilder := self.c.Git().Patch.PatchBuilder
target := self.target()
if !patchBuilder.Active() || target == nil {
return nil
}
from, reverse := self.patchEndpoints(target)
if patchBuilder.NewPatchRequired(from, target.to, reverse) {
return nil
}
// Which lines of a file are in the patch is asked of the patch builder per file, and
// a diff can span many, so each is asked about when a line of it first comes up.
includedByPath := map[string]*set.Set[patch.LineIdentity]{}
return func(info types.DiffLineInfo) bool {
path := self.patchBuilderPath(info.Path)
if path == "" {
return false
}
included, asked := includedByPath[path]
if !asked {
included = set.NewFromSlice(patchBuilder.IncludedLineIdentities(path))
includedByPath[path] = included
}
return included.Includes(info.PatchLineIdentity())
}
}
// togglePatchLines takes the given lines of the commit's diff into the custom patch, or
// out of it. Which of the two it is is decided once, by the first line of the selection:
// pointing at a line that is already in the patch takes the whole selection out of it,
// as toggling a selection of files in the commit files panel does.
func (self *CommitDiffActions) togglePatchLines(lines []types.DiffLineInfo) error {
patchBuilder := self.c.Git().Patch.PatchBuilder
// The files the selection covers, in the order the diff shows them, and per file the
// lines of it that are selected: a patch is built a file at a time, while a selection
// can span several of them.
paths := []string{}
linesByPath := map[string][]patch.LineIdentity{}
for _, line := range lines {
path := self.patchBuilderPath(line.Path)
if path == "" {
continue
}
if _, seen := linesByPath[path]; !seen {
paths = append(paths, path)
}
linesByPath[path] = append(linesByPath[path], line.PatchLineIdentity())
}
if len(paths) == 0 {
return nil
}
previousPaths := self.previousPaths()
indicesByPath := map[string][]int{}
wholeFileByPath := map[string]bool{}
for _, path := range paths {
indices, err := patchBuilder.PatchLineIndicesForLines(path, previousPaths[path], linesByPath[path])
if err != nil {
return err
}
indicesByPath[path] = indices
wholeFile, err := patchBuilder.SelectionRepresentsWholeFile(path, previousPaths[path], linesByPath[path])
if err != nil {
return err
}
wholeFileByPath[path] = wholeFile
}
included, err := patchBuilder.GetFileIncLineIndices(paths[0], previousPaths[paths[0]])
if err != nil {
return err
}
removing := len(indicesByPath[paths[0]]) > 0 && lo.Contains(included, indicesByPath[paths[0]][0])
for _, path := range paths {
if len(indicesByPath[path]) == 0 {
continue
}
var err error
switch {
case wholeFileByPath[path] && removing:
err = patchBuilder.RemoveFile(path, previousPaths[path])
case wholeFileByPath[path]:
err = patchBuilder.AddFileWhole(path, previousPaths[path])
case removing:
err = patchBuilder.RemoveFileLineRange(path, previousPaths[path], indicesByPath[path])
default:
err = patchBuilder.AddFileLineRange(path, previousPaths[path], indicesByPath[path])
}
if err != nil {
return err
}
}
return nil
}
// previousPaths says which files of the diff were renamed, and what they were called
// before. A renamed file's diff only comes out as a rename when git is asked about both
// of its paths, and its lines are numbered in the file under its old name, so the patch
// builder has to be told the old path along with them.
func (self *CommitDiffActions) previousPaths() map[string]string {
target := self.target()
if target == nil {
return nil
}
from, reverse := self.patchEndpoints(target)
files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, target.to, reverse)
if err != nil {
return nil
}
previousPaths := map[string]string{}
for _, file := range files {
if file.PreviousPath != "" {
previousPaths[file.Path] = file.PreviousPath
}
}
return previousPaths
}
// patchEndpoints gives the two ends of the diff a patch is built from. They are the ends
// of the diff shown, except in diffing mode, where what is shown is a diff against
// another ref, possibly the other way around.
func (self *CommitDiffActions) patchEndpoints(target *commitDiffTarget) (string, bool) {
return self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(target.from)
}
// patchBuilderPath turns the absolute path a diff line carries into the repo-relative
// one the patch builder keys a file by, and "" for a path that is no file of this repo.
func (self *CommitDiffActions) patchBuilderPath(path string) string {
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
if err != nil || strings.HasPrefix(relativePath, "..") {
return ""
}
return filepath.ToSlash(relativePath)
}
// indexOfTargetCommit finds the commit the diff belongs to among the commits of the
// branch we are on, which is how a rebase is told which commit to rewrite. -1 when it
// isn't one of them, in which case there is nothing we can rewrite.
func (self *CommitDiffActions) indexOfTargetCommit(target *commitDiffTarget) int {
return lo.IndexOf(
lo.Map(self.c.Model().Commits, func(commit *models.Commit, _ int) string { return commit.Hash() }),
target.to)
}
// showsCustomPatch reports whether the given main pane is the one previewing the custom
// patch being built, rather than the commit's diff — which for a commit's diff is always
// the lower one.
func (self *CommitDiffActions) showsCustomPatch(pane types.DiffPaneContext) bool {
return pane.GetKey() == self.c.Contexts().NormalSecondary.GetKey()
}
+30 -46
View File
@@ -23,6 +23,9 @@ type CommitFilesController struct {
baseController
*ListControllerTrait[*filetree.CommitFileNode]
c *ControllerCommon
// what this panel offers on the diff it shows in the focused main view
diffActions *CommitDiffActions
}
var _ types.IController = &CommitFilesController{}
@@ -30,7 +33,7 @@ var _ types.IController = &CommitFilesController{}
func NewCommitFilesController(
c *ControllerCommon,
) *CommitFilesController {
return &CommitFilesController{
controller := &CommitFilesController{
baseController: baseController{},
c: c,
ListControllerTrait: NewListControllerTrait(
@@ -40,6 +43,18 @@ func NewCommitFilesController(
c.Contexts().CommitFiles.GetSelectedItems,
),
}
controller.diffActions = NewCommitDiffActions(c, c.Contexts().CommitFiles, controller.diffTarget)
return controller
}
// diffTarget is the commit whose files this panel is showing, which is what its main
// view shows the diff of.
func (self *CommitFilesController) diffTarget() *commitDiffTarget {
if self.context().GetRef() == nil && self.context().GetRefRange() == nil {
return nil
}
from, to := self.context().GetFromAndToForDiff()
return &commitDiffTarget{from: from, to: to, canRebase: self.context().GetCanRebase()}
}
func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
@@ -109,8 +124,8 @@ func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
Handler: self.withItem(self.enter),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.EnterCommitFile,
Tooltip: self.c.Tr.EnterCommitFileTooltip,
Description: self.c.Tr.FocusCommitFileDiff,
Tooltip: self.c.Tr.FocusCommitFileDiffTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Files.ToggleTreeView),
@@ -175,9 +190,10 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
from, to := self.context().GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
paths := self.pathsForDiff(node)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false)
task := types.NewRunPtyTask(cmdObj.GetCmd())
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, mode)
task := types.NewMainViewDiffTask(cmdObj.GetCmd(), mode)
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
@@ -191,11 +207,15 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
}
}
func (self *CommitFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.diffActions
}
func (self *CommitFilesController) copyDiffToClipboard(paths []string, toastMessage string) error {
from, to := self.context().GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, true)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, git_commands.DiffModePlain)
diff, err := cmdObj.RunWithOutput()
if err != nil {
return err
@@ -537,41 +557,15 @@ func (self *CommitFilesController) currentFromToReverseForPatchBuilding() (strin
}
func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error {
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
}
func (self *CommitFilesController) enterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error {
if node.File == nil {
return self.handleToggleCommitFileDirCollapsed(node)
}
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
return focusMainView(self.c, self.context(), -1)
}
from, to, reverse := self.currentFromToReverseForPatchBuilding()
mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse)
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
Title: self.c.Tr.DiscardPatch,
Prompt: self.c.Tr.DiscardPatchConfirm,
HandleConfirm: func() error {
if mustDiscardPatch {
self.c.Git().Patch.PatchBuilder.Reset()
}
if !self.c.Git().Patch.PatchBuilder.Active() {
if err := self.startPatchBuilder(); err != nil {
return err
}
}
self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts)
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
return nil
},
})
func (self *CommitFilesController) GetOnDoubleClick() func() error {
return self.withItemGraceful(self.enter)
}
func (self *CommitFilesController) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) error {
@@ -606,16 +600,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())
@@ -1,11 +1,9 @@
package controllers
import (
"errors"
"fmt"
"math"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -51,10 +49,6 @@ func (self *ContextLinesController) Context() types.Context {
}
func (self *ContextLinesController) Increase() error {
if err := self.checkCanChangeContext(); err != nil {
return err
}
if self.c.UserConfig().Git.DiffContextSize < math.MaxUint64 {
self.c.UserConfig().Git.DiffContextSize++
}
@@ -62,10 +56,6 @@ func (self *ContextLinesController) Increase() error {
}
func (self *ContextLinesController) Decrease() error {
if err := self.checkCanChangeContext(); err != nil {
return err
}
if self.c.UserConfig().Git.DiffContextSize > 0 {
self.c.UserConfig().Git.DiffContextSize--
}
@@ -76,22 +66,11 @@ func (self *ContextLinesController) applyChange() error {
self.c.Toast(fmt.Sprintf(self.c.Tr.DiffContextSizeChanged, self.c.UserConfig().Git.DiffContextSize))
currentContext := self.c.Context().CurrentSide()
switch currentContext.GetKey() {
// we make an exception for our staging and patch building contexts because they actually need to refresh their state afterwards.
case context.PATCH_BUILDING_MAIN_CONTEXT_KEY:
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.PATCH_BUILDING}})
case context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY:
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STAGING}})
default:
currentContext.HandleRenderToMain()
}
return nil
}
func (self *ContextLinesController) checkCanChangeContext() error {
if self.c.Git().Patch.PatchBuilder.Active() {
return errors.New(self.c.Tr.CantChangeContextSizeError)
}
// The diff is about to be rendered again with more or less context around
// each change, which reads as the lines you were looking at moving up or down
// the view; keep them where they are instead.
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
currentContext.HandleRenderToMain()
return nil
}
@@ -30,7 +30,7 @@ func (self *CustomPatchOptionsMenuAction) Call() error {
{
Label: self.c.Tr.ResetPatch,
Tooltip: self.c.Tr.ResetPatchTooltip,
OnPress: self.c.Helpers().PatchBuilding.Reset,
OnPress: self.c.Helpers().CustomPatch.Reset,
Keys: menuKey('c'),
},
{
@@ -123,15 +123,7 @@ func (self *CustomPatchOptionsMenuAction) getPatchCommitIndex() int {
return -1
}
func (self *CustomPatchOptionsMenuAction) returnFocusFromPatchExplorerIfNecessary() {
if self.c.Context().Current().GetKey() == self.c.Contexts().CustomPatchBuilder.GetKey() {
self.c.Helpers().PatchBuilding.Escape()
}
}
func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error {
self.returnFocusFromPatchExplorerIfNecessary()
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
@@ -142,8 +134,6 @@ func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error {
}
func (self *CustomPatchOptionsMenuAction) handleMovePatchToSelectedCommit() error {
self.returnFocusFromPatchExplorerIfNecessary()
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
toCommitIndex := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
@@ -155,8 +145,6 @@ func (self *CustomPatchOptionsMenuAction) handleMovePatchToSelectedCommit() erro
}
func (self *CustomPatchOptionsMenuAction) handleMovePatchIntoWorkingTree() error {
self.returnFocusFromPatchExplorerIfNecessary()
mustStash := self.c.Helpers().WorkingTree.IsWorkingTreeDirtyExceptSubmodules()
return self.c.ConfirmIf(mustStash, types.ConfirmOpts{
Title: self.c.Tr.MustStashTitle,
@@ -174,8 +162,6 @@ func (self *CustomPatchOptionsMenuAction) handleMovePatchIntoWorkingTree() error
}
func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error {
self.returnFocusFromPatchExplorerIfNecessary()
commitIndex := self.getPatchCommitIndex()
self.c.Helpers().Commits.OpenCommitMessagePanel(
&helpers.OpenCommitMessagePanelOpts{
@@ -209,8 +195,6 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error {
}
func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() error {
self.returnFocusFromPatchExplorerIfNecessary()
commitIndex := self.getPatchCommitIndex()
self.c.Helpers().Commits.OpenCommitMessagePanel(
&helpers.OpenCommitMessagePanelOpts{
@@ -244,8 +228,6 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() e
}
func (self *CustomPatchOptionsMenuAction) handleApplyPatch(reverse bool) error {
self.returnFocusFromPatchExplorerIfNecessary()
affectedUnstagedFiles := self.getAffectedUnstagedFiles()
mustStageFiles := len(affectedUnstagedFiles) > 0
+43
View File
@@ -0,0 +1,43 @@
package controllers
import (
"strings"
"github.com/samber/lo"
)
// Removes '+' or '-' from the beginning of each line in the diff string, except
// when both '+' and '-' lines are present, or diff header lines, in which case
// the diff is returned unchanged. This is useful for copying parts of diffs to
// the clipboard in order to paste them into code.
func dropDiffPrefix(diff string) string {
lines := strings.Split(strings.TrimRight(diff, "\n"), "\n")
const (
PLUS int = iota
MINUS
CONTEXT
OTHER
)
linesByType := lo.GroupBy(lines, func(line string) int {
switch {
case strings.HasPrefix(line, "+"):
return PLUS
case strings.HasPrefix(line, "-"):
return MINUS
case strings.HasPrefix(line, " "):
return CONTEXT
}
return OTHER
})
hasLinesOfType := func(lineType int) bool { return len(linesByType[lineType]) > 0 }
keepPrefix := hasLinesOfType(OTHER) || (hasLinesOfType(PLUS) && hasLinesOfType(MINUS))
if keepPrefix {
return diff
}
return strings.Join(lo.Map(lines, func(line string, _ int) string { return line[1:] + "\n" }), "")
}
+38 -42
View File
@@ -21,6 +21,9 @@ type FilesController struct {
baseController
*ListControllerTrait[*filetree.FileNode]
c *ControllerCommon
// what this panel offers on the diff it shows in the focused main view
diffActions *WorkingTreeDiffActions
}
var _ types.IController = &FilesController{}
@@ -36,6 +39,7 @@ func NewFilesController(
c.Contexts().Files.GetSelected,
c.Contexts().Files.GetSelectedItems,
),
diffActions: NewWorkingTreeDiffActions(c),
}
}
@@ -348,7 +352,7 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr))
if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" {
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()})
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}, git_commands.DiffModeRendered)
prefix := message + "\n\n"
if node.File.ShortStatus == "DU" {
prefix += self.c.Tr.MergeConflictIncomingDiff
@@ -366,56 +370,52 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
self.c.Helpers().MergeConflicts.ResetMergeState()
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
mainShowsStaged := !split && node.GetHasStagedChanges()
// The unstaged side of a file's diff is shown in the main pane and the staged side
// in the secondary one, each only where there is a side to show — so a side is
// always in the same place, whatever the file happens to have. A file with nothing
// unstaged therefore shows its staged changes in the secondary pane, which then has
// the whole section to itself. Configured to always split, both panes are shown
// whether or not there is anything on either side.
alwaysSplit := self.c.UserConfig().Gui.SplitDiff == "always"
showStaged := node.GetHasStagedChanges() || alwaysSplit
showUnstaged := node.GetHasUnstagedChanges() || alwaysSplit || !showStaged
// While the main view is focused to act on this diff, it may have to be git's own
// rather than the diff renderer's; both panes have to agree about that.
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
paths := self.pathsForDiff(node)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths)
title := self.c.Tr.UnstagedChanges
if mainShowsStaged {
title = self.c.Tr.StagedChanges
}
refreshOpts := types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: title,
},
}
refreshOpts := types.RefreshMainOpts{Pair: self.c.MainViewPairs().Normal}
if split {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths)
title := self.c.Tr.StagedChanges
if mainShowsStaged {
title = self.c.Tr.UnstagedChanges
if showUnstaged {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, mode, false, paths)
refreshOpts.Main = &types.ViewUpdateOpts{
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: self.c.Tr.UnstagedChanges,
NothingToActOn: !node.GetHasUnstagedChanges(),
}
}
if showStaged {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, mode, true, paths)
refreshOpts.Secondary = &types.ViewUpdateOpts{
Title: title,
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: self.c.Tr.StagedChanges,
NothingToActOn: !node.GetHasStagedChanges(),
}
}
self.c.RenderToMainViews(refreshOpts)
}
func (self *FilesController) GetOnDoubleClick() func() error {
return self.withItemGraceful(func(node *filetree.FileNode) error {
return self.press([]*filetree.FileNode{node})
})
func (self *FilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.diffActions
}
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
}
func (self *FilesController) GetOnDoubleClick() func() error {
return self.enter
}
// if we are dealing with a status for which there is no key in this map,
@@ -737,11 +737,7 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
return self.switchToMerge()
}
context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging)
self.c.Context().Push(context, opts)
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
return nil
return focusMainView(self.c, self.context(), opts.ClickedViewLineIdx)
}
// conflictResolutionHint formats a conflict description for the main view,
+4
View File
@@ -190,6 +190,10 @@ func (self *GlobalController) onDiffRenderersChanged() {
if currentSide.GetKey() == currentKey ||
currentKey == context.NORMAL_MAIN_CONTEXT_KEY ||
currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY {
// The new renderer lays the same diff out its own way, so the line you were
// looking at ends up elsewhere in the view; keep it in front of you.
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
currentSide.HandleRenderToMain()
}
@@ -0,0 +1,20 @@
package helpers
import "github.com/jesseduffield/lazygit/pkg/gui/types"
type CustomPatchHelper struct {
c *HelperCommon
}
func NewCustomPatchHelper(c *HelperCommon) *CustomPatchHelper {
return &CustomPatchHelper{c: c}
}
func (self *CustomPatchHelper) Reset() error {
self.c.Git().Patch.PatchBuilder.Reset()
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.COMMIT_FILES},
})
self.c.PostRefreshUpdate(self.c.Context().Current())
return nil
}
+29 -10
View File
@@ -15,11 +15,15 @@ import (
type DiffHelper struct {
c *HelperCommon
// diffLineHelper says how a diff for the main view is to be produced, which depends
// on whether the focused main view could act on what a diff renderer would make of it.
diffLineHelper *DiffLineHelper
}
func NewDiffHelper(c *HelperCommon) *DiffHelper {
func NewDiffHelper(c *HelperCommon, diffLineHelper *DiffLineHelper) *DiffHelper {
return &DiffHelper{
c: c,
c: c,
diffLineHelper: diffLineHelper,
}
}
@@ -53,6 +57,8 @@ func (self *DiffHelper) DiffArgs() []string {
// either there's no range, or it can't be diffed for some reason), then we want
// to fall back to rendering the diff for the single commit.
func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Commit, refRange *types.RefRange) types.UpdateTask {
mode := self.diffLineHelper.MainViewDiffMode()
if refRange != nil {
from, to := refRange.From, refRange.To
args := []string{from.ParentRefName(), to.RefName(), "--stat", "-p"}
@@ -72,13 +78,26 @@ func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Comm
args = append(args, filterPath)
}
}
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
cmdObj := self.c.Git().Diff.DiffCmdObj(args, mode)
prefix := style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName())
return types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
return types.NewMainViewDiffTaskWithPrefix(cmdObj.GetCmd(), prefix, mode)
}
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit))
return types.NewRunPtyTask(cmdObj.GetCmd())
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit), mode)
return types.NewMainViewDiffTask(cmdObj.GetCmd(), mode)
}
// PlainDiffBetweenRefs returns the diff of the given files between two refs as git
// writes it, without colour or a diff renderer's involvement — what a panel showing
// a commit's diff hands out as the diff behind its rendering (see
// types.FocusedMainViewDiffSource). It honours diffing mode, so that the diff is of
// the same two ends the main view is showing.
func (self *DiffHelper) PlainDiffBetweenRefs(from string, to string, paths []string) string {
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
// An error means there is no diff to be had, which for our purposes is the same
// as an empty one.
diff, _ := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, git_commands.DiffModePlain).RunWithOutput()
return diff
}
func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string {
@@ -100,7 +119,7 @@ func (self *DiffHelper) ExitDiffMode() error {
func (self *DiffHelper) RenderDiff() {
args := self.DiffArgs()
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
cmdObj := self.c.Git().Diff.DiffCmdObj(args, git_commands.DiffModeRendered)
prefix := style.FgMagenta.Sprintf(
"%s %s\n\n",
self.c.Tr.ShowingGitDiff,
@@ -192,7 +211,7 @@ func (self *DiffHelper) OpenDiffToolForRef(selectedRef models.Ref) error {
// AdjustLineNumber is used to adjust a line number in the diff that's currently
// being viewed, so that it corresponds to the line number in the actual working
// copy state of the file. It is used when clicking on a delta hyperlink in a
// diff, or when pressing `e` in the staging or patch building panels. It works
// diff, or when pressing `e` in a focused diff. It works
// by getting a diff of what's being viewed in the main view against the working
// copy, and then using that diff to adjust the line number.
// path is the file path of the file being viewed
@@ -203,7 +222,7 @@ func (self *DiffHelper) OpenDiffToolForRef(selectedRef models.Ref) error {
func (self *DiffHelper) AdjustLineNumber(path string, linenumber int, viewname string) int {
switch viewname {
case "main", "patchBuilding":
case "main":
if diffableContext, ok := self.c.Context().CurrentSide().(types.DiffableContext); ok {
ref := diffableContext.RefForAdjustingLineNumberInDiff()
if len(ref) != 0 {
@@ -214,7 +233,7 @@ func (self *DiffHelper) AdjustLineNumber(path string, linenumber int, viewname s
// unstaged changes view of the Files panel; no need to adjust line
// numbers in this case
case "secondary", "stagingSecondary":
case "secondary":
return self.adjustLineNumber(linenumber, "--", path)
}
@@ -0,0 +1,175 @@
package helpers
import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type DiffLineHelper struct {
c *HelperCommon
// What the probe said about the diff renderer that rendererSignature names, or nil
// before it has been asked about any (see diffRendererEmitsMetadata).
rendererEmitsMetadata *bool
rendererSignature string
}
func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper {
return &DiffLineHelper{c: c}
}
// GetDiffLineInfo recovers the identity — file, kind, and old/new line number —
// of the diff row at the given (wrapped) view line of the given view. It is the
// seam every consumer of a diff row goes through, so that how we recover that
// identity can change without them noticing.
//
// There are two ways. A diff renderer that speaks the OSC 1717 protocol states
// the identity of each line it renders, which is the only way to recover it from
// a rendering that doesn't look like a diff any more — columns, or +/- markers
// replaced by colour. Otherwise we parse the view's contents as a unified diff,
// which works for the renderings that keep a diff's structure (no renderer, `git
// diff --color`, a renderer that only colorizes) and fails for the rest.
//
// ok is false when the row's identity can't be recovered, in which case the
// caller must not act on the line at all.
func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) {
identities, ok := self.diffLineIdentitiesAt(view, viewLineIdx)
if !ok {
return types.DiffLineInfo{}, false
}
return identities[0], true
}
// diffLineIdentitiesAt recovers every diff line the row at the given (wrapped) view
// line shows, left to right. It is GetDiffLineInfo's form for a reader that can't
// settle for the line the row leads with: an end of a selection covers its whole
// row, so where a rendering puts a modification's two halves side by side it covers
// both of them. ok is false when the row's identity can't be recovered at all.
func (self *DiffLineHelper) diffLineIdentitiesAt(
view *gocui.View, viewLineIdx int,
) ([]types.DiffLineInfo, bool) {
// The cursor and clicks land on a view line, which counts wrapped segments;
// the contents are indexed by unwrapped buffer line.
bufferLineIdx, ok := view.BufferLineForViewLine(viewLineIdx)
if !ok {
return nil, false
}
contents := view.DiffLineContents()
if bufferLineIdx >= len(contents) {
return nil, false
}
if identities := self.diffLineIdentitiesFromRecords(contents[bufferLineIdx].Metadata); len(identities) > 0 {
return self.inRepoTerms(view, identities), true
}
parsed, ok := parseDiffLineFromBuffer(diffLineTexts(contents), bufferLineIdx)
if !ok {
return nil, false
}
return self.inRepoTerms(view, []types.DiffLineInfo{self.diffLineInfo(parsed)}), true
}
// diffLineInfoFromRecords recovers a row's identity from the records the diff
// renderer stated for it, and is what takes precedence over the buffer parse. ok is
// false when the row carries no record we understand, leaving the caller to parse.
//
// 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) {
identities := self.diffLineIdentitiesFromRecords(metadata)
if len(identities) == 0 {
return types.DiffLineInfo{}, false
}
return identities[0], true
}
// diffLineIdentitiesFromRecords recovers the identity of every diff line the row's
// records state, left to right. Which of them a reader is after depends on the
// reader: the one the row leads with is the row's own identity (see
// diffLineInfoFromRecords), while a reader looking for a particular line has to
// consider them all, since which of a modification's two halves leads a row is up to
// the rendering.
func (self *DiffLineHelper) diffLineIdentitiesFromRecords(metadata []string) []types.DiffLineInfo {
identities := make([]types.DiffLineInfo, 0, len(metadata))
for _, record := range metadata {
if parsed, ok := parseDiffLineMetadata(record); ok {
identities = append(identities, self.diffLineInfo(parsed))
}
}
return identities
}
// 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). 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 and the per-row metadata takes precedence on top.
func (self *DiffLineHelper) resolveDiffLines(contents []gocui.DiffLineContent) []resolvedDiffLine {
bufferParsed := parseAllDiffLinesFromBuffer(diffLineTexts(contents))
resolved := make([]resolvedDiffLine, len(contents))
for i, content := range contents {
if info, ok := self.diffLineInfoFromRecords(content.Metadata); ok {
resolved[i] = resolvedDiffLine{info, true}
} else if bufferParsed[i].ok {
resolved[i] = resolvedDiffLine{self.diffLineInfo(bufferParsed[i].parsed), true}
}
}
return resolved
}
// resolveDiffLineIdentities recovers every diff line each row of a rendered diff
// shows, in one pass, indexed 1:1 with contents. It is resolveDiffLines' form for the
// readers that can't settle for the line a row leads with: looking for a remembered
// line in a new rendering has to consider both halves of a modification, since a
// side-by-side row leads with the deletion whose addition was what got remembered
// under a unified one.
func (self *DiffLineHelper) resolveDiffLineIdentities(contents []gocui.DiffLineContent) [][]types.DiffLineInfo {
bufferParsed := parseAllDiffLinesFromBuffer(diffLineTexts(contents))
identities := make([][]types.DiffLineInfo, len(contents))
for i, content := range contents {
if fromRecords := self.diffLineIdentitiesFromRecords(content.Metadata); len(fromRecords) > 0 {
identities[i] = fromRecords
} else if bufferParsed[i].ok {
identities[i] = []types.DiffLineInfo{self.diffLineInfo(bufferParsed[i].parsed)}
}
}
return identities
}
// 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.
func (self *DiffLineHelper) diffLineInfo(parsed parsedDiffLine) types.DiffLineInfo {
return diffLineInfoIn(self.c.Git().RepoPaths.WorktreePath(), parsed)
}
// diffLineInfoIn is diffLineInfo against a given worktree, for the callers that can't
// ask which repo we are in where they run: a repo switch replaces it, so only the UI
// thread may read it.
func diffLineInfoIn(worktreePath string, parsed parsedDiffLine) types.DiffLineInfo {
path := parsed.Path
if !filepath.IsAbs(path) {
path = filepath.Join(worktreePath, path)
}
return types.DiffLineInfo{
Path: path,
Type: parsed.Type,
NewLine: parsed.NewLine,
OldLine: parsed.OldLine,
}
}
@@ -0,0 +1,305 @@
package helpers
import (
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
// diffFilePrefix marks the start of a file's section in a (possibly multi-file)
// unified diff.
const diffFilePrefix = "diff --git "
// parsedDiffLine is what the parser recovers about a row of a rendered diff.
// Path is the path as the diff header spells it, i.e. relative to the repo root;
// the caller turns it into the absolute path of types.DiffLineInfo.
type parsedDiffLine struct {
Path string
Type types.DiffLineType
NewLine int
OldLine int
}
// bufferLineParse is the parser's result for one buffer line: the recovered
// identity, and whether the line could be resolved at all (false for a line in
// an unparseable section, or outside any file section).
type bufferLineParse struct {
parsed parsedDiffLine
ok bool
}
// parseDiffLineFromBuffer recovers the identity of a row of a rendered diff by
// parsing the view's decolorized contents.
//
// bufferLines is the full unwrapped view buffer; targetIdx is the buffer line to
// resolve. A commit's diff spans several files, so we isolate the file section
// containing targetIdx and parse just that one (see parseFileSection). Use this
// for a single line, e.g. the one under the cursor; to resolve every line of a
// buffer, use parseAllDiffLinesFromBuffer, which parses each section only once.
//
// ok is false when the buffer isn't a parseable unified diff at targetIdx,
// because the diff renderer restructured it, so that the caller can fall back.
func parseDiffLineFromBuffer(bufferLines []string, targetIdx int) (parsedDiffLine, bool) {
if targetIdx < 0 || targetIdx >= len(bufferLines) {
return parsedDiffLine{}, false
}
start, end := fileSectionBounds(bufferLines, targetIdx)
if start == -1 {
return parsedDiffLine{}, false
}
r := parseFileSection(bufferLines[start:end])[targetIdx-start]
return r.parsed, r.ok
}
// parseAllDiffLinesFromBuffer resolves every line of a (possibly multi-file)
// diff buffer in one pass, parsing each file section exactly once. It is the
// batch form of parseDiffLineFromBuffer, for callers that scan a whole buffer:
// resolving line by line would re-parse a section once per line of it — O(n²) on
// a large single-file diff — whereas this is O(n). The result is indexed 1:1
// with bufferLines; a line in an unparseable section, or before the first
// "diff --git", is left ok=false.
func parseAllDiffLinesFromBuffer(bufferLines []string) []bufferLineParse {
result := make([]bufferLineParse, len(bufferLines))
for i := 0; i < len(bufferLines); {
if !strings.HasPrefix(bufferLines[i], diffFilePrefix) {
i++ // not in a file section yet; leave it unresolved
continue
}
_, end := fileSectionBounds(bufferLines, i)
copy(result[i:end], parseFileSection(bufferLines[i:end]))
i = end
}
return result
}
// diffLineTexts extracts the text of each rendered row — the material the buffer
// parser works on.
func diffLineTexts(contents []gocui.DiffLineContent) []string {
texts := make([]string, len(contents))
for i, content := range contents {
texts[i] = content.Text
}
return texts
}
// fileSectionBounds returns the half-open range [start, end) of the file section
// containing targetIdx: the nearest "diff --git" at or above it, up to the next
// one (or the end of the buffer). start is -1 when targetIdx is before the first
// file section.
func fileSectionBounds(bufferLines []string, targetIdx int) (start, end int) {
start = -1
for i := targetIdx; i >= 0; i-- {
if strings.HasPrefix(bufferLines[i], diffFilePrefix) {
start = i
break
}
}
if start == -1 {
return -1, -1
}
end = len(bufferLines)
for i := start + 1; i < len(bufferLines); i++ {
if strings.HasPrefix(bufferLines[i], diffFilePrefix) {
end = i
break
}
}
return start, end
}
// parseFileSection parses one file's diff section (fileLines, starting at its
// "diff --git" line) a single time and returns the identity of each of its
// lines, indexed 1:1 with fileLines. patch.Parse's line indices line up with the
// section's buffer lines, so the type and the old/new line numbers fall out of
// the patch arithmetic. Every line is left ok=false when the section has no
// recoverable path or isn't a well-formed unified diff — the rendering
// restructured it, and acting on a mis-parse would land us on the wrong line, so
// the caller should fall back.
func parseFileSection(fileLines []string) []bufferLineParse {
result := make([]bufferLineParse, len(fileLines))
relPath := pathFromDiffHeader(fileLines)
if relPath == "" {
return result
}
p := patch.Parse(strings.Join(fileLines, "\n"))
if !p.IsWellFormed() {
return result
}
patchLines := p.Lines()
for i := range fileLines {
if i >= len(patchLines) {
break
}
parsed := parsedDiffLine{
Path: relPath,
Type: diffLineTypeForKind(patchLines[i].Kind),
NewLine: p.LineNumberOfLine(i),
}
if parsed.Type == types.DiffLineDeleted {
parsed.OldLine = p.OldLineNumberOfLine(i)
}
result[i] = bufferLineParse{parsed, true}
}
return result
}
func diffLineTypeForKind(kind patch.PatchLineKind) types.DiffLineType {
switch kind {
case patch.PATCH_HEADER:
return types.DiffLineFileHeader
case patch.HUNK_HEADER:
return types.DiffLineHunkHeader
case patch.ADDITION:
return types.DiffLineAdded
case patch.DELETION:
return types.DiffLineDeleted
case patch.CONTEXT:
return types.DiffLineContext
default:
return types.DiffLineOther
}
}
// pathFromDiffHeader extracts the new-file path of a single file's diff section.
// It prefers the "+++ b/<path>" line, falling back to "--- a/<path>" when the
// new path is /dev/null (a deleted file), and to the "diff --git" line when
// there are no such lines at all (a pure rename, which has no hunks).
func pathFromDiffHeader(fileLines []string) string {
var oldPath, newPath string
for _, line := range fileLines {
if strings.HasPrefix(line, "@@") {
break // past the header
}
switch {
case strings.HasPrefix(line, "+++ "):
newPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "+++ "))
case strings.HasPrefix(line, "--- "):
oldPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "--- "))
}
}
if newPath != "" && newPath != "/dev/null" {
return newPath
}
if oldPath != "" && oldPath != "/dev/null" {
return oldPath
}
return pathFromDiffGitLine(fileLines[0])
}
// pathFromDiffHeaderField decodes one path field of a diff header — the part
// after "--- " or "+++ ", or one of the two paths on the "diff --git" line —
// into the repo-relative path it names.
//
// git spells such a field in three ways: plain; terminated by a tab, when the
// path contains a space; or C-quoted as a whole, when the path contains
// characters git won't print raw — which, with core.quotePath enabled (the
// default), includes every non-ASCII byte, so `café` arrives as
// `"b/caf\303\251"`. The quoting is Go's string syntax, octal escapes included,
// so strconv decodes it for us.
//
// Returns "" for a quoted field we can't decode: better to resolve nothing than
// to point a consumer at a path that doesn't exist.
func pathFromDiffHeaderField(field string) string {
field = strings.TrimSuffix(field, "\t")
if strings.HasPrefix(field, `"`) {
unquoted, err := strconv.Unquote(field)
if err != nil {
return ""
}
field = unquoted
}
return stripDiffPathPrefix(field)
}
// stripDiffPathPrefix removes the a/ or b/ prefix git puts on the paths in a
// diff header. We ask git for these prefixes explicitly (diff.noprefix=false),
// so they are always there.
func stripDiffPathPrefix(path string) string {
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
return path[2:]
}
return path
}
// parseDiffLineMetadata parses the payload of an OSC 1717 record, in which a
// diff renderer states which line of which file it is rendering. The v1 payload
// is positional and ';'-delimited:
//
// version;type;new-line;old-line;file
//
// The file comes last so that it may itself contain a ';'. The old-file line is
// empty unless the line is a deletion, the only kind that needs it, and the
// new-file line is empty on a file header, the one kind that has no line.
//
// ok is false for a payload of an unknown version or shape, so that the caller
// can fall back to reading the rendered text.
func parseDiffLineMetadata(payload string) (parsedDiffLine, bool) {
fields := strings.SplitN(payload, ";", 5)
if len(fields) < 5 || fields[0] != "1" {
return parsedDiffLine{}, false
}
lineType, ok := diffLineTypeFromMetadata(fields[1])
if !ok {
return parsedDiffLine{}, false
}
newLine := 0
if fields[2] != "" {
var err error
if newLine, err = strconv.Atoi(fields[2]); err != nil {
return parsedDiffLine{}, false
}
} else if lineType != types.DiffLineFileHeader {
return parsedDiffLine{}, false
}
oldLine := 0
if fields[3] != "" {
var err error
if oldLine, err = strconv.Atoi(fields[3]); err != nil {
return parsedDiffLine{}, false
}
}
return parsedDiffLine{Path: fields[4], Type: lineType, NewLine: newLine, OldLine: oldLine}, true
}
func diffLineTypeFromMetadata(typeField string) (types.DiffLineType, bool) {
switch typeField {
case "c":
return types.DiffLineContext, true
case "a":
return types.DiffLineAdded, true
case "d":
return types.DiffLineDeleted, true
case "f":
return types.DiffLineFileHeader, true
case "h":
return types.DiffLineHunkHeader, true
default:
return types.DiffLineOther, false
}
}
// pathFromDiffGitLine extracts the new-file path from a "diff --git a/X b/X"
// line, where the two paths are separated by a space and either may be quoted.
// A path containing " b/" (or ` "b/`) would defeat this, but the +++/--- lines
// are unambiguous and we only get here when they are absent.
func pathFromDiffGitLine(line string) string {
rest := strings.TrimPrefix(line, diffFilePrefix)
if idx := strings.LastIndex(rest, ` "b/`); idx != -1 {
return pathFromDiffHeaderField(rest[idx+1:])
}
if idx := strings.LastIndex(rest, " b/"); idx != -1 {
return pathFromDiffHeaderField(rest[idx+1:])
}
return ""
}
@@ -0,0 +1,272 @@
package helpers
import (
"strings"
"testing"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/stretchr/testify/assert"
)
// A two-file commit diff as it appears (decolorized) in the main view. file1 has
// two consecutive deletions (grape, pear) that share a new-file line number;
// file2 has two consecutive additions.
const twoFileDiff = `diff --git a/file1.go b/file1.go
index 1111111..2222222 100644
--- a/file1.go
+++ b/file1.go
@@ -1,4 +1,2 @@
apple
-grape
-pear
lemon
diff --git a/dir/file2.go b/dir/file2.go
index 3333333..4444444 100644
--- a/dir/file2.go
+++ b/dir/file2.go
@@ -10,2 +9,4 @@ func foo() {
ctx
+added1
+added2
ctx2`
func TestParseDiffLineFromBuffer(t *testing.T) {
bufferLines := strings.Split(twoFileDiff, "\n")
scenarios := []struct {
name string
targetIdx int
expected parsedDiffLine
expectOk bool
}{
{"file header", 0, parsedDiffLine{Path: "file1.go", Type: types.DiffLineFileHeader, NewLine: 1}, true},
{"hunk header", 4, parsedDiffLine{Path: "file1.go", Type: types.DiffLineHunkHeader, NewLine: 1}, true},
{"context line", 5, parsedDiffLine{Path: "file1.go", Type: types.DiffLineContext, NewLine: 1}, true},
// The two deletions share new-file line 2 but have distinct old-file lines.
{"first deletion", 6, parsedDiffLine{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
{"second deletion", 7, parsedDiffLine{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
// The second file: its path comes from the second "diff --git" section,
// and its additions get distinct new-file line numbers.
{"first addition", 15, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, true},
{"second addition", 16, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 11}, true},
{"out of range", 999, parsedDiffLine{}, false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result, ok := parseDiffLineFromBuffer(bufferLines, s.targetIdx)
assert.Equal(t, s.expectOk, ok)
if s.expectOk {
assert.Equal(t, s.expected, result)
}
})
}
}
func TestParseDiffLineFromBufferRename(t *testing.T) {
// A rename with no content change has no hunks and no +++/--- lines, so the
// path has to come from the "diff --git" line; a rename with a content
// change has them, and they carry the new path.
pureRename := strings.Split(`diff --git a/old.go b/new.go
similarity index 100%
rename from old.go
rename to new.go`, "\n")
result, ok := parseDiffLineFromBuffer(pureRename, 2)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result)
renameWithModification := strings.Split(`diff --git a/old.go b/new.go
similarity index 62%
rename from old.go
rename to new.go
index 1111111..2222222 100644
--- a/old.go
+++ b/new.go
@@ -1,2 +1,2 @@
apple
-grape
+kiwi`, "\n")
result, ok = parseDiffLineFromBuffer(renameWithModification, 10)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineAdded, NewLine: 2}, result)
}
func TestParseDiffLineFromBufferDeletedFile(t *testing.T) {
// The new path is /dev/null, so the identity comes from the old path.
deletedFile := strings.Split(`diff --git a/gone.go b/gone.go
deleted file mode 100644
index 1111111..0000000
--- a/gone.go
+++ /dev/null
@@ -1,2 +0,0 @@
-apple
-grape`, "\n")
result, ok := parseDiffLineFromBuffer(deletedFile, 7)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "gone.go", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 2}, result)
}
func TestParseDiffLineFromBufferNotADiff(t *testing.T) {
// A rendering with no "diff --git" line can't be parsed, so the caller falls
// back rather than acting on the line.
bufferLines := []string{"some", "lines", "that", "are not a diff"}
_, ok := parseDiffLineFromBuffer(bufferLines, 2)
assert.False(t, ok)
}
func TestParseDiffLineFromBufferGutterMangled(t *testing.T) {
// A diff renderer that moves the line numbers into a gutter keeps the diff
// and hunk headers but pushes the +/- markers off the start of each body
// line, so every line reads as context. The body no longer matches the hunk
// header, so we refuse to parse rather than return a confident mis-parse.
mangled := strings.Split(`diff --git a/file1.txt b/file1.txt
index 1111111..2222222 100644
--- a/file1.txt
+++ b/file1.txt
@@ -1,5 +1,3 @@
1 1 apple
2 -grape
3 -pear
4 2 lemon
5 3 mango`, "\n")
_, ok := parseDiffLineFromBuffer(mangled, 6)
assert.False(t, ok)
}
func TestPathFromDiffHeaderField(t *testing.T) {
scenarios := []struct {
name string
field string
expected string
}{
{"new side", "b/file.go", "file.go"},
{"old side", "a/file.go", "file.go"},
{"a missing file", "/dev/null", "/dev/null"},
// git terminates the field with a tab when the path has a space in it.
{"path with a space", "b/with space.go\t", "with space.go"},
// With core.quotePath enabled (the default) every non-ASCII byte is
// escaped, and the field is quoted as a whole, prefix included.
{"non-ASCII path", `"b/caf\303\251.go"`, "café.go"},
{"non-ASCII path with a space", "\"b/caf\\303\\251 x.go\"\t", "café x.go"},
{"path with a double quote", `"b/we\"ird.go"`, `we"ird.go`},
{"path with a backslash", `"b/back\\slash.go"`, `back\slash.go`},
{"path with a tab", `"b/tab\there.go"`, "tab\there.go"},
{"undecodable", `"b/unterminated`, ""},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, pathFromDiffHeaderField(s.field))
})
}
}
func TestParseDiffLineFromBufferQuotedPath(t *testing.T) {
// A rename of a file whose name needs quoting, with a content change: the
// path is quoted on the "diff --git" line and on both of the +++/--- lines.
renamed := []string{
`diff --git "a/caf\303\251 old.go" "b/caf\303\251 new.go"`,
"similarity index 62%",
`rename from "caf\303\251 old.go"`,
`rename to "caf\303\251 new.go"`,
"index 1111111..2222222 100644",
"--- \"a/caf\\303\\251 old.go\"\t",
"+++ \"b/caf\\303\\251 new.go\"\t",
"@@ -1,2 +1,2 @@",
" apple",
"-grape",
"+kiwi",
}
result, ok := parseDiffLineFromBuffer(renamed, 10)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineAdded, NewLine: 2}, result)
// The same rename without a content change has no +++/--- lines, so the path
// comes from the "diff --git" line, where both paths are quoted.
result, ok = parseDiffLineFromBuffer(renamed[:4], 2)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result)
}
func TestParseAllDiffLinesFromBuffer(t *testing.T) {
// Some decoration above the diff, which belongs to no file section: a commit
// message and a diffstat, as `git show` renders them.
bufferLines := append(
[]string{"commit 1234567", "", " do a thing", "", " file1.go | 2 --", ""},
strings.Split(twoFileDiff, "\n")...,
)
all := parseAllDiffLinesFromBuffer(bufferLines)
// The batch parse resolves each file section once, and has to agree with
// resolving the lines one at a time.
assert.Len(t, all, len(bufferLines))
for i := range bufferLines {
parsed, ok := parseDiffLineFromBuffer(bufferLines, i)
assert.Equal(t, bufferLineParse{parsed, ok}, all[i], "line %d: %q", i, bufferLines[i])
}
// The lines above the first file section are left unresolved.
for i := range 6 {
assert.False(t, all[i].ok)
}
assert.True(t, all[6].ok)
}
func TestParseDiffLineMetadata(t *testing.T) {
scenarios := []struct {
name string
payload string
expected parsedDiffLine
expectOk bool
}{
{"context", "1;c;1;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineContext, NewLine: 1}, true},
{"added", "1;a;3;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineAdded, NewLine: 3}, true},
// A deletion carries both numbers; two consecutive deletions share the
// new-file line and differ only in the old-file one.
{"first deletion", "1;d;2;2;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
{"second deletion", "1;d;2;3;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
// A whole-file deletion has new-file position 0 and the old path.
{"deleted file", "1;d;0;1;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 1}, true},
// The path is the last field, so a ';' within it survives.
{"path with semicolon", "1;c;5;;weird;name.txt", parsedDiffLine{Path: "weird;name.txt", Type: types.DiffLineContext, NewLine: 5}, true},
// A renderer may state the path absolutely; the parser keeps it verbatim
// and leaves resolving it to the caller.
{"absolute path", "1;a;7;;/abs/foo.txt", parsedDiffLine{Path: "/abs/foo.txt", Type: types.DiffLineAdded, NewLine: 7}, true},
// A file header has no line number; a hunk header carries the new-file
// line of the hunk's first line (0 for a whole-file deletion, mirroring
// `@@ -1,N +0,0 @@`).
{"file header", "1;f;;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineFileHeader}, true},
{"hunk header", "1;h;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineHunkHeader, NewLine: 10}, true},
{"hunk header of a deleted file", "1;h;0;;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineHunkHeader, NewLine: 0}, true},
// A file header's line number is always empty, but a renderer that fills
// it in anyway is taken at its word rather than rejected.
{"file header with a line number", "1;f;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineFileHeader, NewLine: 10}, true},
{"unknown version", "2;c;1;;foo.txt", parsedDiffLine{}, false},
{"unknown type", "1;x;1;;foo.txt", parsedDiffLine{}, false},
{"too few fields", "1;c;1", parsedDiffLine{}, false},
{"non-numeric new-line", "1;c;x;;foo.txt", parsedDiffLine{}, false},
{"non-numeric old-line", "1;d;2;y;foo.txt", parsedDiffLine{}, false},
// Only a file header may omit the new-file line; on any other kind the
// record is malformed, and rejecting it falls the row back to the diff
// text rather than acting on a line number we don't have.
{"empty new-line on a content line", "1;c;;;foo.txt", parsedDiffLine{}, false},
{"empty new-line on a hunk header", "1;h;;;foo.txt", parsedDiffLine{}, false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result, ok := parseDiffLineMetadata(s.payload)
assert.Equal(t, s.expectOk, ok)
if s.expectOk {
assert.Equal(t, s.expected, result)
}
})
}
}
@@ -0,0 +1,99 @@
package helpers
import (
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
)
// Reading a rendering back to the diff it came from. What a diff view shows is a diff
// renderer's picture of a diff, and a picture is not what you want on your clipboard,
// or in a patch — so the lines of interest are located by identity in the diff itself,
// which the panel that rendered it hands out (types.FocusedMainViewDiffSource).
// PlainDiffOfSelection returns the text of the diff behind the rows selected in view:
// per file the selection touches, the run of diff lines from the first of its selected
// lines to the last, with the files in the order the selection meets them.
//
// A run, rather than the matched lines alone, so that what comes out reads as a diff:
// the lines between two selected ones come along even when the rendering didn't show
// them (difftastic leaves out whitespace-only changes) or showed them in another order
// (a side-by-side rendering groups the deletions of a hunk before its additions).
//
// plainDiff fetches the diff of the given repo-relative files, and is asked only for
// the files the selection touches, so that copying three lines of a commit's diff
// doesn't fetch the whole of it. It returns "" when no selected row could be found in
// the diff, e.g. because the selection covers nothing but a renderer's decoration.
func (self *DiffLineHelper) PlainDiffOfSelection(
view *gocui.View, first int, last int, plainDiff func(paths []string) string,
) string {
worktreePath := self.c.Git().RepoPaths.WorktreePath()
// The files in the order they are shown, and per file the lines to look for. Only
// content lines: a header names no line of the file, so it can't be looked for, and
// the headers within a run come along with it anyway.
paths := []string{}
selected := map[string]map[patchLine]bool{}
for _, info := range self.DiffLinesInViewRange(view, first, last) {
if !info.IsContent() {
continue
}
if _, ok := selected[info.Path]; !ok {
paths = append(paths, info.Path)
selected[info.Path] = map[patchLine]bool{}
}
selected[info.Path][patchLineOf(info)] = true
}
relPaths := repoRelativePaths(worktreePath, paths)
if len(relPaths) == 0 {
return ""
}
diffLines := strings.Split(strings.TrimSuffix(plainDiff(relPaths), "\n"), "\n")
runs := map[string][2]int{}
for i, parsed := range parseAllDiffLinesFromBuffer(diffLines) {
if !parsed.ok {
continue
}
info := diffLineInfoIn(worktreePath, parsed.parsed)
if !selected[info.Path][patchLineOf(info)] {
continue
}
if run, ok := runs[info.Path]; ok {
runs[info.Path] = [2]int{run[0], i}
} else {
runs[info.Path] = [2]int{i, i}
}
}
text := strings.Builder{}
for _, path := range paths {
run, ok := runs[path]
if !ok {
continue
}
for _, line := range diffLines[run[0] : run[1]+1] {
text.WriteString(line)
text.WriteString("\n")
}
}
return text.String()
}
// repoRelativePaths turns the absolute paths a diff line's identity carries into the
// repo-relative ones git speaks, dropping any that lies outside the worktree — a diff
// renderer states the path however it likes, and one we can't place is one we can't
// ask git about.
func repoRelativePaths(worktreePath string, paths []string) []string {
relPaths := make([]string, 0, len(paths))
for _, path := range paths {
relPath, err := filepath.Rel(worktreePath, path)
if err != nil || strings.HasPrefix(relPath, "..") {
continue
}
relPaths = append(relPaths, filepath.ToSlash(relPath))
}
return relPaths
}
@@ -0,0 +1,458 @@
package helpers
import (
"path/filepath"
"strings"
"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.
// DiffLinesInViewRange returns the identity of every diff line shown by the rows in
// the inclusive view-line range [first, last] of view's rendered diff, in display
// order. Rows whose identity can't be recovered are left out, as are the wrapped
// segments of a row already counted.
//
// A row can show more than one diff line — a side-by-side rendering puts a deletion
// beside the addition replacing it — and all of them are reported: what the user
// pointed at is the row, so everything on it is selected.
func (self *DiffLineHelper) DiffLinesInViewRange(view *gocui.View, first int, last int) []types.DiffLineInfo {
identities := self.resolveDiffLineIdentities(view.DiffLineContents())
infos := []types.DiffLineInfo{}
previousBufferLine := -1
for viewLine := first; viewLine <= last; viewLine++ {
bufferLine, ok := view.BufferLineForViewLine(viewLine)
if !ok || bufferLine == previousBufferLine || bufferLine >= len(identities) {
continue
}
previousBufferLine = bufferLine
infos = append(infos, identities[bufferLine]...)
}
return self.inRepoTerms(view, infos)
}
// ChangeLineOrdinals says, for each of the given change lines, which of its file's
// changes it is in the given diff — its place among them, counted from the top of the
// file — keyed by file. Lines the diff doesn't have are left out.
//
// It is how a line is named in something built out of a diff rather than being that diff:
// the custom patch holds the lines it was given in the order the file has them, so a
// place among a file's changes is a line of the patch.
func (self *DiffLineHelper) ChangeLineOrdinals(
diff string, infos []types.DiffLineInfo,
) map[string][]int {
ordinals := map[patchLine]int{}
counts := map[string]int{}
for _, parsed := range parseAllDiffLinesFromBuffer(strings.Split(diff, "\n")) {
if !parsed.ok {
continue
}
info := self.diffLineInfo(parsed.parsed)
if !info.IsChange() {
continue
}
ordinals[patchLineOf(info)] = counts[info.Path]
counts[info.Path]++
}
ordinalsByPath := map[string][]int{}
for _, info := range infos {
if ordinal, ok := ordinals[patchLineOf(info)]; ok {
ordinalsByPath[info.Path] = append(ordinalsByPath[info.Path], ordinal)
}
}
return ordinalsByPath
}
// inRepoTerms brings the paths of lines recovered from a view into the repo's terms.
//
// They are in them already for a diff of the repo's own files. The pane previewing the
// custom patch, though, shows a diff of the two trees the patch was materialized into: a
// diff renderer states the path it was handed there, which is under the tree's own name,
// while the diff's text names the trees where an ordinary diff has git's a/ and b/
// prefixes and so needs nothing.
func (self *DiffLineHelper) inRepoTerms(view *gocui.View, infos []types.DiffLineInfo) []types.DiffLineInfo {
if !self.ShowsCustomPatch(view) {
return infos
}
worktreePath := self.c.Git().RepoPaths.WorktreePath()
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
return lo.Map(infos, func(info types.DiffLineInfo, _ int) types.DiffLineInfo {
info.Path = repoPathOfTreePath(info.Path, treesDir, worktreePath)
return info
})
}
// repoPathOfTreePath maps a path under one of the trees the custom patch was materialized
// into to the file of the repo it stands for: the path is the tree's name followed by the
// file's own, stated either against the directory holding the trees or against the repo,
// depending on how the renderer that stated it was given it.
func repoPathOfTreePath(path string, treesDir string, worktreePath string) string {
root := worktreePath
if treesDir != "" && strings.HasPrefix(path, treesDir+string(filepath.Separator)) {
root = treesDir
}
relativePath, err := filepath.Rel(root, path)
if err != nil {
return path
}
segments := strings.Split(filepath.ToSlash(relativePath), "/")
if len(segments) > 1 && (segments[0] == "a" || segments[0] == "b") {
relativePath = filepath.Join(segments[1:]...)
}
return filepath.Join(worktreePath, relativePath)
}
// ChangeLinesInViewRange returns the change lines — the additions and deletions —
// among the diff lines shown by the rows in the inclusive view-line range. Those are
// the lines a patch is built from: a patch carries whatever context it needs around
// them by itself, so a selection contributes only its changes.
func (self *DiffLineHelper) ChangeLinesInViewRange(view *gocui.View, first int, last int) []types.DiffLineInfo {
return lo.Filter(self.DiffLinesInViewRange(view, first, last),
func(info types.DiffLineInfo, _ int) bool { return info.IsChange() })
}
// 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. That order is what hunk
// mode wants of the block it offers up on focus — a block whose beginning the user can
// see, rather than the tail of one they have scrolled past the start of, with the one
// bleeding in from above 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
}
// 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)
}
})
}
}
@@ -0,0 +1,109 @@
package helpers
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/tasks"
)
// Falling back to git's own diff when the configured one can't be acted on.
//
// A diff renderer is free to lay a diff out however it likes, and once it has, the only
// way to know which line of which file a row shows is for the renderer to say so. One
// that doesn't produces a diff that can be read but not staged, edited or copied from —
// so when the user focuses the main view to act on it, we show git's own diff instead.
// Browsing keeps the renderer's version; only acting on it needs one we can follow.
// MainViewDiffMode says how a side panel should produce the diff it renders into the
// main view: as the user configured it, or as git's own — while the main view holds
// focus and what the renderer would produce couldn't be acted on.
//
// Every panel that renders a diff into the main view asks, so that a re-render while
// focused — after staging a hunk, say — stays with git's own diff rather than flipping
// back to the renderer's.
func (self *DiffLineHelper) MainViewDiffMode() git_commands.DiffMode {
if self.mainViewIsFocused() && self.diffNeedsMetadata() && !self.diffRendererEmitsMetadata() {
return git_commands.DiffModeRaw
}
return git_commands.DiffModeRendered
}
// RenderFocusedMainViewAgain has the panel beneath the focused main view render its
// diff again — which, the main view now holding focus, is git's own diff rather than
// the renderer's — and calls place once that is on screen.
//
// The whole diff is read before it is shown, rather than the first screenful: place
// looks at what is there to decide where to put the selection, and a change line
// further down would otherwise be missed.
//
// It is the same diff of the same files, so the view keeps the scroll position it has
// rather than starting from the top: git lays the changes out its own way and the line
// the user was on is somewhere else now, but the offset still puts them among the same
// part of the file — and the selection is then established from what that leaves on
// screen.
func (self *DiffLineHelper) RenderFocusedMainViewAgain(view *gocui.View, sidePanel types.Context, place func()) {
manager := self.c.GetOrCreateViewBufferManagerForView(view)
if manager == nil {
return
}
manager.SetKeepScrollPositionForNextTask()
manager.SetRestoreForNextTask(&tasks.RenderRestore{
FirstPaintReady: func() bool { return false },
Apply: func(swapIn func()) {
swapIn()
place()
},
})
sidePanel.HandleRenderToMain()
}
func (self *DiffLineHelper) mainViewIsFocused() bool {
current := self.c.Context().CurrentStatic().GetKey()
return current == self.c.Contexts().Normal.GetKey() ||
current == self.c.Contexts().NormalSecondary.GetKey()
}
// diffNeedsMetadata reports whether the diff we would show is one whose rows can only
// be placed in the file by the records the renderer states. Any custom renderer may
// restructure the diff; so may git itself, once the renderer's arguments ask for a word
// diff, whose markup is inline. Plain git output describes itself, and needs no records.
func (self *DiffLineHelper) diffNeedsMetadata() bool {
manager := self.c.State().GetDiffRendererConfigManager()
if manager.GetDiffRendererType() != config.DiffRendererType_RawGit {
return true
}
return len(manager.GetRawGitArgs()) > 0
}
// diffRendererEmitsMetadata is the probed verdict about the current diff renderer, asked
// once and remembered until the renderer changes — the user cycling to another one, or a
// changed config being reloaded.
func (self *DiffLineHelper) diffRendererEmitsMetadata() bool {
signature := self.diffRendererSignature()
if self.rendererEmitsMetadata == nil || signature != self.rendererSignature {
verdict := self.c.Git().Diff.ProbeDiffRendererEmitsMetadata()
self.rendererEmitsMetadata = &verdict
self.rendererSignature = signature
}
return *self.rendererEmitsMetadata
}
// diffRendererSignature is what identifies the current diff renderer, so that the
// remembered verdict is dropped when it stops describing the renderer we have. The width
// a command is asked for is no part of its identity, so a fixed one is used.
func (self *DiffLineHelper) diffRendererSignature() string {
manager := self.c.State().GetDiffRendererConfigManager()
index, _ := manager.CurrentDiffRendererIndex()
return fmt.Sprintf("%d\x00%s\x00%s\x00%s",
index,
manager.GetExternalDiffCommand(3),
manager.GetStdinFilterCommand(0),
strings.Join(manager.GetRawGitArgs(), "\x00"))
}
@@ -0,0 +1,607 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/samber/lo"
)
// Keeping a diff view where it is when the same diff is rendered again differently.
// The line the user is on is remembered by identity (diff_line_helper.go), because a
// new rendering puts it on a different line of the view — and may not have it at all,
// which is what the fallbacks below are for.
// diffLineAnchor is a line for a restore to land on: the identity to find it by in
// the new rendering, and the screen row it was on, so that it can be put back there.
type diffLineAnchor struct {
identity types.DiffLineInfo
row int
}
// PreserveDiffPositionOnRerender remembers where a diff view is and puts it back
// there as it next re-renders, instead of leaving the user at the top of a new
// rendering of the diff they were already reading. Call it on the view about to be
// re-rendered, right before triggering the re-render — on both panes of the main
// window where both are being rendered again, since either of them may hold the diff
// being read; a pane that isn't showing is left alone.
//
// The line to keep is the end of the selection that is on screen, and the middle
// visible line when there is no selection or the whole of it has been scrolled out of
// sight — what the user is looking at, rather than the view's top edge or a selection
// they have long since left behind. It may not survive the re-render: a context line
// goes when the context size shrinks, and a whole hunk or file goes when whitespace
// stops counting. So the lines around it come along as fallbacks and the view lands on
// the nearest one that is still there, put back on the screen row it was on. With none
// of them left — and with a renderer that says nothing about its rows there is nothing
// to look for in the first place — the view keeps the scroll offset it had, which is
// still nearer to what was being read than the top of the diff.
//
// An off-screen selection is still put back on the diff line it was on, wherever the
// new rendering has that; it is only the view that stays where it is.
//
// A range or hunk selection has a second end, which is remembered the same way, so
// that it still covers the same lines of the diff afterwards.
func (self *DiffLineHelper) PreserveDiffPositionOnRerender(view *gocui.View) {
// A view that isn't the one its window is currently showing — the merge-conflicts
// view takes the main window over — isn't the one about to be re-rendered, so a
// restore installed on it would sit there and claim a later render instead.
if !view.Visible {
return
}
// The re-render is produced by a different command from the one behind what is on
// screen — another context size, another renderer — so without being told otherwise
// it would be taken for content the user has never seen and shown from the top.
// Whether or not a line of the old rendering can be found in the new one, the offset
// into it is nearer to where they were reading than the top is.
if manager := self.c.GetViewBufferManagerForView(view); manager != nil {
manager.SetKeepScrollPositionForNextTask()
}
showSelection := view.Highlight
anchorViewLine := view.MiddleVisibleLineIdx()
farEnd, hasFarEnd := types.DiffLineInfo{}, false
// A cursor that has been scrolled away from is put back by its own lines rather
// than by the anchor's, so that it comes out on the same line of the diff without
// the view having to go there.
var cursorCandidates []diffLineAnchor
if showSelection {
farEnd, hasFarEnd = self.selectionFarEndIdentity(view)
if end, ok := visibleSelectionEnd(view); ok {
anchorViewLine = end
}
if anchorViewLine != view.SelectedLineIdx() {
cursorCandidates = self.nearbyDiffLines(view, view.SelectedLineIdx())
}
}
self.restoreDiffLinePositionOnRerender(view, self.nearbyDiffLines(view, anchorViewLine),
func(anchor diffLineAnchor, viewLine int) {
// Put the line back on the screen row it was on, clamped into the view for
// the fallback lines, which can come from off screen.
row := lo.Clamp(anchor.row, 0, max(0, view.InnerHeight()-1))
view.SetOrigin(0, max(0, viewLine-row))
if showSelection {
// Put the far end back before the cursor, so that the selection covers
// the same lines again; a selection whose far end didn't survive the
// re-render is left as the single line we landed on. The origin is
// already where it should be, so moving the cursor mustn't scroll.
view.CancelRangeSelect()
cursorViewLine := self.selectionLine(view, cursorCandidates, viewLine)
if hasFarEnd {
if farEndViewLine, ok := self.findDiffLine(view, farEnd); ok {
cursorViewLine, farEndViewLine = coverWholeLines(view, cursorViewLine, farEndViewLine)
view.SetRangeSelectStart(farEndViewLine)
}
}
view.FocusPoint(0, cursorViewLine, false)
}
})
}
// coverWholeLines moves the two ends of a restored selection out to the edges of the
// diff lines they are on, so that the selection covers those lines whole. Both ends
// arrive on the first view line of their diff line, which is where looking one up by
// identity lands, and the view draws a line it wraps as several — of which a
// selection of that line means all.
func coverWholeLines(view *gocui.View, cursorViewLine int, farEndViewLine int) (int, int) {
if cursorViewLine <= farEndViewLine {
return cursorViewLine, lastViewLineOfSameDiffLine(view, farEndViewLine)
}
return lastViewLineOfSameDiffLine(view, cursorViewLine), farEndViewLine
}
// lastViewLineOfSameDiffLine returns the last view line showing the same line of the
// diff as the given one, which is that line itself unless the view wrapped it.
func lastViewLineOfSameDiffLine(view *gocui.View, viewLine int) int {
bufferLine, ok := view.BufferLineForViewLine(viewLine)
if !ok {
return viewLine
}
if last, ok := view.LastViewLineForBufferLine(bufferLine); ok {
return last
}
return viewLine
}
// visibleSelectionEnd returns the end of the selection to keep in place across a
// re-render: the selected line when it is on screen, and the range's other end when
// that is and the selected line isn't — a range can be long enough for the user to be
// looking at one end of it with the other far away. ok is false when the whole
// selection is off screen, and there is nothing of it to keep in place.
func visibleSelectionEnd(view *gocui.View) (int, bool) {
if view.IsLineVisible(view.SelectedLineIdx()) {
return view.SelectedLineIdx(), true
}
if farEnd, _, ok := selectionFarEndViewLine(view); ok && view.IsLineVisible(farEnd) {
return farEnd, true
}
return 0, false
}
// selectionLine returns the line to put the cursor on once a re-render is on screen:
// the line the position anchor landed on, which is the selected one whenever it was
// on screen, and otherwise the nearest surviving line to where the selection was —
// found among its own candidates, since the anchor's are a search of the diff from
// somewhere else entirely.
func (self *DiffLineHelper) selectionLine(
view *gocui.View, candidates []diffLineAnchor, anchorViewLine int,
) int {
if len(candidates) == 0 {
return anchorViewLine
}
_, bufferLine := self.nearestSurvivingCandidate(view.DiffLineContents(), candidates)
if bufferLine == -1 {
return anchorViewLine
}
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
return viewLine
}
return anchorViewLine
}
// selectionFarEndIdentity returns the identity of the end of a range or hunk
// selection the cursor isn't on, so that a re-render can put it back. ok is false for
// a selection that is only a cursor, where restoring that is the whole job, and for
// an end that resolves to no diff line.
//
// An end covers the whole of its row, so where the row shows more than one diff line
// — a rendering that puts a modification's two halves side by side, or a word diff
// that puts both on the one line it changed — the end takes the outermost of them:
// the last for the range's lower end and the first for its upper one. Otherwise a
// rendering that splits them apart again would get back only the half the row led
// with, and half a change selected where a whole one was.
func (self *DiffLineHelper) selectionFarEndIdentity(view *gocui.View) (types.DiffLineInfo, bool) {
farEnd, isLowerEnd, ok := selectionFarEndViewLine(view)
if !ok {
return types.DiffLineInfo{}, false
}
identities, ok := self.diffLineIdentitiesAt(view, farEnd)
if !ok {
return types.DiffLineInfo{}, false
}
if isLowerEnd {
return identities[len(identities)-1], true
}
return identities[0], true
}
// selectionFarEndViewLine returns the view line of the end of a range or hunk
// selection the cursor isn't on, and whether that is the lower of the two ends. ok
// is false when there is no range at all, only a cursor.
//
// A range whose two ends are on the same view line still has one, and is not the
// same thing as a cursor sitting there: it covers everything that row shows, which
// may be two lines of the diff at once.
func selectionFarEndViewLine(view *gocui.View) (int, bool, bool) {
if !view.HasRangeSelect() {
return 0, false, false
}
first, last := view.SelectedLineRange()
if view.SelectedLineIdx() == first {
return last, true, true
}
return first, false, true
}
// findDiffLine returns the view line showing the given diff line in what view is
// displaying now, for placing a remembered line once the re-render is on screen.
func (self *DiffLineHelper) findDiffLine(view *gocui.View, identity types.DiffLineInfo) (int, bool) {
bufferLine, ok := self.patchLineRows(view.DiffLineContents())[patchLineOf(identity)]
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(bufferLine)
}
// restoreDiffLinePositionOnRerender arranges for view's next re-render to land on the
// first of the given candidate lines the new rendering still has, calling place with
// that candidate and the view line it ended up on. The candidates are in priority
// order (see nearbyDiffLines); if the rendering has none of them, place isn't called
// and the view re-renders as it otherwise would.
//
// The nearest candidate is looked for as the content loads, so that the re-render can
// be revealed at the right position as soon as that line and a screenful below it
// have arrived. Only the nearest one, because the candidates aren't in load order: a
// farther one can load first, and landing on it while a nearer one is still on its
// way would be settling for worse. The rest are considered together once the whole
// rendering is there.
func (self *DiffLineHelper) restoreDiffLinePositionOnRerender(
view *gocui.View, candidates []diffLineAnchor, place func(anchor diffLineAnchor, viewLine int),
) {
if len(candidates) == 0 {
return
}
// The search of the loading content runs on the task's own goroutine, where the
// repo we are in may not be read — a repo switch replaces it — so take it here, on
// the UI thread, for the search to work from.
worktreePath := self.c.Git().RepoPaths.WorktreePath()
// Which candidate the search settled on, for place to put back where it was.
found := diffLineAnchor{}
self.installDiffLineRestore(view,
func(rows []gocui.DiffLineContent, offset int) (int, bool) {
for i, row := range rows {
if rowShowsDiffLine(row, worktreePath, candidates[0].identity) {
found = candidates[0]
return offset + i, true
}
}
return 0, false
},
func(contents []gocui.DiffLineContent) (int, bool) {
anchor, bufferLine := self.nearestSurvivingCandidate(contents, candidates)
if bufferLine == -1 {
return 0, false
}
found = anchor
return bufferLine, true
},
func(viewLine int) { place(found, viewLine) },
nil,
)
}
// ChangeLineOrdinal returns how many change lines of view's rendered diff come before
// the one at the given view line — that line's place in the sequence of changes. ok is
// false when the view line belongs to no row of the content.
//
// It is how a place in a diff is remembered across acting on it: an action consumes
// the lines it acted on, so the identity of the line the user was on is gone, but the
// place it left behind is the same one that identity used to have.
func (self *DiffLineHelper) ChangeLineOrdinal(view *gocui.View, viewLine int) (int, bool) {
bufferLine, ok := view.BufferLineForViewLine(viewLine)
if !ok {
return 0, false
}
ordinal := 0
for i, row := range self.resolveDiffLines(view.DiffLineContents()) {
if i >= bufferLine {
break
}
if row.ok && row.info.IsChange() {
ordinal++
}
}
return ordinal, true
}
// RevealChangeLineAtOrdinal arranges for view's next re-render to be shown with the
// change line at the given ordinal placed by place — the diff having changed under the
// user, this is where what they were doing carries on. When the new diff has fewer
// changes than that, because the ones acted on were its last, it lands on the last
// change left.
//
// done is called once the selection is where it belongs, or once it turns out that no
// render is coming to put it there, for a caller that must not let the user act again
// in between.
func (self *DiffLineHelper) RevealChangeLineAtOrdinal(
view *gocui.View, ordinal int, place func(viewLine int), done func(),
) {
// How many change lines the incremental search has passed, so that it can carry on
// counting where it left off.
seen := 0
self.installDiffLineRestore(view,
func(rows []gocui.DiffLineContent, offset int) (int, bool) {
for i, row := range rows {
if info, ok := self.diffLineInfoFromRecords(row.Metadata); ok && info.IsChange() {
if seen == ordinal {
return offset + i, true
}
seen++
}
}
return 0, false
},
func(contents []gocui.DiffLineContent) (int, bool) {
last, count := -1, 0
for i, row := range self.resolveDiffLines(contents) {
if !row.ok || !row.info.IsChange() {
continue
}
if count == ordinal {
return i, true
}
count++
last = i
}
return last, last != -1
},
place,
done,
)
}
// installDiffLineRestore is what the restores are built on: it arranges for view's
// next re-render to be revealed with the row a search finds in it placed by place,
// instead of from the top.
//
// The search comes in two halves, because the content arrives a line at a time.
// findEarly is given the rows that have loaded since it last looked, so that the
// re-render can be revealed as soon as the row is there rather than waiting for the
// rest of a long diff; it can only go by what the renderer states about a row, a
// partly-loaded diff being unparseable. findComplete is given the whole rendering at
// the swap, for a target the incremental search couldn't settle on. Either returns the
// buffer line it found, and place is not called at all when neither does.
func (self *DiffLineHelper) installDiffLineRestore(
view *gocui.View,
findEarly func(rows []gocui.DiffLineContent, offset int) (int, bool),
findComplete func(contents []gocui.DiffLineContent) (int, bool),
place func(viewLine int),
done func(),
) {
// Get-or-create, because the pane may not have rendered anything yet: a file whose
// diff has only just become split has a second pane whose first render is the one
// this restore is for.
manager := self.c.GetOrCreateViewBufferManagerForView(view)
if manager == nil {
if done != nil {
done()
}
return
}
// The readiness check below runs on the task's own goroutine, which may not read
// the view's dimensions, so take them here, on the UI thread.
viewHeight := view.InnerHeight()
// What the search of the loading content has found, and how far it has looked, so
// that each line is looked at once.
foundLine := -1
scanned := 0
manager.SetRestoreForNextTask(&tasks.RenderRestore{
FirstPaintReady: func() bool {
if foundLine == -1 {
rows := view.OffscreenDiffLineContentsFrom(scanned)
if bufferLine, ok := findEarly(rows, scanned); ok {
foundLine = bufferLine
}
scanned += len(rows)
if foundLine == -1 {
return false
}
}
// Wait for a screenful below the line as well, so that the re-render isn't
// revealed with it stranded at the bottom of a half-filled view.
return view.OffscreenLineCount() >= foundLine+viewHeight
},
Apply: func(swapIn func()) {
bufferLine := foundLine
if bufferLine == -1 {
if line, ok := findComplete(view.OffscreenDiffLineContents()); ok {
bufferLine = line
}
}
swapIn()
if bufferLine == -1 {
return
}
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
place(viewLine)
}
},
Done: done,
})
}
// nearbyDiffLines collects the lines of view's rendered diff as candidates for a
// restore to land on, ordered by proximity to the anchor line — the anchor itself
// first, then outward, preferring at-or-below on ties — each tagged with the screen
// row it is on. A restore lands on the first of them its re-render still has, so this
// order is what makes it land as near as possible to where the user was.
//
// The walk covers the whole diff rather than stopping at the change lines on either
// side of the anchor, which a context-size change always keeps: ignoring whitespace
// keeps nothing in particular, and can take a hunk or a whole file out of the diff,
// leaving the nearest surviving line in a neighbouring file.
func (self *DiffLineHelper) nearbyDiffLines(view *gocui.View, anchorViewLine int) []diffLineAnchor {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return nil
}
resolved := self.resolveDiffLines(view.DiffLineContents())
if anchor >= len(resolved) {
return nil
}
rows := screenRows(view, len(resolved))
candidates := make([]diffLineAnchor, 0, len(resolved))
collect := func(bufferLine int) {
if line := resolved[bufferLine]; line.ok {
candidates = append(candidates, diffLineAnchor{identity: line.info, row: rows[bufferLine]})
}
}
collect(anchor)
for below, above := anchor+1, anchor-1; below < len(resolved) || above >= 0; below, above = below+1, above-1 {
if below < len(resolved) {
collect(below)
}
if above >= 0 {
collect(above)
}
}
return candidates
}
// screenRows maps each line of view's content to the screen row it is drawn on. The
// lines above the visible ones get -1 and those below them the view's height, so that
// putting one of them back where it was lands it at the top or bottom edge.
func screenRows(view *gocui.View, bufferLineCount int) []int {
height := view.InnerHeight()
originY := view.OriginY()
rows := make([]int, bufferLineCount)
for i := range rows {
rows[i] = -1
}
lastVisible := -1
for y := originY; y < min(originY+height, view.ViewLinesHeight()); y++ {
bufferLine, ok := view.BufferLineForViewLine(y)
if !ok || bufferLine >= bufferLineCount {
continue
}
if rows[bufferLine] == -1 {
rows[bufferLine] = y - originY
}
lastVisible = bufferLine
}
for i := lastVisible + 1; i < bufferLineCount; i++ {
rows[i] = height
}
return rows
}
// nearestSurvivingCandidate returns the first of the candidates that the given
// rendering still shows, and the line of it that does. The rendering is indexed
// first, rather than searched once per candidate: the candidate list is as long as
// the diff, and so is the rendering.
func (self *DiffLineHelper) nearestSurvivingCandidate(
contents []gocui.DiffLineContent, candidates []diffLineAnchor,
) (diffLineAnchor, int) {
rows := self.patchLineRows(contents)
for _, candidate := range candidates {
if line, ok := rows[patchLineOf(candidate.identity)]; ok {
return candidate, line
}
}
return diffLineAnchor{}, -1
}
// patchLineRows indexes a rendering by the diff lines it shows: for each of them, the
// first of its rows that does. A row can show more than one, and each is then a way
// of finding that row again.
func (self *DiffLineHelper) patchLineRows(contents []gocui.DiffLineContent) map[patchLine]int {
rows := map[patchLine]int{}
for i, identities := range self.resolveDiffLineIdentities(contents) {
for _, identity := range identities {
if _, seen := rows[patchLineOf(identity)]; !seen {
rows[patchLineOf(identity)] = i
}
}
}
return rows
}
// rowShowsDiffLine reports whether the given row of a rendering shows the given diff
// line — among any others it shows, since a side-by-side rendering puts a deletion
// beside the addition replacing it. It only knows what the renderer states about the
// row, since the alternative, parsing the rendering as a diff, needs whole hunks and
// this is asked of content that is still loading. It takes the repo's worktree path
// rather than reading it, being asked off the UI thread.
func rowShowsDiffLine(row gocui.DiffLineContent, worktreePath string, target types.DiffLineInfo) bool {
return lo.SomeBy(row.Metadata, func(record string) bool {
parsed, ok := parseDiffLineMetadata(record)
return ok && patchLineOf(diffLineInfoIn(worktreePath, parsed)) == patchLineOf(target)
})
}
// patchLine is what stays the same about a diff line when the same diff is rendered
// again differently: which file it belongs to, the line number that identifies it on
// the side it belongs to, and what kind of line it is.
type patchLine struct {
path string
// Every kind of content line collapses into DiffLineContext, since an addition
// and the context line it turns into when whitespace stops counting are the same
// line of the same file. The header rows keep their kind: a file's header and the
// first line of the file it heads are not the same place.
kind types.DiffLineType
// The old file's line number for a deletion, since two consecutive deletions
// share a new-file position and differ only here; the new file's otherwise.
line int
isDeletion bool
}
func patchLineOf(info types.DiffLineInfo) patchLine {
switch info.Type {
case types.DiffLineFileHeader, types.DiffLineHunkHeader:
return patchLine{path: info.Path, kind: info.Type, line: info.NewLine}
case types.DiffLineDeleted:
return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.OldLine, isDeletion: true}
default:
return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.NewLine}
}
}
// RevealSelectionAfterAction moves a diff pane's selection to the change that takes the
// place of the one just acted on, once the changed diff has re-rendered. Call it with
// the pane acted in, the pane the work carries on in, and the first line of the
// selection, before triggering the re-render.
//
// The line acted on is gone from the diff, so what is remembered is its place among the
// diff's changes: the next change moves up into it, which is where you want to be to
// carry on. A range collapses to a single line at its start, and hunk mode selects the
// whole block it lands in, so that pressing the key again acts on the next hunk. The
// target pane inherits that select mode, this being the same piece of work continuing
// in another pane — and shows no selection until the restore places one, so that what
// it was left showing the last time it was used doesn't appear for a frame.
//
// advanceBy moves on by that many changes past the place remembered, for an action that
// leaves the diff as it was: lines taken into a custom patch are still in the commit's
// diff, so the place remembered is still the line acted on, and carrying on means going
// past the lines just dealt with rather than staying on them.
//
// done, which may be nil, is called once the selection is where it belongs, or once it
// turns out that no render is coming to put it there — for a caller that must not let
// the user act again in between.
func (self *DiffLineHelper) RevealSelectionAfterAction(
source types.DiffPaneContext, target types.DiffPaneContext, firstLineIdx int, advanceBy int, done func(),
) {
ordinal, ok := self.ChangeLineOrdinal(source.GetView(), firstLineIdx)
if !ok {
if done != nil {
done()
}
return
}
sel := source.DiffSelectState()
if sel.Mode == types.DiffSelectModeRange {
sel.Mode = types.DiffSelectModeLine
sel.RangeIsSticky = false
}
*target.DiffSelectState() = *sel
selectHunk := sel.Mode == types.DiffSelectModeHunk
targetView := target.GetView()
if target != source {
target.SetHasSelectableContent(false)
self.c.Context().UpdateSelectionHighlights()
}
self.RevealChangeLineAtOrdinal(targetView, ordinal+advanceBy, func(viewLine int) {
if selectHunk {
self.SelectChangeBlock(target, viewLine, true)
return
}
targetView.CancelRangeSelect()
self.ShowSelectionAtLine(targetView, viewLine, true)
}, done)
}
@@ -0,0 +1,184 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// Putting a selection in the focused main view: where it starts out, and how it is
// widened to a whole change block. Both are answered from what the view is showing,
// which is what the queries next door recover.
// EstablishSelection 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 (self *DiffLineHelper) EstablishSelection(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 !self.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 self.hunkModeApplies(view, clickedViewLine) && self.IsChangeLine(view, clickedViewLine) {
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
self.SelectChangeBlock(mainContext, clickedViewLine, false)
return
}
self.ShowSelectionAtLine(view, clickedViewLine, false)
return
}
target, ok := self.changeToSelectOnScreen(view)
if !ok {
self.ShowSelectionAtLine(view, view.MiddleVisibleLineIdx(), false)
return
}
if self.hunkModeApplies(view, target) {
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
self.SelectChangeBlock(mainContext, target, false)
return
}
self.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 (self *DiffLineHelper) changeToSelectOnScreen(view *gocui.View) (int, bool) {
if self.c.UserConfig().Gui.UseHunkModeInDiffView {
return self.FirstChangeBlockInView(view)
}
return self.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 IsSingleHunkForWholeFile.
func (self *DiffLineHelper) hunkModeApplies(view *gocui.View, changeViewLine int) bool {
return self.c.UserConfig().Gui.UseHunkModeInDiffView &&
!self.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 (self *DiffLineHelper) ShowSelectionAtLine(view *gocui.View, lineIdx int, scrollIntoView bool) {
view.FocusPoint(0, lo.Clamp(lineIdx, 0, max(0, view.ViewLinesHeight()-1)), scrollIntoView)
}
// SelectChangeBlock 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 (self *DiffLineHelper) SelectChangeBlock(
pane types.DiffPaneContext, changeViewLine int, scrollIntoView bool,
) {
view := pane.GetView()
start, end, ok := self.ChangeBlockBounds(view, changeViewLine)
if !ok {
pane.DiffSelectState().Mode = types.DiffSelectModeLine
view.CancelRangeSelect()
self.ShowSelectionAtLine(view, changeViewLine, scrollIntoView)
return
}
view.SetRangeSelectStart(end)
self.ShowSelectionAtLine(view, start, scrollIntoView)
}
// RefreshInclusionGutter updates the marks drawn over the diff in the main pane, which
// say which of its lines are in the custom patch being built from it.
//
// They are shown while the focused main view holds the focus — either of its panes, so
// that moving between the diff and the patch previewed beside it doesn't make them come
// and go — and only over a diff a patch is being built from: a patch built from some
// other commit says nothing about the lines of this one.
//
// Call it whenever either of those can have changed: as a pane's content settles, when
// the focus arrives or leaves, and when the patch itself changes.
func (self *DiffLineHelper) RefreshInclusionGutter() {
view := self.c.Contexts().Normal.GetView()
included := self.patchInclusion()
if included == nil {
view.SetInclusionGutter(false, nil)
return
}
resolved := self.resolveDiffLines(view.DiffLineContents())
marks := make([]bool, len(resolved))
showsChanges := false
for i, row := range resolved {
if !row.ok || !row.info.IsChange() {
continue
}
showsChanges = true
marks[i] = included(row.info)
}
// Nothing to mark and nowhere to mark it: the pane is showing a message rather than
// a diff, or a diff with nothing in it.
if !showsChanges {
view.SetInclusionGutter(false, nil)
return
}
view.SetInclusionGutter(true, marks)
}
// patchInclusion asks the panel whose diff the focused main view is showing which of
// that diff's lines are in the custom patch being built from it, and answers nil where
// there is no such patch — including when the focus is elsewhere, the marks being an
// affordance of the focused view.
func (self *DiffLineHelper) patchInclusion() func(types.DiffLineInfo) bool {
if !self.mainViewIsFocused() {
return nil
}
// The panel beneath is found from the pane that holds the focus, which is not always
// the one the diff is in: moving to the pane beside it takes the other off the stack.
sidePanel := self.c.Context().NextInStack(self.c.Context().CurrentStatic())
if sidePanel == nil {
return nil
}
actions, ok := sidePanel.GetFocusedMainViewDiffSource().(types.FocusedMainViewActions)
if !ok {
return nil
}
return actions.PatchInclusion()
}
// ShowsCustomPatch reports whether the given view is the one previewing the custom patch
// being built, which is the lower pane while a patch is being built from the diff in the
// upper one.
func (self *DiffLineHelper) ShowsCustomPatch(view *gocui.View) bool {
return view == self.c.Contexts().NormalSecondary.GetView() && self.patchInclusion() != nil
}
+4 -4
View File
@@ -28,8 +28,7 @@ type Helpers struct {
MergeConflicts *MergeConflictsHelper
CherryPick *CherryPickHelper
Host *HostHelper
PatchBuilding *PatchBuildingHelper
Staging *StagingHelper
CustomPatch *CustomPatchHelper
GPG *GpgHelper
Upstream *UpstreamHelper
AmendHelper *AmendHelper
@@ -39,6 +38,7 @@ type Helpers struct {
Snake *SnakeHelper
// lives in context package because our contexts need it to render to main
Diff *DiffHelper
DiffLine *DiffLineHelper
Repos *ReposHelper
RecordDirectory *RecordDirectoryHelper
Update *UpdateHelper
@@ -67,8 +67,7 @@ func NewStubHelpers() *Helpers {
MergeConflicts: &MergeConflictsHelper{},
CherryPick: &CherryPickHelper{},
Host: &HostHelper{},
PatchBuilding: &PatchBuildingHelper{},
Staging: &StagingHelper{},
CustomPatch: &CustomPatchHelper{},
GPG: &GpgHelper{},
Upstream: &UpstreamHelper{},
AmendHelper: &AmendHelper{},
@@ -76,6 +75,7 @@ func NewStubHelpers() *Helpers {
Commits: &CommitsHelper{},
Snake: &SnakeHelper{},
Diff: &DiffHelper{},
DiffLine: &DiffLineHelper{},
Repos: &ReposHelper{},
RecordDirectory: &RecordDirectoryHelper{},
Update: &UpdateHelper{},
+5 -5
View File
@@ -14,7 +14,7 @@ type ModeHelper struct {
c *HelperCommon
diffHelper *DiffHelper
patchBuildingHelper *PatchBuildingHelper
customPatchHelper *CustomPatchHelper
cherryPickHelper *CherryPickHelper
mergeAndRebaseHelper *MergeAndRebaseHelper
bisectHelper *BisectHelper
@@ -24,7 +24,7 @@ type ModeHelper struct {
func NewModeHelper(
c *HelperCommon,
diffHelper *DiffHelper,
patchBuildingHelper *PatchBuildingHelper,
customPatchHelper *CustomPatchHelper,
cherryPickHelper *CherryPickHelper,
mergeAndRebaseHelper *MergeAndRebaseHelper,
bisectHelper *BisectHelper,
@@ -32,7 +32,7 @@ func NewModeHelper(
return &ModeHelper{
c: c,
diffHelper: diffHelper,
patchBuildingHelper: patchBuildingHelper,
customPatchHelper: customPatchHelper,
cherryPickHelper: cherryPickHelper,
mergeAndRebaseHelper: mergeAndRebaseHelper,
bisectHelper: bisectHelper,
@@ -71,9 +71,9 @@ func (self *ModeHelper) Statuses() []ModeStatus {
return self.withResetButton(self.c.Tr.BuildingPatch, style.FgYellow.SetBold())
},
CancelLabel: func() string {
return self.c.Tr.ExitCustomPatchBuilder
return self.c.Tr.ResetCustomPatch
},
Reset: self.patchBuildingHelper.Reset,
Reset: self.customPatchHelper.Reset,
},
{
IsActive: self.c.Modes().Filtering.Active,
@@ -1,115 +0,0 @@
package helpers
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type PatchBuildingHelper struct {
c *HelperCommon
}
func NewPatchBuildingHelper(
c *HelperCommon,
) *PatchBuildingHelper {
return &PatchBuildingHelper{
c: c,
}
}
func (self *PatchBuildingHelper) ShowHunkStagingHint() {
if !self.c.AppState.DidShowHunkStagingHint && self.c.UserConfig().Gui.UseHunkModeInStagingView {
self.c.AppState.DidShowHunkStagingHint = true
self.c.SaveAppStateAndLogError()
message := fmt.Sprintf(self.c.Tr.HunkStagingHint, self.c.UserConfig().Keybinding.Main.ToggleSelectHunk)
self.c.Confirm(types.ConfirmOpts{
Prompt: message,
})
}
}
// takes us from the patch building panel back to the commit files panel
func (self *PatchBuildingHelper) Escape() {
self.c.Context().Pop()
}
// kills the custom patch and returns us back to the commit files panel if needed
func (self *PatchBuildingHelper) Reset() error {
self.c.Git().Patch.PatchBuilder.Reset()
if self.c.Context().CurrentStatic().GetKind() != types.SIDE_CONTEXT {
self.Escape()
}
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.COMMIT_FILES},
})
// refreshing the current context so that the secondary panel is hidden if necessary.
self.c.PostRefreshUpdate(self.c.Context().Current())
return nil
}
func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpts) {
selectedLineIdx := -1
if opts.ClickedWindowName == "main" {
selectedLineIdx = opts.ClickedViewLineIdx
}
if !self.c.Git().Patch.PatchBuilder.Active() {
self.Escape()
return
}
// get diff from commit file that's currently selected
file := self.c.Contexts().CommitFiles.GetSelectedFile()
if file == nil {
return
}
from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, file.Path, file.PreviousPath, true)
if err != nil {
return
}
secondaryDiff := self.c.Git().Patch.PatchBuilder.RenderPatchForFile(patch.RenderPatchForFileOpts{
Filename: file.Path,
PreviousPath: file.PreviousPath,
Plain: false,
Reverse: false,
TurnAddedFilesIntoDiffAgainstEmptyFile: true,
})
context := self.c.Contexts().CustomPatchBuilder
oldState := context.GetState()
state := patch_exploring.NewState(diff, selectedLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView)
context.SetState(state)
if state == nil {
self.Escape()
return
}
mainContent := context.GetContentToRender()
self.c.Contexts().CustomPatchBuilder.FocusSelection()
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().PatchBuilding,
Main: &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(mainContent),
Title: self.c.Tr.Patch,
},
Secondary: &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(secondaryDiff),
Title: self.c.Tr.CustomPatch,
},
})
}
+17 -42
View File
@@ -30,8 +30,6 @@ type RefreshHelper struct {
c *HelperCommon
refsHelper *RefsHelper
mergeAndRebaseHelper *MergeAndRebaseHelper
patchBuildingHelper *PatchBuildingHelper
stagingHelper *StagingHelper
mergeConflictsHelper *MergeConflictsHelper
worktreeHelper *WorktreeHelper
searchHelper *SearchHelper
@@ -62,8 +60,6 @@ func NewRefreshHelper(
c *HelperCommon,
refsHelper *RefsHelper,
mergeAndRebaseHelper *MergeAndRebaseHelper,
patchBuildingHelper *PatchBuildingHelper,
stagingHelper *StagingHelper,
mergeConflictsHelper *MergeConflictsHelper,
worktreeHelper *WorktreeHelper,
searchHelper *SearchHelper,
@@ -72,8 +68,6 @@ func NewRefreshHelper(
c: c,
refsHelper: refsHelper,
mergeAndRebaseHelper: mergeAndRebaseHelper,
patchBuildingHelper: patchBuildingHelper,
stagingHelper: stagingHelper,
mergeConflictsHelper: mergeConflictsHelper,
worktreeHelper: worktreeHelper,
searchHelper: searchHelper,
@@ -244,8 +238,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
var scopeSet *set.Set[types.RefreshableView]
if len(options.Scope) == 0 {
// not refreshing staging/patch-building unless explicitly requested because we only need
// to refresh those while focused.
scopeSet = set.NewFromSlice([]types.RefreshableView{
types.COMMITS,
types.BRANCHES,
@@ -257,7 +249,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
types.WORKTREES,
types.STATUS,
types.BISECT_INFO,
types.STAGING,
types.PULL_REQUESTS,
})
} else {
@@ -496,37 +487,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
})
}
if scopeSet.Includes(types.STAGING) {
refresh("staging", func() {
fileWg.Wait()
// Bounce onto the UI thread so this runs after the files
// scope's model-update bounce — RefreshStagingPanel reads
// Model.Files (via Files.GetSelected) and would otherwise
// see the pre-refresh model. Guard on the generation so a
// repo switch mid-refresh drops it, like the model bounces.
self.onUIThreadUnlessRepoChanged(env, func() {
self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{})
})
})
}
if scopeSet.Includes(types.PATCH_BUILDING) {
refresh("patch building", func() {
// Bounce onto the UI thread, like the staging panel above:
// RefreshPatchBuildingPanel reads the commit-files selection and
// sets the patch view's origin, neither of which may run off the UI
// thread. Guard on the generation so a repo switch mid-refresh drops
// it, like the model bounces.
self.onUIThreadUnlessRepoChanged(env, func() {
self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{})
})
})
}
if scopeSet.Includes(types.MERGE_CONFLICTS) {
refresh("merge conflicts", func() {
// Bounce onto the UI thread, like the staging and patch-building
// panels above: RefreshMergeState reads the current context and
// Bounce onto the UI thread: RefreshMergeState reads the current context and
// renders (or escapes) the merge-conflicts view, none of which may
// run off the UI thread.
self.onUIThreadUnlessRepoChanged(env, func() {
@@ -655,8 +618,6 @@ func getScopeNames(scopes []types.RefreshableView) []string {
types.WORKTREES: "worktrees",
types.STATUS: "status",
types.BISECT_INFO: "bisect",
types.STAGING: "staging",
types.PATCH_BUILDING: "patchBuilding",
types.MERGE_CONFLICTS: "mergeConflicts",
types.COMMIT_FILES: "commitFiles",
types.PULL_REQUESTS: "pullRequests",
@@ -1065,17 +1026,31 @@ type capturedCommitFilesState struct {
from string
to string
reverse bool
// Whether there is a commit to load the files of at all. The panel is only ever
// pointed at one by being entered, and a patch can now be built from a commit's diff
// without that — after which anything that refreshes the panel would otherwise be
// asking for the files of nothing.
hasCommit bool
}
// captureCommitFilesState reads the commit-files refresh's diff endpoints into
// an immutable snapshot. It must run on the UI thread.
func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState {
from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
commitFilesContext := self.c.Contexts().CommitFiles
if commitFilesContext.GetRef() == nil && commitFilesContext.GetRefRange() == nil {
return capturedCommitFilesState{}
}
from, to := commitFilesContext.GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
return capturedCommitFilesState{from: from, to: to, reverse: reverse}
return capturedCommitFilesState{from: from, to: to, reverse: reverse, hasCommit: true}
}
func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error {
if !captured.hasCommit {
return nil
}
files, err := env.git.Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse)
if err != nil {
return err
@@ -50,7 +50,6 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions
types.REFLOG,
types.WORKTREES,
types.BISECT_INFO,
types.STAGING,
}
if options.RefreshPullRequests {
scope = append(scope, types.PULL_REQUESTS)
@@ -1,127 +0,0 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type StagingHelper struct {
c *HelperCommon
}
func NewStagingHelper(
c *HelperCommon,
) *StagingHelper {
return &StagingHelper{
c: c,
}
}
// NOTE: used from outside this file
func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) {
secondaryFocused := self.secondaryStagingFocused()
mainFocused := self.mainStagingFocused()
// this method could be called when the staging panel is not being used,
// in which case we don't want to do anything.
if !mainFocused && !secondaryFocused {
return
}
mainSelectedLineIdx := -1
secondarySelectedLineIdx := -1
if focusOpts.ClickedViewLineIdx > 0 {
if secondaryFocused {
secondarySelectedLineIdx = focusOpts.ClickedViewLineIdx
} else {
mainSelectedLineIdx = focusOpts.ClickedViewLineIdx
}
}
mainContext := self.c.Contexts().Staging
secondaryContext := self.c.Contexts().StagingSecondary
var file *models.File
node := self.c.Contexts().Files.GetSelected()
if node != nil {
file = node.File
}
if file == nil || (!file.HasUnstagedChanges && !file.HasStagedChanges) {
self.handleStagingEscape()
return
}
mainDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, false)
secondaryDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, true)
// grabbing locks here and releasing before we finish the function
// because pushing say the secondary context could mean entering this function
// again, and we don't want to have a deadlock
mainContext.GetMutex().Lock()
secondaryContext.GetMutex().Lock()
hunkMode := self.c.UserConfig().Gui.UseHunkModeInStagingView
mainContext.SetState(
patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainContext.GetView(), mainContext.GetState(), hunkMode),
)
secondaryContext.SetState(
patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode),
)
mainState := mainContext.GetState()
secondaryState := secondaryContext.GetState()
mainContent := mainContext.GetContentToRender()
secondaryContent := secondaryContext.GetContentToRender()
mainContext.GetMutex().Unlock()
secondaryContext.GetMutex().Unlock()
if mainState == nil && secondaryState == nil {
self.handleStagingEscape()
return
}
if mainState == nil && !secondaryFocused {
self.c.Context().Push(secondaryContext, focusOpts)
return
}
if secondaryState == nil && secondaryFocused {
self.c.Context().Push(mainContext, focusOpts)
return
}
if secondaryFocused {
self.c.Contexts().StagingSecondary.FocusSelection()
} else {
self.c.Contexts().Staging.FocusSelection()
}
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Staging,
Main: &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(mainContent),
Title: self.c.Tr.UnstagedChanges,
},
Secondary: &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(secondaryContent),
Title: self.c.Tr.StagedChanges,
},
})
}
func (self *StagingHelper) handleStagingEscape() {
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
}
func (self *StagingHelper) secondaryStagingFocused() bool {
return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().StagingSecondary.GetKey()
}
func (self *StagingHelper) mainStagingFocused() bool {
return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().Staging.GetKey()
}
@@ -60,7 +60,7 @@ type WindowArrangementArgs struct {
ContentHeightForWindow func(window string) int
// Whether the main panel is split (as is the case e.g. when a file has both
// staged and unstaged changes)
SplitMainPanel bool
MainPanes types.MainPanes
// The current screen mode (normal, half, full)
ScreenMode types.ScreenMode
// The content shown on the bottom left of the screen when showing a loader
@@ -103,7 +103,7 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string,
ContentHeightForWindow: func(window string) int {
return self.windowHelper.GetContextForWindow(window).TotalContentHeight()
},
SplitMainPanel: repoState.GetSplitMainPanel(),
MainPanes: repoState.GetMainPanes(),
ScreenMode: repoState.GetScreenMode(),
AppStatus: appStatus,
InformationStr: informationStr,
@@ -215,36 +215,27 @@ func MergeMaps[K comparable, V any](maps ...map[K]V) map[K]V {
}
func mainSectionChildren(args WindowArrangementArgs) []*boxlayout.Box {
// if we're not in split mode we can just show the one main panel. Likewise if
// the main panel is focused and we're in full-screen mode
if !args.SplitMainPanel || (args.ScreenMode == types.SCREEN_FULL && args.CurrentWindow == "main") {
return []*boxlayout.Box{
{
Window: "main",
Weight: 1,
},
mainPane := &boxlayout.Box{Window: "main", Weight: 1}
secondaryPane := &boxlayout.Box{Window: "secondary", Weight: 1}
switch args.MainPanes {
case types.MainPaneOnly:
return []*boxlayout.Box{mainPane}
case types.SecondaryPaneOnly:
return []*boxlayout.Box{secondaryPane}
case types.BothMainPanes:
// In full-screen mode the focused one takes the whole section anyway.
if args.ScreenMode == types.SCREEN_FULL {
if args.CurrentWindow == "main" {
return []*boxlayout.Box{mainPane}
}
if args.CurrentWindow == "secondary" {
return []*boxlayout.Box{secondaryPane}
}
}
}
if args.CurrentWindow == "secondary" && args.ScreenMode == types.SCREEN_FULL {
return []*boxlayout.Box{
{
Window: "secondary",
Weight: 1,
},
}
}
return []*boxlayout.Box{
{
Window: "main",
Weight: 1,
},
{
Window: "secondary",
Weight: 1,
},
}
return []*boxlayout.Box{mainPane, secondaryPane}
}
func getMidSectionWeights(args WindowArrangementArgs) (int, int) {
@@ -382,7 +373,7 @@ func infoSectionChildren(args WindowArrangementArgs) []*boxlayout.Box {
}
func splitMainPanelSideBySide(args WindowArrangementArgs) bool {
if !args.SplitMainPanel {
if args.MainPanes != types.BothMainPanes {
return false
}
@@ -35,7 +35,7 @@ func TestGetWindowDimensions(t *testing.T) {
// Each panel shows its first tab by default; for the special-cased
// panels (status, stash) the view name matches the window name.
ActiveViewForWindow: func(window string) string { return window },
SplitMainPanel: false,
MainPanes: types.MainPaneOnly,
ScreenMode: types.SCREEN_NORMAL,
AppStatus: "",
InformationStr: "information",
@@ -719,17 +719,30 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() {
}
}
// secondaryPatchPanelUpdateOpts renders the custom patch being built into the pane
// beside the diff it is being built from, as a diff of the two trees the patch is
// materialized into — so that it is shown by whatever renders the rest of the diffs, and
// so that its lines can be pointed at and taken back out of the patch.
func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts {
if c.Git().Patch.PatchBuilder.Active() {
patch := c.Git().Patch.PatchBuilder.RenderAggregatedPatch(false)
return &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(patch),
Title: c.Tr.CustomPatch,
}
if !c.Git().Patch.PatchBuilder.Active() {
return nil
}
return nil
// A render of the same patch reuses the trees; only a change to the patch writes them
// again.
if err := c.Git().Patch.EnsureCustomPatchDiffTrees(); err != nil {
c.Log.Error(err)
}
// The same mode as the diff beside it: both panes of the pair have to agree about
// whether what they show can be acted on.
mode := c.Helpers().DiffLine.MainViewDiffMode()
cmdObj := c.Git().Diff.CustomPatchDiffCmdObj(c.Git().Patch.PatchBuilder.TempDir(), mode)
return &types.ViewUpdateOpts{
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
Title: c.Tr.CustomPatch,
}
}
func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
+804 -12
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 {
@@ -34,14 +46,99 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
{
Keys: opts.GetKeys(opts.Config.Universal.TogglePanel),
Handler: self.togglePanel,
Description: self.c.Tr.ToggleStagingView,
Tooltip: self.c.Tr.ToggleStagingViewTooltip,
Description: self.c.Tr.ToggleDiffPane,
Tooltip: self.c.Tr.ToggleDiffPaneTooltip,
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.Universal.Select),
Handler: self.primaryAction,
// The description is of the working tree's diff, which is where the key does
// the thing users know it for; over a commit's diff it says so for itself.
Description: self.c.Tr.Stage,
DescriptionFunc: self.diffActionDescription(self.c.Tr.Stage, self.c.Tr.ToggleSelectionForPatch),
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.StageSelectionTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Remove),
Handler: self.discardSelection,
Description: self.c.Tr.DiscardSelection,
DescriptionFunc: self.diffActionDescription(self.c.Tr.DiscardSelection, self.c.Tr.RemoveSelectionFromPatch),
GetDisabledReason: self.discardSelectionDisabledReason,
Tooltip: self.c.Tr.DiscardSelectionTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard),
Handler: self.copySelection,
Description: self.c.Tr.CopySelectedTextToClipboard,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.CopySelectedTextToClipboard),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
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 +148,54 @@ 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,
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChanges),
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleCommitPress),
Description: self.c.Tr.Commit,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.Commit),
Tooltip: self.c.Tr.CommitTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithoutHook),
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleWIPCommitPress),
Description: self.c.Tr.CommitChangesWithoutHook,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.CommitChangesWithoutHook),
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithEditor),
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleCommitEditorPress),
Description: self.c.Tr.CommitChangesWithEditor,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.CommitChangesWithEditor),
},
{
Keys: opts.GetKeys(opts.Config.Files.FindBaseCommitForFixup),
Handler: self.workingTreeAction(self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress),
Description: self.c.Tr.FindBaseCommitForFixup,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.FindBaseCommitForFixup),
Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip,
},
{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 +213,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,
},
}
}
@@ -75,36 +233,670 @@ func (self *MainViewController) Context() types.Context {
return self.context
}
// GetOnFocus brings on the marks over the lines that are in the custom patch, which
// are an affordance of the focused view, so they arrive with the focus.
func (self *MainViewController) GetOnFocus() func(types.OnFocusOpts) {
return func(types.OnFocusOpts) {
self.c.Helpers().DiffLine.RefreshInclusionGutter()
}
}
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 {
self.c.Helpers().DiffLine.EstablishSelection(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
}
// diffSource returns the panel beneath the focused main view, as the thing that can
// hand out the diff it rendered there. nil when this pane isn't on the stack, or the
// panel beneath shows no diff.
func (self *MainViewController) diffSource() types.FocusedMainViewDiffSource {
if !self.c.Context().IsInStack(self.context) {
return nil
}
sidePanel := self.c.Context().NextInStack(self.context)
if sidePanel == nil {
return nil
}
return sidePanel.GetFocusedMainViewDiffSource()
}
// focusedMainViewActions returns what the panel beneath the focused main view does to
// a selection in its diff, or nil where it does nothing to it — a panel whose diff can
// be read and copied but not acted on.
func (self *MainViewController) focusedMainViewActions() types.FocusedMainViewActions {
actions, _ := self.diffSource().(types.FocusedMainViewActions)
return actions
}
// primaryAction acts on the selected diff lines, leaving what that means to the panel
// beneath — which also re-renders the diff, since it is the one that changed it.
func (self *MainViewController) primaryAction() error {
actions := self.focusedMainViewActions()
if actions == nil {
return nil
}
first, last := self.context.GetView().SelectedLineRange()
return actions.PrimaryAction(self.context, first, last)
}
// discardSelection takes the selected diff lines back out of what they are part of,
// which — like the primary action — is the panel's business, and so is the re-render
// that follows.
func (self *MainViewController) discardSelection() error {
actions := self.focusedMainViewActions()
if actions == nil {
return nil
}
first, last := self.context.GetView().SelectedLineRange()
return actions.DiscardSelection(self.context, first, last)
}
// workingTreeAction wraps a command that acts on the working tree — committing, finding
// the commit to fix up — so that it only runs while the focused main view is showing the
// working tree's diff. Over a commit's diff the key does nothing, so that browsing
// through history can't commit by accident. The check is per press, since what the main
// view shows changes as the user moves around while the keybindings are registered once.
func (self *MainViewController) workingTreeAction(action func() error) func() error {
return func() error {
if self.diffMainViewType() != types.DiffMainViewTypeStaging {
return nil
}
return action()
}
}
// workingTreeActionDescription gives a command's description only where the command
// applies — over the working tree's diff — so that it is listed there and nowhere else.
func (self *MainViewController) workingTreeActionDescription(description string) func() string {
return self.diffActionDescription(description, "")
}
// diffActionDescription describes a command in the words of the diff it is over: acting
// on the working tree's diff stages, acting on a commit's builds a custom patch. Over
// content that is no diff at all the command doesn't apply, and describes itself as
// nothing, which keeps it out of the keybindings menu there.
func (self *MainViewController) diffActionDescription(staging string, patchBuilding string) func() string {
return func() string {
switch self.diffMainViewType() {
case types.DiffMainViewTypeStaging:
return staging
case types.DiffMainViewTypePatchBuilding:
return patchBuilding
default:
return ""
}
}
}
// copySelection copies the selected diff lines to the clipboard — not as the diff
// renderer drew them, but as they read in the diff itself, which is both what you meant
// to copy and the only form a renderer can't have mangled. A selection that is all
// additions or all deletions loses its +/- column, so that it can be pasted straight
// into code.
func (self *MainViewController) copySelection() error {
source := self.diffSource()
if source == nil {
return nil
}
view := self.context.GetView()
first, last := view.SelectedLineRange()
text := self.c.Helpers().DiffLine.PlainDiffOfSelection(view, first, last,
func(paths []string) string { return source.PlainDiff(self.context, paths) })
if text == "" {
return nil
}
self.c.LogAction(self.c.Tr.Actions.CopySelectedTextToClipboard)
return self.c.OS().CopyToClipboard(dropDiffPrefix(text))
}
// 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
}
// discardSelectionDisabledReason disables discarding while there is nothing to discard,
// and where the panel beneath won't have it: taking lines out of a commit means
// rewriting it, which isn't always something we may do.
func (self *MainViewController) discardSelectionDisabledReason() *types.DisabledReason {
if reason := self.diffSelectionDisabledReason(); reason != nil {
return reason
}
if actions := self.focusedMainViewActions(); actions != nil {
return actions.DiscardSelectionDisabledReason(self.context)
}
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()
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()
}
// Where the focus has gone is already known here, so asking again is what keeps
// the patch marks over a move to the pane beside this one and takes them away
// when the focus leaves the pair.
self.c.Helpers().DiffLine.RefreshInclusionGutter()
}
}
// 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 a click on a change line keeps hunk mode and selects
// that whole block, so clicking from hunk to hunk stays ready to act on one; a click
// on context drops to a single line, as does any click when we weren't in hunk mode —
// the click points at that line precisely, e.g. to edit it.
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 &&
self.c.Helpers().DiffLine.IsChangeLine(view, viewLine) {
self.selectHunkAround(viewLine, false)
return
}
self.context.ResetDiffSelectMode()
self.c.Helpers().DiffLine.ShowSelectionAtLine(view, viewLine, false)
}
func (self *MainViewController) selectHunkAround(changeViewLine int, scrollIntoView bool) {
self.c.Helpers().DiffLine.SelectChangeBlock(self.context, changeViewLine, 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, like the staging
// 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.
func (self *MainViewController) navigate(find findDiffRowFn, forward 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)
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)
}
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)
return nil
}
func (self *MainViewController) prevChangeBlock() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, false)
return nil
}
func (self *MainViewController) nextFile() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, true)
return nil
}
func (self *MainViewController) prevFile() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, false)
return nil
}
func (self *MainViewController) placeNavigationTarget(target int) {
v := self.context.GetView()
if !v.Highlight {
v.SetOrigin(0, target)
return
}
// Jumping to another block or file moves the cursor without shift held, so a
// range that only grows while it is collapses rather than stretching all the way
// to the target. A sticky range does stretch — that is what makes it 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.
self.c.Helpers().DiffLine.ShowSelectionAtLine(v, target, true)
}
// 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)
}
v.FocusPoint(0, 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)
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()
v := self.context.GetView()
v.FocusPoint(0, lo.Clamp(target, 0, v.ViewLinesHeight()-1), 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() {
@@ -1,277 +0,0 @@
package controllers
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type PatchBuildingController struct {
baseController
c *ControllerCommon
}
var _ types.IController = &PatchBuildingController{}
func NewPatchBuildingController(
c *ControllerCommon,
) *PatchBuildingController {
return &PatchBuildingController{
baseController: baseController{},
c: c,
}
}
func (self *PatchBuildingController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
return []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.OpenFile),
Handler: self.OpenFile,
Description: self.c.Tr.OpenFile,
Tooltip: self.c.Tr.OpenFileTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Edit),
Handler: self.EditFile,
Description: self.c.Tr.EditFile,
Tooltip: self.c.Tr.EditFileTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Select),
Handler: self.ToggleSelectionAndRefresh,
Description: self.c.Tr.ToggleSelectionForPatch,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Remove),
Handler: self.discardSelection,
GetDisabledReason: self.getDisabledReasonForDiscard,
Description: self.c.Tr.RemoveSelectionFromPatch,
Tooltip: self.c.Tr.RemoveSelectionFromPatchTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Return),
Handler: self.Escape,
Description: self.c.Tr.ExitCustomPatchBuilder,
DescriptionFunc: self.EscapeDescription,
DisplayOnScreen: true,
},
}
}
func (self *PatchBuildingController) Context() types.Context {
return self.c.Contexts().CustomPatchBuilder
}
func (self *PatchBuildingController) context() types.IPatchExplorerContext {
return self.c.Contexts().CustomPatchBuilder
}
func (self *PatchBuildingController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
return []*gocui.ViewMouseBinding{}
}
func (self *PatchBuildingController) GetOnFocus() func(types.OnFocusOpts) {
return func(opts types.OnFocusOpts) {
// no need to change wrap on the secondary view because it can't be interacted with
self.c.Views().PatchBuilding.Wrap = self.c.UserConfig().Gui.WrapLinesInStagingView
self.c.Helpers().PatchBuilding.RefreshPatchBuildingPanel(opts)
}
}
func (self *PatchBuildingController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(opts types.OnFocusLostOpts) {
self.context().SetState(nil)
self.c.Views().PatchBuilding.Wrap = true
if self.c.Git().Patch.PatchBuilder.IsEmpty() {
self.c.Git().Patch.PatchBuilder.Reset()
}
}
}
func (self *PatchBuildingController) OpenFile() error {
self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock()
path := self.c.Contexts().CommitFiles.GetSelectedPath()
if path == "" {
return nil
}
return self.c.Helpers().Files.OpenFile(path)
}
func (self *PatchBuildingController) EditFile() error {
self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock()
path := self.c.Contexts().CommitFiles.GetSelectedPath()
if path == "" {
return nil
}
lineNumber := self.context().GetState().CurrentLineNumber()
lineNumber = self.c.Helpers().Diff.AdjustLineNumber(path, lineNumber, self.context().GetViewName())
return self.c.Helpers().Files.EditFileAtLine(path, lineNumber)
}
func (self *PatchBuildingController) ToggleSelectionAndRefresh() error {
if err := self.toggleSelection(); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.PATCH_BUILDING, types.COMMIT_FILES},
})
return nil
}
func (self *PatchBuildingController) toggleSelection() error {
self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock()
file := self.c.Contexts().CommitFiles.GetSelectedFile()
if file == nil {
return nil
}
state := self.context().GetState()
// Get added/deleted lines in the selected patch range
lineIndicesToToggle := state.LineIndicesOfAddedOrDeletedLinesInSelectedPatchRange()
if len(lineIndicesToToggle) == 0 {
// Only context lines or header lines selected, so nothing to do
return nil
}
includedLineIndices, err := self.c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath)
if err != nil {
return err
}
toggleFunc := self.c.Git().Patch.PatchBuilder.AddFileLineRange
firstSelectedChangeLineIsStaged := lo.Contains(includedLineIndices, lineIndicesToToggle[0])
if firstSelectedChangeLineIsStaged {
toggleFunc = self.c.Git().Patch.PatchBuilder.RemoveFileLineRange
}
// add range of lines to those set for the file
if err := toggleFunc(file.Path, file.PreviousPath, lineIndicesToToggle); err != nil {
// might actually want to return an error here
self.c.Log.Error(err)
}
if state.SelectingRange() {
state.SetLineSelectMode()
}
state.SelectNextStageableLineOfSameIncludedState(self.context().GetIncludedLineIndices(), firstSelectedChangeLineIsStaged)
return nil
}
func (self *PatchBuildingController) getDisabledReasonForDiscard() *types.DisabledReason {
if !self.c.Git().Patch.PatchBuilder.CanRebase {
return &types.DisabledReason{Text: self.c.Tr.CanOnlyDiscardFromLocalCommits, ShowErrorInPanel: true}
}
if self.c.Git().Status.WorkingTreeState().Any() {
return &types.DisabledReason{Text: self.c.Tr.CantPatchWhileRebasingError, ShowErrorInPanel: true}
}
if self.c.UserConfig().Git.DiffContextSize == 0 {
text := fmt.Sprintf(self.c.Tr.Actions.NotEnoughContextToRemoveLines,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
return &types.DisabledReason{Text: text, ShowErrorInPanel: true}
}
return nil
}
func (self *PatchBuildingController) discardSelection() error {
prompt := lo.Ternary(self.c.Git().Patch.PatchBuilder.IsEmpty(),
self.c.Tr.DiscardLinesFromCommitPrompt,
self.c.Tr.DiscardLinesFromCommitPromptWithReset)
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.DiscardLinesFromCommitTitle,
Prompt: prompt,
HandleConfirm: func() error {
return self.discardSelectionFromCommit()
},
})
return nil
}
func (self *PatchBuildingController) discardSelectionFromCommit() error {
// Reset the current patch if there is one.
if !self.c.Git().Patch.PatchBuilder.IsEmpty() {
self.c.Git().Patch.PatchBuilder.Reset()
}
if err := self.toggleSelection(); err != nil {
return err
}
if self.c.Git().Patch.PatchBuilder.IsEmpty() {
return nil
}
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
// Escape pops the patch-building context, so run it on the UI thread
// before the refresh below.
_ = self.c.GocuiGui().OnUIThreadAndWait(func() {
self.c.Helpers().PatchBuilding.Escape()
})
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{})
})
}
func (self *PatchBuildingController) getPatchCommitIndex() int {
for index, commit := range self.c.Model().Commits {
if commit.Hash() == self.c.Git().Patch.PatchBuilder.To {
return index
}
}
return -1
}
func (self *PatchBuildingController) Escape() error {
context := self.c.Contexts().CustomPatchBuilder
state := context.GetState()
if state.SelectingRange() || state.SelectingHunkEnabledByUser() {
state.SetLineSelectMode()
self.c.PostRefreshUpdate(context)
return nil
}
self.c.Helpers().PatchBuilding.Escape()
return nil
}
func (self *PatchBuildingController) EscapeDescription() string {
context := self.c.Contexts().CustomPatchBuilder
if state := context.GetState(); state != nil {
if state.SelectingRange() {
return self.c.Tr.DismissRangeSelect
}
if state.SelectingHunkEnabledByUser() {
return self.c.Tr.SelectLineByLine
}
}
return self.c.Tr.ExitCustomPatchBuilder
}
@@ -1,416 +0,0 @@
package controllers
import (
"strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type PatchExplorerControllerFactory struct {
c *ControllerCommon
}
func NewPatchExplorerControllerFactory(c *ControllerCommon) *PatchExplorerControllerFactory {
return &PatchExplorerControllerFactory{
c: c,
}
}
func (self *PatchExplorerControllerFactory) Create(context types.IPatchExplorerContext) *PatchExplorerController {
controller := &PatchExplorerController{
baseController: baseController{},
c: self.c,
context: context,
}
controller.dragAutoscroller = helpers.NewDragAutoscroller(
self.c.HelperCommon,
context,
controller.canDragAutoscroll,
controller.handleDragAutoscroll,
)
return controller
}
type PatchExplorerController struct {
baseController
c *ControllerCommon
context types.IPatchExplorerContext
dragAutoscroller *helpers.DragAutoscroller
draggingWithMouse bool
}
func (self *PatchExplorerController) Context() types.Context {
return self.context
}
func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
return []*types.Binding{
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.PrevItem),
Handler: self.withRenderAndFocus(self.HandlePrevLine),
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.NextItem),
Handler: self.withRenderAndFocus(self.HandleNextLine),
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectUp),
Handler: self.withRenderAndFocus(self.HandlePrevLineRange),
Description: self.c.Tr.RangeSelectUp,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown),
Handler: self.withRenderAndFocus(self.HandleNextLineRange),
Description: self.c.Tr.RangeSelectDown,
},
{
Keys: opts.GetKeys(opts.Config.Main.PrevHunk),
Handler: self.withRenderAndFocus(self.HandlePrevHunk),
Description: self.c.Tr.PrevHunk,
},
{
Keys: opts.GetKeys(opts.Config.Main.NextHunk),
Handler: self.withRenderAndFocus(self.HandleNextHunk),
Description: self.c.Tr.NextHunk,
},
{
Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect),
Handler: self.withRenderAndFocus(self.HandleToggleSelectRange),
Description: self.c.Tr.ToggleRangeSelect,
},
{
Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk),
Handler: self.withRenderAndFocus(self.HandleToggleSelectHunk),
Description: self.c.Tr.ToggleSelectHunk,
DescriptionFunc: func() string {
if state := self.context.GetState(); state != nil && state.SelectingHunk() {
return self.c.Tr.SelectLineByLine
}
return self.c.Tr.SelectHunk
},
Tooltip: self.c.Tr.ToggleSelectHunkTooltip,
DisplayOnScreen: true,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.PrevPage),
Handler: self.withRenderAndFocus(self.HandlePrevPage),
Description: self.c.Tr.PrevPage,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.NextPage),
Handler: self.withRenderAndFocus(self.HandleNextPage),
Description: self.c.Tr.NextPage,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.GotoTop),
Handler: self.withRenderAndFocus(self.HandleGotoTop),
Description: self.c.Tr.GotoTop,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.GotoBottom),
Description: self.c.Tr.GotoBottom,
Handler: self.withRenderAndFocus(self.HandleGotoBottom),
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.ScrollLeft),
Handler: self.withRenderAndFocus(self.HandleScrollLeft),
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.ScrollRight),
Handler: self.withRenderAndFocus(self.HandleScrollRight),
},
{
Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard),
Handler: self.withLock(self.CopySelectedToClipboard),
Description: self.c.Tr.CopySelectedTextToClipboard,
},
}
}
func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
return []*gocui.ViewMouseBinding{
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Handler: func(opts gocui.ViewMouseBindingOpts) error {
if self.isFocused() {
return self.withRenderAndFocus(self.HandleMouseDown)()
}
self.c.Context().Push(self.context, types.OnFocusOpts{
ClickedWindowName: self.context.GetWindowName(),
ClickedViewLineIdx: opts.Y,
})
return nil
},
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModMotion,
Handler: self.handleMouseDrag,
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseRelease,
Handler: func(gocui.ViewMouseBindingOpts) error { return self.handleDragRelease() },
},
}
}
func (self *PatchExplorerController) handleMouseDrag(opts gocui.ViewMouseBindingOpts) error {
if err := self.withLock(func() error {
self.context.GetState().DragSelectLine(opts.Y)
self.renderDragSelection()
return nil
})(); err != nil {
return err
}
self.draggingWithMouse = true
originY, _ := self.context.GetViewTrait().ViewPortYBounds()
self.dragAutoscroller.Update(opts.Y - originY)
return nil
}
func (self *PatchExplorerController) canDragAutoscroll(int) bool {
state := self.context.GetState()
return state != nil && state.SelectingRange()
}
func (self *PatchExplorerController) handleDragAutoscroll(viewIndex int) bool {
if !self.canDragAutoscroll(0) {
return false
}
if err := self.withLock(func() error {
self.context.GetState().DragSelectLine(viewIndex)
self.renderDragSelection()
return nil
})(); err != nil {
return false
}
return true
}
func (self *PatchExplorerController) renderDragSelection() {
view := self.context.GetView()
state := self.context.GetState()
originY := view.OriginY()
startIndex, _ := state.SelectedViewRange()
view.SetRangeSelectStart(startIndex)
view.SetCursorY(state.GetSelectedViewLineIdx() - originY)
self.context.Render()
}
func (self *PatchExplorerController) handleDragRelease() error {
self.draggingWithMouse = false
self.dragAutoscroller.Cancel()
return nil
}
func (self *PatchExplorerController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(types.OnFocusLostOpts) {
self.dragAutoscroller.Cancel()
if self.draggingWithMouse {
self.draggingWithMouse = false
self.c.GocuiGui().CancelMouseCapture()
}
}
}
func (self *PatchExplorerController) HandlePrevLine() error {
before := self.context.GetState().GetSelectedViewLineIdx()
self.context.GetState().CycleSelection(false)
after := self.context.GetState().GetSelectedViewLineIdx()
if self.context.GetState().SelectingLine() {
checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
}
return nil
}
func (self *PatchExplorerController) HandleNextLine() error {
before := self.context.GetState().GetSelectedViewLineIdx()
self.context.GetState().CycleSelection(true)
after := self.context.GetState().GetSelectedViewLineIdx()
if self.context.GetState().SelectingLine() {
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
}
return nil
}
func (self *PatchExplorerController) HandlePrevLineRange() error {
s := self.context.GetState()
s.CycleRange(false)
return nil
}
func (self *PatchExplorerController) HandleNextLineRange() error {
s := self.context.GetState()
s.CycleRange(true)
return nil
}
func (self *PatchExplorerController) HandlePrevHunk() error {
self.context.GetState().SelectPreviousHunk()
return nil
}
func (self *PatchExplorerController) HandleNextHunk() error {
self.context.GetState().SelectNextHunk()
return nil
}
func (self *PatchExplorerController) HandleToggleSelectRange() error {
self.context.GetState().ToggleStickySelectRange()
return nil
}
func (self *PatchExplorerController) HandleToggleSelectHunk() error {
self.context.GetState().ToggleSelectHunk()
return nil
}
func (self *PatchExplorerController) HandleScrollLeft() error {
self.context.GetViewTrait().ScrollLeft()
return nil
}
func (self *PatchExplorerController) HandleScrollRight() error {
self.context.GetViewTrait().ScrollRight()
return nil
}
func (self *PatchExplorerController) HandlePrevPage() error {
self.context.GetState().AdjustSelectedLineIdx(-self.context.GetViewTrait().PageDelta())
return nil
}
func (self *PatchExplorerController) HandleNextPage() error {
self.context.GetState().AdjustSelectedLineIdx(self.context.GetViewTrait().PageDelta())
return nil
}
func (self *PatchExplorerController) HandleGotoTop() error {
self.context.GetState().SelectTop()
return nil
}
func (self *PatchExplorerController) HandleGotoBottom() error {
self.context.GetState().SelectBottom()
return nil
}
func (self *PatchExplorerController) HandleMouseDown() error {
self.context.GetState().SelectNewLineForRange(self.context.GetViewTrait().SelectedLineIdx())
return nil
}
func (self *PatchExplorerController) CopySelectedToClipboard() error {
selected := self.context.GetState().PlainRenderSelected()
self.c.LogAction(self.c.Tr.Actions.CopySelectedTextToClipboard)
if err := self.c.OS().CopyToClipboard(dropDiffPrefix(selected)); err != nil {
return err
}
return nil
}
// Removes '+' or '-' from the beginning of each line in the diff string, except
// when both '+' and '-' lines are present, or diff header lines, in which case
// the diff is returned unchanged. This is useful for copying parts of diffs to
// the clipboard in order to paste them into code.
func dropDiffPrefix(diff string) string {
lines := strings.Split(strings.TrimRight(diff, "\n"), "\n")
const (
PLUS int = iota
MINUS
CONTEXT
OTHER
)
linesByType := lo.GroupBy(lines, func(line string) int {
switch {
case strings.HasPrefix(line, "+"):
return PLUS
case strings.HasPrefix(line, "-"):
return MINUS
case strings.HasPrefix(line, " "):
return CONTEXT
}
return OTHER
})
hasLinesOfType := func(lineType int) bool { return len(linesByType[lineType]) > 0 }
keepPrefix := hasLinesOfType(OTHER) || (hasLinesOfType(PLUS) && hasLinesOfType(MINUS))
if keepPrefix {
return diff
}
return strings.Join(lo.Map(lines, func(line string, _ int) string { return line[1:] + "\n" }), "")
}
func (self *PatchExplorerController) isFocused() bool {
return self.c.Context().Current().GetKey() == self.context.GetKey()
}
func (self *PatchExplorerController) withRenderAndFocus(f func() error) func() error {
return self.withLock(func() error {
if err := f(); err != nil {
return err
}
self.context.RenderAndFocus()
return nil
})
}
func (self *PatchExplorerController) withLock(f func() error) func() error {
return func() error {
self.context.GetMutex().Lock()
defer self.context.GetMutex().Unlock()
if self.context.GetState() == nil {
return nil
}
return f()
}
}
@@ -10,6 +10,9 @@ type ReflogCommitsController struct {
baseController
*ListControllerTrait[*models.Commit]
c *ControllerCommon
// what this panel offers on the diff it shows in the focused main view
diffActions *CommitDiffActions
}
var _ types.IController = &ReflogCommitsController{}
@@ -17,7 +20,7 @@ var _ types.IController = &ReflogCommitsController{}
func NewReflogCommitsController(
c *ControllerCommon,
) *ReflogCommitsController {
return &ReflogCommitsController{
controller := &ReflogCommitsController{
baseController: baseController{},
ListControllerTrait: NewListControllerTrait(
c,
@@ -27,6 +30,19 @@ func NewReflogCommitsController(
),
c: c,
}
controller.diffActions = NewCommitDiffActions(c, c.Contexts().ReflogCommits, controller.diffTarget)
return controller
}
// diffTarget is the reflog entry the panel has selected, whose diff its main view
// shows. A reflog entry is never a commit of the checked-out branch as far as we are
// concerned, so nothing here may be rewritten.
func (self *ReflogCommitsController) diffTarget() *commitDiffTarget {
commit := self.context().GetSelected()
if commit == nil {
return nil
}
return &commitDiffTarget{from: commit.ParentRefName(), to: commit.RefName()}
}
func (self *ReflogCommitsController) Context() types.Context {
@@ -37,6 +53,10 @@ func (self *ReflogCommitsController) context() *context.ReflogCommitsContext {
return self.c.Contexts().ReflogCommits
}
func (self *ReflogCommitsController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.diffActions
}
func (self *ReflogCommitsController) GetOnRenderToMain() func() {
return func() {
self.c.Helpers().Diff.WithDiffModeCheck(func() {
@@ -45,9 +65,10 @@ func (self *ReflogCommitsController) GetOnRenderToMain() func() {
if commit == nil {
task = types.NewRenderStringTask("No reflog history")
} else {
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit))
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit), mode)
task = types.NewRunPtyTask(cmdObj.GetCmd())
task = types.NewMainViewDiffTask(cmdObj.GetCmd(), mode)
}
self.c.RenderToMainViews(types.RefreshMainOpts{
@@ -56,6 +77,7 @@ func (self *ReflogCommitsController) GetOnRenderToMain() func() {
Title: "Reflog Entry",
Task: task,
},
Secondary: secondaryPatchPanelUpdateOpts(self.c),
})
})
}
-358
View File
@@ -1,358 +0,0 @@
package controllers
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type StagingController struct {
baseController
c *ControllerCommon
context types.IPatchExplorerContext
otherContext types.IPatchExplorerContext
// if true, we're dealing with the secondary context i.e. dealing with staged file changes
staged bool
}
var _ types.IController = &StagingController{}
func NewStagingController(
c *ControllerCommon,
context types.IPatchExplorerContext,
otherContext types.IPatchExplorerContext,
staged bool,
) *StagingController {
return &StagingController{
baseController: baseController{},
c: c,
context: context,
otherContext: otherContext,
staged: staged,
}
}
func (self *StagingController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
return []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.Select),
Handler: self.ToggleStaged,
Description: self.c.Tr.Stage,
Tooltip: self.c.Tr.StageSelectionTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Remove),
Handler: self.DiscardSelection,
Description: self.c.Tr.DiscardSelection,
Tooltip: self.c.Tr.DiscardSelectionTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.OpenFile),
Handler: self.OpenFile,
Description: self.c.Tr.OpenFile,
Tooltip: self.c.Tr.OpenFileTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Edit),
Handler: self.EditFile,
Description: self.c.Tr.EditFile,
Tooltip: self.c.Tr.EditFileTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Return),
Handler: self.Escape,
Description: self.c.Tr.ReturnToFilesPanel,
DescriptionFunc: self.EscapeDescription,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.TogglePanel),
Handler: self.TogglePanel,
Description: self.c.Tr.ToggleStagingView,
Tooltip: self.c.Tr.ToggleStagingViewTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Main.EditSelectHunk),
Handler: self.EditHunkAndRefresh,
Description: self.c.Tr.EditHunk,
Tooltip: self.c.Tr.EditHunkTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChanges),
Handler: self.c.Helpers().WorkingTree.HandleCommitPress,
Description: self.c.Tr.Commit,
Tooltip: self.c.Tr.CommitTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithoutHook),
Handler: self.c.Helpers().WorkingTree.HandleWIPCommitPress,
Description: self.c.Tr.CommitChangesWithoutHook,
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithEditor),
Handler: self.c.Helpers().WorkingTree.HandleCommitEditorPress,
Description: self.c.Tr.CommitChangesWithEditor,
},
{
Keys: opts.GetKeys(opts.Config.Files.FindBaseCommitForFixup),
Handler: self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress,
Description: self.c.Tr.FindBaseCommitForFixup,
Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip,
},
}
}
func (self *StagingController) Context() types.Context {
return self.context
}
func (self *StagingController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
return []*gocui.ViewMouseBinding{}
}
func (self *StagingController) GetOnFocus() func(types.OnFocusOpts) {
return func(opts types.OnFocusOpts) {
wrap := self.c.UserConfig().Gui.WrapLinesInStagingView
self.c.Views().Staging.Wrap = wrap
self.c.Views().StagingSecondary.Wrap = wrap
self.c.Helpers().Staging.RefreshStagingPanel(opts)
}
}
func (self *StagingController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(opts types.OnFocusLostOpts) {
self.context.SetState(nil)
if opts.NewContextKey != self.otherContext.GetKey() {
self.c.Views().Staging.Wrap = true
self.c.Views().StagingSecondary.Wrap = true
}
}
}
func (self *StagingController) OpenFile() error {
self.context.GetMutex().Lock()
defer self.context.GetMutex().Unlock()
path := self.FilePath()
if path == "" {
return nil
}
return self.c.Helpers().Files.OpenFile(path)
}
func (self *StagingController) EditFile() error {
self.context.GetMutex().Lock()
defer self.context.GetMutex().Unlock()
path := self.FilePath()
if path == "" {
return nil
}
lineNumber := self.context.GetState().CurrentLineNumber()
lineNumber = self.c.Helpers().Diff.AdjustLineNumber(path, lineNumber, self.context.GetViewName())
return self.c.Helpers().Files.EditFileAtLine(path, lineNumber)
}
func (self *StagingController) Escape() error {
if self.context.GetState().SelectingRange() || self.context.GetState().SelectingHunkEnabledByUser() {
self.context.GetState().SetLineSelectMode()
self.c.PostRefreshUpdate(self.context)
return nil
}
self.c.Context().Pop()
return nil
}
func (self *StagingController) EscapeDescription() string {
if state := self.context.GetState(); state != nil {
if state.SelectingRange() {
return self.c.Tr.DismissRangeSelect
}
if state.SelectingHunkEnabledByUser() {
return self.c.Tr.SelectLineByLine
}
}
return self.c.Tr.ReturnToFilesPanel
}
func (self *StagingController) TogglePanel() error {
if self.otherContext.GetState() != nil {
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
}
return nil
}
func (self *StagingController) ToggleStaged() error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
return self.applySelectionAndRefresh(self.staged)
}
func (self *StagingController) DiscardSelection() error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToDiscard,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
return self.c.ConfirmIf(!self.staged && !self.c.UserConfig().Gui.SkipDiscardChangeWarning,
types.ConfirmOpts{
Title: self.c.Tr.DiscardChangeTitle,
Prompt: self.c.Tr.DiscardChangePrompt,
HandleConfirm: func() error { return self.applySelectionAndRefresh(true) },
})
}
func (self *StagingController) applySelectionAndRefresh(reverse bool) error {
if err := self.applySelection(reverse); err != nil {
return err
}
// Block input until the refresh has landed: it rebuilds the staging panel
// and moves the selection to the next stageable change, and a quick second
// keypress must act on that, not on the stale pre-refresh diff.
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
return nil
}
func (self *StagingController) applySelection(reverse bool) error {
self.context.GetMutex().Lock()
defer self.context.GetMutex().Unlock()
state := self.context.GetState()
path := self.FilePath()
if path == "" {
return nil
}
firstLineIdx, lastLineIdx := state.SelectedPatchRange()
patchToApply := patch.
Parse(state.GetDiff()).
Transform(patch.TransformOpts{
Reverse: reverse,
IncludedLineIndices: patch.ExpandRange(firstLineIdx, lastLineIdx),
FileNameOverride: path,
}).
FormatPlain()
if patchToApply == "" {
return nil
}
// apply the patch then refresh this panel
// create a new temp file with the patch, then call git apply with that patch
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
err := self.c.Git().Patch.ApplyPatch(
patchToApply,
git_commands.ApplyPatchOpts{
Reverse: reverse,
Cached: !reverse || self.staged,
},
)
if err != nil {
return err
}
if state.SelectingRange() {
firstLine, _ := state.SelectedViewRange()
state.SelectLine(firstLine)
}
return nil
}
func (self *StagingController) EditHunkAndRefresh() error {
if err := self.editHunk(); err != nil {
return err
}
// Block input like applySelectionAndRefresh does; the refresh rebuilds the
// staging panel from the post-edit diff.
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
return nil
}
func (self *StagingController) editHunk() error {
self.context.GetMutex().Lock()
defer self.context.GetMutex().Unlock()
state := self.context.GetState()
path := self.FilePath()
if path == "" {
return nil
}
hunkStartIdx, hunkEndIdx := state.CurrentHunkBounds()
patchText := patch.
Parse(state.GetDiff()).
Transform(patch.TransformOpts{
Reverse: self.staged,
IncludedLineIndices: patch.ExpandRange(hunkStartIdx, hunkEndIdx),
FileNameOverride: path,
}).
FormatPlain()
patchFilepath, err := self.c.Git().Patch.SaveTemporaryPatch(patchText)
if err != nil {
return err
}
lineOffset := 3
lineIdxInHunk := state.GetSelectedPatchLineIdx() - hunkStartIdx
if err := self.c.Helpers().Files.EditFileAtLineAndWait(patchFilepath, lineIdxInHunk+lineOffset); err != nil {
return err
}
editedPatchText, err := self.c.Git().File.Cat(patchFilepath)
if err != nil {
return err
}
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
lineCount := strings.Count(editedPatchText, "\n") + 1
newPatchText := patch.
Parse(editedPatchText).
Transform(patch.TransformOpts{
IncludedLineIndices: patch.ExpandRange(0, lineCount),
FileNameOverride: path,
}).
FormatPlain()
if err := self.c.Git().Patch.ApplyPatch(
newPatchText,
git_commands.ApplyPatchOpts{
Reverse: self.staged,
Cached: true,
},
); err != nil {
return err
}
return nil
}
func (self *StagingController) FilePath() string {
return self.c.Contexts().Files.GetSelectedPath()
}
+5 -2
View File
@@ -92,10 +92,12 @@ func (self *StashController) GetOnRenderToMain() func() {
if stashEntry == nil {
task = types.NewRenderStringTask(self.c.Tr.NoStashEntries)
} else {
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
prefix := style.FgYellow.Sprintf("%s\n\n", stashEntry.Description())
task = types.NewRunPtyTaskWithPrefix(
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index).GetCmd(),
task = types.NewMainViewDiffTaskWithPrefix(
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index, mode).GetCmd(),
prefix,
mode,
)
}
@@ -106,6 +108,7 @@ func (self *StashController) GetOnRenderToMain() func() {
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: task,
},
Secondary: secondaryPatchPanelUpdateOpts(self.c),
})
})
}
@@ -56,6 +56,7 @@ func (self *SubCommitsController) GetOnRenderToMain() func() {
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: task,
},
Secondary: secondaryPatchPanelUpdateOpts(self.c),
})
})
}
+3 -1
View File
@@ -5,6 +5,8 @@ import (
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
@@ -123,7 +125,7 @@ func (self *SubmodulesController) GetOnRenderToMain() func() {
if file == nil {
task = types.NewRenderStringTask(prefix)
} else {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, file.Names())
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, git_commands.DiffModeRendered, !file.HasUnstagedChanges && file.HasStagedChanges, file.Names())
task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)
}
}
@@ -85,7 +85,6 @@ func (self *SuggestionsController) GetMouseKeybindings(opts types.KeybindingsOpt
func (self *SuggestionsController) switchToPrompt() error {
self.c.Views().Suggestions.Subtitle = ""
self.c.Views().Suggestions.Highlight = false
self.c.Context().Replace(self.c.Contexts().Prompt)
return nil
}
@@ -4,6 +4,7 @@ import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -24,17 +25,48 @@ type SwitchToDiffFilesController struct {
baseController
c *ControllerCommon
context CanSwitchToDiffFiles
// what this panel offers on the diff it shows in the focused main view
diffActions *CommitDiffActions
}
func NewSwitchToDiffFilesController(
c *ControllerCommon,
context CanSwitchToDiffFiles,
) *SwitchToDiffFilesController {
return &SwitchToDiffFilesController{
controller := &SwitchToDiffFilesController{
baseController: baseController{},
c: c,
context: context,
}
controller.diffActions = NewCommitDiffActions(c, context, controller.diffTarget)
return controller
}
// diffTarget is the commit — or stash entry, or range of commits — the panel has
// selected, whose whole diff its main view shows.
func (self *SwitchToDiffFilesController) diffTarget() *commitDiffTarget {
ref := self.context.GetSelectedRef()
if ref == nil {
return nil
}
refRange := self.context.GetSelectedRefRangeForDiffFiles()
from, to := context.FromAndToForDiff(ref, refRange)
return &commitDiffTarget{from: from, to: to, canRebase: self.canRebase(ref, refRange)}
}
// canRebase reports whether the given selection is one lazygit may rewrite: the panel
// has to allow it in the first place, a range of commits can't be rewritten as one,
// and in diffing mode what the main view shows is a diff against another ref rather
// than the commit itself, unless that other ref is the selected commit.
func (self *SwitchToDiffFilesController) canRebase(ref models.Ref, refRange *types.RefRange) bool {
if !self.context.CanRebase() {
return false
}
if self.c.Modes().Diffing.Active() {
return self.c.Modes().Diffing.Ref == ref.RefName()
}
return refRange == nil
}
func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
@@ -64,21 +96,16 @@ func (self *SwitchToDiffFilesController) GetOnDoubleClick() func() error {
}
}
func (self *SwitchToDiffFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.diffActions
}
func (self *SwitchToDiffFilesController) enter() error {
ref := self.context.GetSelectedRef()
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
commitFilesContext := self.c.Contexts().CommitFiles
canRebase := self.context.CanRebase()
if canRebase {
if self.c.Modes().Diffing.Active() {
if self.c.Modes().Diffing.Ref != ref.RefName() {
canRebase = false
}
} else if refsRange != nil {
canRebase = false
}
}
canRebase := self.canRebase(ref, refsRange)
commitFilesContext.ClearFilter()
commitFilesContext.ReInit(ref, refsRange)
@@ -1,7 +1,9 @@
package controllers
import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -61,21 +63,51 @@ 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 focusMainView(self.c, self.context, -1)
}
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error {
if context, ok := mainViewContext.(types.ISearchableContext); ok {
context.ClearSearchString()
func focusMainView(c *ControllerCommon, source types.Context, clickedLineIdx int) error {
// Usually the main pane, but the content can be in the secondary one alone: a file
// with nothing but staged changes shows them there.
mainViewContext := c.Contexts().Normal
if c.State().GetRepoState().GetMainPanes() == types.SecondaryPaneOnly {
mainViewContext = c.Contexts().NormalSecondary
}
self.c.Context().Push(mainViewContext, types.OnFocusOpts{})
return focusMainViewPane(c, source, mainViewContext, clickedLineIdx)
}
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *context.MainContext, clickedLineIdx int) error {
return focusMainViewPane(self.c, self.context, mainViewContext, clickedLineIdx)
}
func focusMainViewPane(c *ControllerCommon, source types.Context, mainViewContext *context.MainContext, clickedLineIdx int) error {
mainViewContext.ClearSearchString()
c.Context().Push(mainViewContext, types.OnFocusOpts{})
if _, ok := source.(types.DiffMainViewContext); !ok {
return nil
}
// The diff on screen was produced for reading, and the renderer that produced it may
// have laid it out in a way that says nothing about which line of which file each row
// is. Now that the user wants to act on it, it is re-rendered as git's own diff — the
// panel below decides that for itself, from the same question — and the selection
// goes on that instead of on rows we can't place.
if c.Helpers().DiffLine.MainViewDiffMode() == git_commands.DiffModeRaw {
c.Helpers().DiffLine.RenderFocusedMainViewAgain(mainViewContext.GetView(), source, func() {
c.Helpers().DiffLine.EstablishSelection(mainViewContext, clickedLineIdx)
})
return nil
}
c.Helpers().DiffLine.EstablishSelection(mainViewContext, clickedLineIdx)
return nil
}
@@ -1,32 +1,18 @@
package controllers
import (
"errors"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type ToggleWhitespaceAction struct {
c *ControllerCommon
}
func (self *ToggleWhitespaceAction) Call() error {
contextsThatDontSupportIgnoringWhitespace := []types.ContextKey{
context.STAGING_MAIN_CONTEXT_KEY,
context.STAGING_SECONDARY_CONTEXT_KEY,
context.PATCH_BUILDING_MAIN_CONTEXT_KEY,
}
if lo.Contains(contextsThatDontSupportIgnoringWhitespace, self.c.Context().Current().GetKey()) {
// Ignoring whitespace is not supported in these views. Let the user
// know that it's not going to work in case they try to turn it on.
return errors.New(self.c.Tr.IgnoreWhitespaceNotSupportedHere)
}
self.c.UserConfig().Git.IgnoreWhitespaceInDiffView = !self.c.UserConfig().Git.IgnoreWhitespaceInDiffView
self.c.Context().CurrentSide().HandleFocus(types.OnFocusOpts{})
// You toggle this to see whether what you are looking at is more than
// reindentation, so that is the thing to keep in front of you — even though
// ignoring whitespace, unlike the other ways of re-rendering a diff, can take
// the line away entirely along with the hunk or file it was in.
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
self.c.Context().CurrentSide().HandleRenderToMain()
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
}
@@ -0,0 +1,310 @@
package controllers
import (
"fmt"
"path/filepath"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// WorkingTreeDiffActions is what the files panel offers on the diff it renders into
// the focused main view: the diff itself, for the commands that need to read lines out
// of it rather than off the screen.
type WorkingTreeDiffActions struct {
c *ControllerCommon
}
var _ types.FocusedMainViewActions = &WorkingTreeDiffActions{}
func NewWorkingTreeDiffActions(c *ControllerCommon) *WorkingTreeDiffActions {
return &WorkingTreeDiffActions{c: c}
}
func (self *WorkingTreeDiffActions) context() *context.WorkingTreeContext {
return self.c.Contexts().Files
}
// PlainDiff hands out the working tree's diff for the given files, taken from the
// side of the index that the asking pane shows.
func (self *WorkingTreeDiffActions) PlainDiff(pane types.DiffPaneContext, paths []string) string {
node := self.context().GetSelected()
if node == nil {
return ""
}
// An error means there is no diff to be had, which for our purposes is the same as
// an empty one.
diff, _ := self.c.Git().WorkingTree.
WorktreeFileDiffCmdObj(node, git_commands.DiffModePlain, self.showsStagedSide(pane), paths).
RunWithOutput()
return diff
}
// showsStagedSide reports whether the given main pane is the one showing the staged
// side of a file's diff, which is always the lower one.
func (self *WorkingTreeDiffActions) showsStagedSide(pane types.DiffPaneContext) bool {
return pane.GetKey() == self.c.Contexts().NormalSecondary.GetKey()
}
// PrimaryAction stages the selected diff lines, or takes them back out of the index
// when what is selected is the staged side of the diff.
func (self *WorkingTreeDiffActions) PrimaryAction(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
infos, onStagedSide, ok := self.diffLineSelection(pane, firstLineIdx, lastLineIdx)
if !ok {
return nil
}
// Either way the patch goes to the index: forwards from the unstaged side to stage
// it, backwards from the staged side to take it back out.
return self.applyDiffLineSelection(pane, firstLineIdx, infos, onStagedSide,
git_commands.ApplyPatchOpts{Reverse: onStagedSide, Cached: true})
}
// DiscardSelection takes the selected diff lines out of the working tree — or, on the
// staged side, out of the index, which is where "discard this" means "I don't want it
// staged".
func (self *WorkingTreeDiffActions) DiscardSelection(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToDiscard,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
infos, onStagedSide, ok := self.diffLineSelection(pane, firstLineIdx, lastLineIdx)
if !ok {
return nil
}
// Either way the change is applied backwards; the side it is applied to is what
// makes the difference. On the staged side that is the index, which is the same
// thing as unstaging and so is not destructive. On the unstaged side it is the
// working tree, where the change is gone for good — hence the confirmation.
return self.c.ConfirmIf(!onStagedSide && !self.c.UserConfig().Gui.SkipDiscardChangeWarning,
types.ConfirmOpts{
Title: self.c.Tr.DiscardChangeTitle,
Prompt: self.c.Tr.DiscardChangePrompt,
HandleConfirm: func() error {
return self.applyDiffLineSelection(pane, firstLineIdx, infos, onStagedSide,
git_commands.ApplyPatchOpts{Reverse: true, Cached: onStagedSide})
},
})
}
// DiscardSelectionDisabledReason is nil: a change of the working tree can always be
// thrown away, and one in the index always taken back out of it.
func (self *WorkingTreeDiffActions) DiscardSelectionDisabledReason(types.DiffPaneContext) *types.DisabledReason {
return nil
}
// PatchInclusion is nil: a custom patch is built from a commit's diff, never from the
// working tree's, so no line of this diff is ever in one.
func (self *WorkingTreeDiffActions) PatchInclusion() func(types.DiffLineInfo) bool {
return nil
}
// diffLineSelection resolves what the user has selected in a pane of the focused main
// view to the change lines to act on, and reports whether they are the staged side of
// the diff — which is a question about the pane, so it is the same for every file of a
// directory's diff. ok is false when the selection holds no change line, in which case
// there is nothing to act on.
func (self *WorkingTreeDiffActions) diffLineSelection(
pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int,
) (infos []types.DiffLineInfo, onStagedSide bool, ok bool) {
infos = self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
if len(infos) == 0 {
return nil, false, false
}
return infos, self.showsStagedSide(pane), true
}
// applyDiffLineSelection applies the selected change lines, a patch per file, and
// re-renders what that changed. onStagedSide says which of the file's two diffs the
// lines were selected in and so are to be found in; opts says how to apply them.
// firstLineIdx is where the selection started, which is where the work carries on from
// once the diff has changed under it.
func (self *WorkingTreeDiffActions) applyDiffLineSelection(
pane types.DiffPaneContext, firstLineIdx int,
infos []types.DiffLineInfo, onStagedSide bool, opts git_commands.ApplyPatchOpts,
) error {
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
// A directory's diff spans several files, and a patch is of one file, so the
// selected lines are grouped by the file they belong to and applied file by file.
infosByFile := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path })
acted := set.New[string]()
actedSideRemains := false
for path, fileInfos := range infosByFile {
file := self.fileForDiffLinePath(path)
if file == nil {
continue
}
changesLeft, err := self.applyDiffLines(file, fileInfos, onStagedSide, opts)
if err != nil {
return err
}
acted.Add(file.GetPath())
actedSideRemains = actedSideRemains || changesLeft
}
if !actedSideRemains {
actedSideRemains = self.anyFileHasChangesOnSide(acted, onStagedSide)
}
// Whether the other side has anything is a question about the pane the work would
// carry on in. Where the lines went into the index they are there now; where they
// were thrown away that side is as the model describes it, having not been touched.
otherSideHasChanges := opts.Cached || self.anyFileHasChangesOnSide(set.New[string](), !onStagedSide)
// The refresh below queues the re-render of the diff we just changed; this rides it,
// so that the selection ends up on the change that took the place of the one acted
// on rather than at a position that means nothing any more — and in the pane the
// work carries on in, which is not always the one it was in.
self.revealSelectionInPaneItLandsIn(pane, firstLineIdx, actedSideRemains, otherSideHasChanges)
// Block input until the refresh has landed, so that a quick second keypress acts on
// the diff as it now is rather than on the one we just changed.
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
return nil
}
// fileForDiffLinePath maps the absolute path a diff line carries to the working tree
// file it belongs to, or nil for a path that is no file of this repo's working tree.
func (self *WorkingTreeDiffActions) fileForDiffLinePath(path string) *models.File {
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
if err != nil {
return nil
}
return self.context().FileTreeViewModel.GetFile(filepath.ToSlash(relativePath))
}
// applyDiffLines applies the given change lines of one file — a line, a hunk, a range —
// as a patch built from that file's own diff:
//
// - stage: read the unstaged diff, apply it to the index
// - unstage: read the staged diff, apply it to the index backwards
//
// sourceCached names the diff the lines were selected in, which is where they are found
// again; opts says how to apply what is built from them. The two are independent — a
// discard reads one side and reverses it — so they are passed separately.
//
// Each selected line is looked for by where it sits in the file, which is what tells
// the two halves of a modified line apart: the deletion and the addition replacing it
// share a position in the new file and differ only in being a deletion. Context lines
// are not selected: a patch of the lines you picked keeps whatever context it needs
// around them by itself.
//
// It reports whether the diff it read holds changes the selection didn't cover, which
// is how the caller knows whether the side acted on still has anything of this file in
// it once we are done.
func (self *WorkingTreeDiffActions) applyDiffLines(
file *models.File, infos []types.DiffLineInfo, sourceCached bool, opts git_commands.ApplyPatchOpts,
) (bool, error) {
parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, sourceCached))
patchLineIndices := patch.ChangeLineIndicesForLines(parsedPatch,
lo.Map(infos, func(info types.DiffLineInfo, _ int) patch.LineIdentity {
return info.PatchLineIdentity()
}))
changesLeft := len(patchLineIndices) < changeLineCount(parsedPatch)
// Acting on every change of a file is acting on the file itself, and saying so is
// not the same as applying its diff. The diff of a deleted file is its content
// going away, and putting that into the index line by line leaves an empty file
// there rather than the deletion; the diff of an added one is its whole content,
// and taking that back out leaves an empty file in the index rather than an
// untracked one.
if !changesLeft && opts.Cached {
if opts.Reverse {
return false, self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked)
}
return false, self.c.Git().WorkingTree.StageFile(file.GetPath())
}
patchToApply := parsedPatch.
Transform(patch.TransformOpts{
Reverse: opts.Reverse,
IncludedLineIndices: patchLineIndices,
FileNameOverride: file.GetPath(),
}).
FormatPlain()
if patchToApply == "" {
return changesLeft, nil
}
return changesLeft, self.c.Git().Patch.ApplyPatch(patchToApply, opts)
}
// changeLineCount returns how many of a patch's lines are changes rather than context
// or header, which is how many of them a selection of the whole diff covers.
func changeLineCount(p *patch.Patch) int {
return lo.CountBy(p.Lines(), func(line *patch.PatchLine) bool {
return line.IsAddition() || line.IsDeletion()
})
}
// revealSelectionInPaneItLandsIn arranges for the selection to carry on where the work
// does, which is not always the pane it was in.
//
// Each side of the diff has a pane of its own, so acting on one usually leaves
// everything where it is. But a pane is only shown while its side has something in it:
// staging the last unstaged change takes the upper pane away, and unstaging the last
// staged one takes the lower one away. The refresh moves the focus into whichever pane
// is left, and this puts the selection there to meet it — on the lines just acted on,
// which are in that pane now, unless they were discarded rather than moved, in which
// case on what is left of the file.
func (self *WorkingTreeDiffActions) revealSelectionInPaneItLandsIn(
pane types.DiffPaneContext, firstLineIdx int, actedSideRemains bool, otherSideHasChanges bool,
) {
target := pane
if !actedSideRemains && otherSideHasChanges {
target = self.otherPane(pane)
}
// Hold input back until the selection is on the change the work carries on from. The
// refresh holds it until the model is up to date, but the diff is re-rendered after
// that, and until it has been the selection is still on lines that aren't there any
// more — so a key pressed meanwhile would act on nothing.
self.c.GocuiGui().BeginBlockingEvents()
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, target, firstLineIdx, 0,
func() { _ = self.c.GocuiGui().EndBlockingEvents() })
}
// otherPane returns the main pane that isn't the given one.
func (self *WorkingTreeDiffActions) otherPane(pane types.DiffPaneContext) types.DiffPaneContext {
if pane.GetKey() == self.c.Contexts().Normal.GetKey() {
return self.c.Contexts().NormalSecondary
}
return self.c.Contexts().Normal
}
// anyFileHasChangesOnSide reports whether any file under the selected node, other than
// the ones named by except, has changes on the given side of the index, as the model
// has them. The model is right about any file the action didn't touch; the ones it did
// touch report for themselves, their entry not being right until the refresh lands.
func (self *WorkingTreeDiffActions) anyFileHasChangesOnSide(except *set.Set[string], staged bool) bool {
node := self.context().GetSelected()
if node == nil {
return false
}
found := false
_ = node.ForEachFile(func(file *models.File) error {
if except.Includes(file.GetPath()) {
return nil
}
if (staged && file.HasStagedChanges) || (!staged && file.HasUnstagedChanges) {
found = true
}
return nil
})
return found
}
+13 -12
View File
@@ -22,12 +22,7 @@ func (gui *Gui) scrollDownView(view *gocui.View) {
}
func (gui *Gui) scrollUpMain() error {
var view *gocui.View
if gui.c.Context().Current().GetWindowName() == "secondary" {
view = gui.secondaryView()
} else {
view = gui.mainView()
}
view := gui.mainSectionView()
if view.Name() == "mergeConflicts" {
// although we have this same logic in the controller, this method can be invoked
@@ -43,12 +38,7 @@ func (gui *Gui) scrollUpMain() error {
}
func (gui *Gui) scrollDownMain() error {
var view *gocui.View
if gui.c.Context().Current().GetWindowName() == "secondary" {
view = gui.secondaryView()
} else {
view = gui.mainView()
}
view := gui.mainSectionView()
if view.Name() == "mergeConflicts" {
gui.State.Contexts.MergeConflicts.SetUserScrolling(true)
@@ -59,6 +49,17 @@ func (gui *Gui) scrollDownMain() error {
return nil
}
// mainSectionView returns the view that the keys for scrolling the main section act
// on: the pane the focus is in when it is in one of them, and otherwise the pane the
// section is showing — which is the lower one whenever it has the section to itself.
func (gui *Gui) mainSectionView() *gocui.View {
if gui.c.Context().Current().GetWindowName() == "secondary" ||
gui.State.MainPanes == types.SecondaryPaneOnly {
return gui.secondaryView()
}
return gui.mainView()
}
func (gui *Gui) mainView() *gocui.View {
viewName := gui.helpers.Window.GetViewNameForWindow("main")
view, _ := gui.g.View(viewName)

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