-```
-
-## Feedback
-
-If you think this process can be improved, let me know! It shouldn't be too hard to change things.
+see new docs [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md
index e855bb056..d9884256b 100644
--- a/docs/keybindings/Keybindings_en.md
+++ b/docs/keybindings/Keybindings_en.md
@@ -5,36 +5,38 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
## Global Keybindings
- ctrl+r: switch to a recent repo ()
- pgup: scroll up main panel (fn+up)
- pgdown: scroll down main panel (fn+down)
+ ctrl+r: switch to a recent repo
+ pgup: scroll up main panel (fn+up/shift+k)
+ pgdown: scroll down main panel (fn+down/shift+j)
m: view merge/rebase options
ctrl+p: view custom patch options
- P: push
- p: pull
R: refresh
x: open menu
- z: undo (via reflog) (experimental)
- ctrl+z: redo (via reflog) (experimental)
+: next screen mode (normal/half/fullscreen)
_: prev screen mode
- :: execute custom command
ctrl+s: view filter-by-path options
W: open diff menu
ctrl+e: open diff menu
@: open command log menu
}: Increase the size of the context shown around changes in the diff view
{: Decrease the size of the context shown around changes in the diff view
+ :: execute custom command
+ z: undo (via reflog) (experimental)
+ ctrl+z: redo (via reflog) (experimental)
+ P: push
+ p: pull
## List Panel Navigation
- .: next page
,: previous page
+ .: next page
<: scroll to top
- >: scroll to bottom
/: start search
+ >: scroll to bottom
+ H: scroll left
+ L: scroll right
]: next tab
[: previous tab
@@ -43,8 +45,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
space: checkout
- o: create / open pull request
- O: create / open pull request options
+ o: create pull request
+ O: create pull request options
ctrl+y: copy pull request URL to clipboard
c: checkout by name
F: force checkout
@@ -116,93 +118,223 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
o: open file
e: edit file
space: toggle file included in patch
+ a: toggle all files included in patch
enter: enter file to add selected lines to the patch (or toggle directory collapsed)
`: toggle file tree view
-## Commits Panel (Commits)
+## Commits
- ctrl+l: open log menu
+ ctrl+o: copy commit SHA to clipboard
+ ctrl+r: reset cherry-picked (copied) commits selection
+ b: view bisect options
s: squash down
+ f: fixup commit
r: reword commit
R: reword commit with editor
- g: reset to this commit
- f: fixup commit
+ d: delete commit
+ e: edit commit
+ p: pick commit (when mid-rebase)
F: create fixup commit for this commit
S: squash all 'fixup!' commits above selected commit (autosquash)
- d: delete commit
ctrl+j: move commit down one
ctrl+k: move commit up one
- e: edit commit
- A: amend commit with staged changes
- p: pick commit (when mid-rebase)
- t: revert commit
- c: copy commit (cherry-pick)
- ctrl+o: copy commit SHA to clipboard
- C: copy commit range (cherry-pick)
v: paste commits (cherry-pick)
- enter: view commit's files
- space: checkout commit
- n: create new branch off of commit
+ A: amend commit with staged changes
+ a: reset commit author
+ t: revert commit
T: tag commit
- ctrl+r: reset cherry-picked (copied) commits selection
- ctrl+y: copy commit message to clipboard
- o: open commit in browser
- b: view bisect options
-
-
-## Commits Panel (Reflog Tab)
-
-
- enter: view commit's files
+ ctrl+l: open log menu
space: checkout commit
+ y: copy commit attribute
+ o: open commit in browser
+ n: create new branch off of commit
g: view reset options
c: copy commit (cherry-pick)
C: copy commit range (cherry-pick)
- ctrl+r: reset cherry-picked (copied) commits selection
- ctrl+o: copy commit SHA to clipboard
+ enter: view selected item's files
-## Extras Panel
-
-
- @: open command log menu
-
-
-## Files Panel
-
-
- ctrl+b: Filter commit files
-
-
-## Files Panel (Files)
+## Files
+ ctrl+o: copy the file name to the clipboard
+ ctrl+w: Toggle whether or not whitespace changes are shown in the diff view
+ d: view 'discard changes' options
+ space: toggle staged
+ ctrl+b: Filter files (staged/unstaged)
c: commit changes
w: commit changes without pre-commit hook
A: amend last commit
C: commit changes using git editor
- space: toggle staged
- d: view 'discard changes' options
e: edit file
o: open file
- i: add to .gitignore
+ i: ignore or exclude file
r: refresh files
- s: stash changes
+ s: stash all changes
S: view stash options
a: stage/unstage all
- D: view reset options
enter: stage individual hunks/lines for file, or collapse/expand for directory
- f: fetch
- ctrl+o: copy the file name to the clipboard
g: view upstream reset options
+ D: view reset options
`: toggle file tree view
M: open external merge tool (git mergetool)
- ctrl+w: Toggle whether or not whitespace changes are shown in the diff view
+ f: fetch
-## Files Panel (Submodules)
+## Local Branches
+
+
+ ctrl+o: copy branch name to clipboard
+ i: show git-flow options
+ space: checkout
+ n: new branch
+ o: create pull request
+ O: create pull request options
+ ctrl+y: copy pull request URL to clipboard
+ c: checkout by name
+ F: force checkout
+ d: delete branch
+ r: rebase checked-out branch onto this branch
+ M: merge into currently checked out branch
+ f: fast-forward this branch from its upstream
+ g: view reset options
+ R: rename branch
+ u: set/unset upstream
+ enter: view commits
+
+
+## Main Panel (Merging)
+
+
+ e: edit file
+ o: open file
+ â—„: select previous conflict
+ â–ş: select next conflict
+ â–˛: select previous hunk
+ â–Ľ: select next hunk
+ z: undo
+ M: open external merge tool (git mergetool)
+ space: pick hunk
+ b: pick all hunks
+ esc: return to files panel
+
+
+## Main Panel (Normal)
+
+
+ mouse wheel â–Ľ: scroll down (fn+up)
+ mouse wheel â–˛: scroll up (fn+down)
+
+
+## Main Panel (Patch Building)
+
+
+ â—„: select previous hunk
+ â–ş: select next hunk
+ v: toggle drag select
+ V: toggle drag select
+ a: toggle select hunk
+ ctrl+o: copy the selected text to the clipboard
+ o: open file
+ e: edit file
+ space: add/remove line(s) to patch
+ esc: exit custom patch builder
+
+
+## Main Panel (Staging)
+
+
+ â—„: select previous hunk
+ â–ş: select next hunk
+ v: toggle drag select
+ V: toggle drag select
+ a: toggle select hunk
+ ctrl+o: copy the selected text to the clipboard
+ o: open file
+ e: edit file
+ esc: return to files panel
+ tab: switch to other panel (staged/unstaged changes)
+ space: toggle line staged / unstaged
+ d: delete change (git reset)
+ E: edit hunk
+
+
+## Reflog
+
+
+ ctrl+o: copy commit SHA to clipboard
+ space: checkout commit
+ y: copy commit attribute
+ o: open commit in browser
+ n: create new branch off of commit
+ g: view reset options
+ c: copy commit (cherry-pick)
+ C: copy commit range (cherry-pick)
+ ctrl+r: reset cherry-picked (copied) commits selection
+ enter: view commits
+
+
+## Remote Branches
+
+
+ space: checkout
+ n: new branch
+ M: merge into currently checked out branch
+ r: rebase checked-out branch onto this branch
+ d: delete branch
+ u: set as upstream of checked-out branch
+ esc: Return to remotes list
+ g: view reset options
+ enter: view commits
+
+
+## Remotes
+
+
+ f: fetch remote
+ n: add new remote
+ d: remove remote
+ e: edit remote
+
+
+## Stash
+
+
+ space: apply
+ g: pop
+ d: drop
+ n: new branch
+ enter: view selected item's files
+
+
+## Status
+
+
+ e: edit config file
+ o: open config file
+ u: check for update
+ enter: switch to a recent repo
+ a: show all branch logs
+
+
+## Sub-commits
+
+
+ ctrl+o: copy commit SHA to clipboard
+ space: checkout commit
+ y: copy commit attribute
+ o: open commit in browser
+ n: create new branch off of commit
+ g: view reset options
+ c: copy commit (cherry-pick)
+ C: copy commit range (cherry-pick)
+ ctrl+r: reset cherry-picked (copied) commits selection
+ enter: view selected item's files
+
+
+## Submodules
ctrl+o: copy submodule name to clipboard
@@ -215,94 +347,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
b: view bulk submodule options
-## Main Panel (Merging)
+## Tags
- H: scroll left
- L: scroll right
- esc: return to files panel
- M: open external merge tool (git mergetool)
- space: pick hunk
- b: pick all hunks
- â—„: select previous conflict
- â–ş: select next conflict
- â–˛: select previous hunk
- â–Ľ: select next hunk
- z: undo
-
-
-## Main Panel (Normal)
-
-
- Ĺ: scroll down (fn+up)
- Ĺ‘: scroll up (fn+down)
-
-
-## Main Panel (Patch Building)
-
-
- esc: exit line-by-line mode
- o: open file
- â–˛: select previous line
- â–Ľ: select next line
- â—„: select previous hunk
- â–ş: select next hunk
- ctrl+o: copy the selected text to the clipboard
- space: add/remove line(s) to patch
- v: toggle drag select
- V: toggle drag select
- a: toggle select hunk
- H: scroll left
- L: scroll right
-
-
-## Main Panel (Staging)
-
-
- esc: return to files panel
- space: toggle line staged / unstaged
- d: delete change (git reset)
- tab: switch to other panel
- o: open file
- â–˛: select previous line
- â–Ľ: select next line
- â—„: select previous hunk
- â–ş: select next hunk
- ctrl+o: copy the selected text to the clipboard
- e: edit file
- o: open file
- v: toggle drag select
- V: toggle drag select
- a: toggle select hunk
- H: scroll left
- L: scroll right
- c: commit changes
- w: commit changes without pre-commit hook
- C: commit changes using git editor
-
-
-## Menu Panel
-
-
- esc: close menu
-
-
-## Stash Panel
-
-
- enter: view stash entry's files
- space: apply
- g: pop
- d: drop
- n: new branch
-
-
-## Status Panel
-
-
- e: edit config file
- o: open config file
- u: check for update
- enter: switch to a recent repo
- a: show all branch logs
+ space: checkout
+ d: delete tag
+ P: push tag
+ n: create tag
+ g: view reset options
+ enter: view commits
diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md
new file mode 100644
index 000000000..d22f6d150
--- /dev/null
+++ b/docs/keybindings/Keybindings_ja.md
@@ -0,0 +1,291 @@
+_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
+
+# Lazygit ă‚ăĽăイăłă‰
+
+## ă‚°ăăĽăă«ă‚ăĽăイăłă‰
+
+
+ ctrl+r: 最近使用ă—ăźăŞăťă‚¸ăăŞă«ĺ‡ă‚Šć›żă
+ pgup: ăˇă‚¤ăłă‘ăŤă«ă‚’上ă«ă‚ąă‚ŻăăĽă« (fn+up/shift+k)
+ pgdown: ăˇă‚¤ăłă‘ăŤă«ă‚’下ă«ă‚ąă‚ŻăăĽă« (fn+down/shift+j)
+ m: view merge/rebase options
+ ctrl+p: view custom patch options
+ R: ăŞă•ă¬ăă‚·ăĄ
+ x: ăˇă‹ăĄăĽă‚’é–‹ăŹ
+ +: 次ă®ă‚ąă‚ŻăŞăĽăłă˘ăĽă‰ (normal/half/fullscreen)
+ _: 前ă®ă‚ąă‚ŻăŞăĽăłă˘ăĽă‰
+ ctrl+s: view filter-by-path options
+ W: ĺ·®ĺ†ăˇă‹ăĄăĽă‚’é–‹ăŹ
+ ctrl+e: ĺ·®ĺ†ăˇă‹ăĄăĽă‚’é–‹ăŹ
+ @: コăžăłă‰ăă‚°ăˇă‹ăĄăĽă‚’é–‹ăŹ
+ }: Increase the size of the context shown around changes in the diff view
+ {: Decrease the size of the context shown around changes in the diff view
+ :: カスタă コăžăłă‰ă‚’実行
+ z: アăłă‰ă‚Ą (via reflog) (experimental)
+ ctrl+z: ăŞă‰ă‚Ą (via reflog) (experimental)
+ P: push
+ p: pull
+
+
+## 一覧ă‘ăŤă«ă®ć“Ťä˝ś
+
+
+ ,: 前ă®ăšăĽă‚¸
+ .: 次ă®ăšăĽă‚¸
+ <: 最上é¨ăľă§ă‚ąă‚ŻăăĽă«
+ /: 検索を開始
+ >: 最下é¨ăľă§ă‚ąă‚ŻăăĽă«
+ H: 左スクăăĽă«
+ L: 右スクăăĽă«
+ ]: 次ă®ă‚żă–
+ [: 前ă®ă‚żă–
+
+
+## Stash
+
+
+ space: é©ç”¨
+ g: pop
+ d: drop
+ n: ć–°ă—ă„ă–ă©ăłăを作ć
+ enter: view selected item's files
+
+
+## Sub-commits
+
+
+ ctrl+o: コăźăăă®SHAをクăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ
+ space: コăźăăă‚’ăă‚§ăクアウă
+ y: コăźăăă®ć…ĺ ±ă‚’ă‚łă”ăĽ
+ o: ă–ă©ă‚¦ă‚¶ă§ă‚łăźăăă‚’é–‹ăŹ
+ n: コăźăăă«ă–ă©ăłăを作ć
+ g: view reset options
+ c: コăźăăをコă”㼠(cherry-pick)
+ C: コăźăăを範囲コă”㼠(cherry-pick)
+ ctrl+r: reset cherry-picked (copied) commits selection
+ enter: view selected item's files
+
+
+## コăźăă
+
+
+ ctrl+o: コăźăăă®SHAをクăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ
+ ctrl+r: reset cherry-picked (copied) commits selection
+ b: view bisect options
+ s: squash down
+ f: fixup commit
+ r: コăźăăăˇăă‚»ăĽă‚¸ă‚’変更
+ R: エă‡ă‚Łă‚żă§ă‚łăźăăăˇăă‚»ăĽă‚¸ă‚’編集
+ d: コăźăăを削除
+ e: コăźăăを編集
+ p: pick commit (when mid-rebase)
+ F: ă“ă®ă‚łăźăăă«ĺŻľă™ă‚‹fixupコăźăăを作ć
+ S: squash all 'fixup!' commits above selected commit (autosquash)
+ ctrl+j: コăźăăă‚’1ă¤ä¸‹ă«ç§»ĺ‹•
+ ctrl+k: コăźăăă‚’1ă¤ä¸Šă«ç§»ĺ‹•
+ v: コăźăăを貼りä»ă‘ (cherry-pick)
+ A: スă†ăĽă‚¸ă•れăźĺ¤‰ć›´ă§amendコăźăă
+ a: reset commit author
+ t: コăźăăă‚’revert
+ T: タグを作ć
+ ctrl+l: ăă‚°ăˇă‹ăĄăĽă‚’é–‹ăŹ
+ space: コăźăăă‚’ăă‚§ăクアウă
+ y: コăźăăă®ć…ĺ ±ă‚’ă‚łă”ăĽ
+ o: ă–ă©ă‚¦ă‚¶ă§ă‚łăźăăă‚’é–‹ăŹ
+ n: コăźăăă«ă–ă©ăłăを作ć
+ g: view reset options
+ c: コăźăăをコă”㼠(cherry-pick)
+ C: コăźăăを範囲コă”㼠(cherry-pick)
+ enter: view selected item's files
+
+
+## コăźăăă•ァイă«
+
+
+ ctrl+o: コăźăăă•れăźă•ァイă«ĺŤă‚’クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ
+ c: checkout file
+ d: discard this commit's changes to this file
+ o: ă•ァイă«ă‚’é–‹ăŹ
+ e: ă•ァイă«ă‚’編集
+ space: toggle file included in patch
+ a: toggle all files included in patch
+ enter: enter file to add selected lines to the patch (or toggle directory collapsed)
+ `: ă•ァイă«ă„ăŞăĽă®čˇ¨ç¤şă‚’ĺ‡ă‚Šć›żă
+
+
+## サă–ă˘ă‚¸ăĄăĽă«
+
+
+ ctrl+o: サă–ă˘ă‚¸ăĄăĽă«ĺŤă‚’クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ
+ enter: サă–ă˘ă‚¸ăĄăĽă«ă‚’é–‹ăŹ
+ d: サă–ă˘ă‚¸ăĄăĽă«ă‚’削除
+ u: サă–ă˘ă‚¸ăĄăĽă«ă‚’ć›´ć–°
+ n: サă–ă˘ă‚¸ăĄăĽă«ă‚’新規追ĺŠ
+ e: サă–ă˘ă‚¸ăĄăĽă«ă®URLă‚’ć›´ć–°
+ i: サă–ă˘ă‚¸ăĄăĽă«ă‚’ĺťćśźĺŚ–
+ b: view bulk submodule options
+
+
+## スă†ăĽă‚żă‚ą
+
+
+ e: č¨ĺ®šă•ァイă«ă‚’編集
+ o: č¨ĺ®šă•ァイă«ă‚’é–‹ăŹ
+ u: 更新を確認
+ enter: 最近使用ă—ăźăŞăťă‚¸ăăŞă«ĺ‡ă‚Šć›żă
+ a: ă™ăąă¦ă®ă–ă©ăłăăグを表示
+
+
+## タグ
+
+
+ space: ăă‚§ăクアウă
+ d: タグを削除
+ P: タグをpush
+ n: タグを作ć
+ g: view reset options
+ enter: コăźăăを閲覧
+
+
+## ă•ァイă«
+
+
+ ctrl+o: ă•ァイă«ĺŤă‚’クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ
+ ctrl+w: 空白文ĺ—ă®ĺ·®ĺ†ă®čˇ¨ç¤şćś‰ç„ˇă‚’ĺ‡ă‚Šć›żă
+ d: view 'discard changes' options
+ space: スă†ăĽă‚¸/アăłă‚ąă†ăĽă‚¸
+ ctrl+b: ă•ァイă«ă‚’ă•ィă«ă‚ż (スă†ăĽă‚¸/アăłă‚ąă†ăĽă‚¸)
+ c: 変更をコăźăă
+ w: pre-commită•ăクを実行ă›ăšă«ĺ¤‰ć›´ă‚’コăźăă
+ A: 最新ă®ă‚łăźăăă«amend
+ C: gitエă‡ă‚Łă‚żă‚’使用ă—ă¦ĺ¤‰ć›´ă‚’コăźăă
+ e: ă•ァイă«ă‚’編集
+ o: ă•ァイă«ă‚’é–‹ăŹ
+ i: ă•ァイă«ă‚’ignore
+ r: ă•ァイă«ă‚’ăŞă•ă¬ăă‚·ăĄ
+ s: 変更をstash
+ S: view stash options
+ a: ă™ăąă¦ă®ĺ¤‰ć›´ă‚’スă†ăĽă‚¸/アăłă‚ąă†ăĽă‚¸
+ enter: stage individual hunks/lines for file, or collapse/expand for directory
+ g: view upstream reset options
+ D: view reset options
+ `: ă•ァイă«ă„ăŞăĽă®čˇ¨ç¤şă‚’ĺ‡ă‚Šć›żă
+ M: git mergetoolă‚’é–‹ăŹ
+ f: fetch
+
+
+## ă–ă©ăłă
+
+
+ ctrl+o: ă–ă©ăłăĺŤă‚’クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ
+ i: show git-flow options
+ space: ăă‚§ăクアウă
+ n: ć–°ă—ă„ă–ă©ăłăを作ć
+ o: Pull Requestを作ć
+ O: create pull request options
+ ctrl+y: Pull Requestă®URLをクăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ
+ c: checkout by name
+ F: force checkout
+ d: ă–ă©ăłăを削除
+ r: rebase checked-out branch onto this branch
+ M: 現在ă®ă–ă©ăłăă«ăžăĽă‚¸
+ f: fast-forward this branch from its upstream
+ g: view reset options
+ R: ă–ă©ăłăĺŤă‚’変更
+ u: set/unset upstream
+ enter: コăźăăを閲覧
+
+
+## ăˇă‚¤ăłă‘ăŤă« (Merging)
+
+
+ e: ă•ァイă«ă‚’編集
+ o: ă•ァイă«ă‚’é–‹ăŹ
+ â—„: 前ă®ă‚łăłă•ăŞă‚Żăă‚’é¸ćŠž
+ â–ş: 次ă®ă‚łăłă•ăŞă‚Żăă‚’é¸ćŠž
+ â–˛: 前ă®hunkă‚’é¸ćŠž
+ â–Ľ: 次ă®hunkă‚’é¸ćŠž
+ z: アăłă‰ă‚Ą
+ M: git mergetoolă‚’é–‹ăŹ
+ space: pick hunk
+ b: pick all hunks
+ esc: ă•ァイă«ä¸€č¦§ă«ć»ă‚‹
+
+
+## ăˇă‚¤ăłă‘ăŤă« (Normal)
+
+
+ mouse wheel â–Ľ: 下ă«ă‚ąă‚ŻăăĽă« (fn+up)
+ mouse wheel â–˛: 上ă«ă‚ąă‚ŻăăĽă« (fn+down)
+
+
+## ăˇă‚¤ăłă‘ăŤă« (Patch Building)
+
+
+ â—„: 前ă®hunkă‚’é¸ćŠž
+ â–ş: 次ă®hunkă‚’é¸ćŠž
+ v: 範囲é¸ćŠžă‚’ĺ‡ă‚Šć›żă
+ V: 範囲é¸ćŠžă‚’ĺ‡ă‚Šć›żă
+ a: hunké¸ćŠžă‚’ĺ‡ă‚Šć›żă
+ ctrl+o: é¸ćŠžă•れăźă†ă‚スăをクăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ
+ o: ă•ァイă«ă‚’é–‹ăŹ
+ e: ă•ァイă«ă‚’編集
+ space: 行をă‘ăăă«čż˝ĺŠ /削除
+ esc: exit custom patch builder
+
+
+## ăˇă‚¤ăłă‘ăŤă« (Staging)
+
+
+ â—„: 前ă®hunkă‚’é¸ćŠž
+ â–ş: 次ă®hunkă‚’é¸ćŠž
+ v: 範囲é¸ćŠžă‚’ĺ‡ă‚Šć›żă
+ V: 範囲é¸ćŠžă‚’ĺ‡ă‚Šć›żă
+ a: hunké¸ćŠžă‚’ĺ‡ă‚Šć›żă
+ ctrl+o: é¸ćŠžă•れăźă†ă‚スăをクăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ
+ o: ă•ァイă«ă‚’é–‹ăŹ
+ e: ă•ァイă«ă‚’編集
+ esc: ă•ァイă«ä¸€č¦§ă«ć»ă‚‹
+ tab: ă‘ăŤă«ă‚’ĺ‡ă‚Šć›żă
+ space: é¸ćŠžčˇŚă‚’ă‚ąă†ăĽă‚¸/アăłă‚ąă†ăĽă‚¸
+ d: 変更を削除 (git reset)
+ E: edit hunk
+
+
+## ăŞă˘ăĽă
+
+
+ f: ăŞă˘ăĽăă‚’fetch
+ n: ăŞă˘ăĽăを新規追ĺŠ
+ d: ăŞă˘ăĽăを削除
+ e: ăŞă˘ăĽăを編集
+
+
+## ăŞă˘ăĽăă–ă©ăłă
+
+
+ space: ăă‚§ăクアウă
+ n: ć–°ă—ă„ă–ă©ăłăを作ć
+ M: 現在ă®ă–ă©ăłăă«ăžăĽă‚¸
+ r: rebase checked-out branch onto this branch
+ d: ă–ă©ăłăを削除
+ u: set as upstream of checked-out branch
+ esc: ăŞă˘ăĽă一覧ă«ć»ă‚‹
+ g: view reset options
+ enter: コăźăăを閲覧
+
+
+## 参照ăă‚°
+
+
+ ctrl+o: コăźăăă®SHAをクăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ
+ space: コăźăăă‚’ăă‚§ăクアウă
+ y: コăźăăă®ć…ĺ ±ă‚’ă‚łă”ăĽ
+ o: ă–ă©ă‚¦ă‚¶ă§ă‚łăźăăă‚’é–‹ăŹ
+ n: コăźăăă«ă–ă©ăłăを作ć
+ g: view reset options
+ c: コăźăăをコă”㼠(cherry-pick)
+ C: コăźăăを範囲コă”㼠(cherry-pick)
+ ctrl+r: reset cherry-picked (copied) commits selection
+ enter: コăźăăを閲覧
+
diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md
new file mode 100644
index 000000000..690cd5790
--- /dev/null
+++ b/docs/keybindings/Keybindings_ko.md
@@ -0,0 +1,291 @@
+_This file is auto-generated. To update, make the changes in the pkg/i18n directory and then run `go run scripts/cheatsheet/main.go generate` from the project root._
+
+# Lazygit 키 바인딩
+
+## 글로벌 키 바인딩
+
+
+ ctrl+r: ěµśę·Ľě— ě‚¬ěš©í•ś ě €ěžĄě†Śëˇś ě „í™
+ pgup: 메인 패ë„ěť„ 위로 스í¬ëˇ¤ (fn+up/shift+k)
+ pgdown: 메인 패ë„ěť„ ě•„ëžëˇśëˇś 스í¬ëˇ¤ (fn+down/shift+j)
+ m: view merge/rebase options
+ ctrl+p: 커스텀 Patch ěµě… 보기
+ R: ěëˇśęł ěą¨
+ x: 매뉴 열기
+ +: 다음 스í¬ë¦° 모드 (normal/half/fullscreen)
+ _: ěť´ě „ 스í¬ë¦° 모드
+ ctrl+s: view filter-by-path options
+ W: Diff 메뉴 열기
+ ctrl+e: Diff 메뉴 열기
+ @: ëŞ…ë ąě–´ 로그 메뉴 열기
+ }: diff ëł´ę¸°ěť ëł€ę˛˝ ě‚¬í• ěŁĽěś„ě— í‘śě‹śë는 ě»¨í…ŤěŠ¤íŠ¸ěť í¬ę¸°ëĄĽ ëŠë¦¬ę¸°
+ {: diff ëł´ę¸°ěť ëł€ę˛˝ ě‚¬í• ěŁĽěś„ě— í‘śě‹śë는 컨텍스트 í¬ę¸° 줄이기
+ :: execute custom command
+ z: ë돌리기 (reflog) (실í—ě )
+ ctrl+z: 다시 실행 (reflog) (실í—ě )
+ P: 푸시
+ p: 업데이트
+
+
+## List Panel Navigation
+
+
+ ,: ěť´ě „ íŽěť´ě§€
+ .: 다음 íŽěť´ě§€
+ <: 맨 위로 스í¬ëˇ¤
+ /: ę˛€ě‰ ě‹śěž‘
+ >: 맨 ě•„ëžëˇś 스í¬ëˇ¤
+ H: ěš° 스í¬ëˇ¤
+ L: 좌 스í¬ëˇ¤
+ ]: ěť´ě „ í
+ [: 다음 í
+
+
+## Reflog
+
+
+ ctrl+o: 커밋 SHA를 í´ë¦˝ëł´ë“śě— 복사
+ space: 커밋을 체í¬ě•„ě›
+ y: 커밋 attribute 복사
+ o: ë¸ŚëťĽěš°ě €ě—서 커밋 열기
+ n: 커밋ě—서 ě 브랜ěąëĄĽ ë§Śë“ë‹ë‹¤.
+ g: view reset options
+ c: 커밋을 복사 (cherry-pick)
+ C: 커밋을 범위로 복사 (cherry-pick)
+ ctrl+r: reset cherry-picked (copied) commits selection
+ enter: 커밋 보기
+
+
+## Stash
+
+
+ space: ě ěš©
+ g: pop
+ d: drop
+ n: ě ë¸Śëžśěą ěťě„±
+ enter: view selected item's files
+
+
+## Sub-commits
+
+
+ ctrl+o: 커밋 SHA를 í´ë¦˝ëł´ë“śě— 복사
+ space: 커밋을 체í¬ě•„ě›
+ y: 커밋 attribute 복사
+ o: ë¸ŚëťĽěš°ě €ě—서 커밋 열기
+ n: 커밋ě—서 ě 브랜ěąëĄĽ ë§Śë“ë‹ë‹¤.
+ g: view reset options
+ c: 커밋을 복사 (cherry-pick)
+ C: 커밋을 범위로 복사 (cherry-pick)
+ ctrl+r: reset cherry-picked (copied) commits selection
+ enter: view selected item's files
+
+
+## 메인 íŚ¨ë„ (Merging)
+
+
+ e: 파일 편집
+ o: 파일 닫기
+ â—„: ěť´ě „ 충돌을 ě„ íť
+ â–ş: 다음 충돌을 ě„ íť
+ â–˛: ěť´ě „ hunk를 ě„ íť
+ â–Ľ: 다음 hunk를 ě„ íť
+ z: ë돌리기
+ M: git mergetool를 열기
+ space: pick hunk
+ b: pick all hunks
+ esc: 파일 목록으로 돌아가기
+
+
+## 메인 íŚ¨ë„ (Normal)
+
+
+ mouse wheel â–Ľ: ě•„ëžëˇś 스í¬ëˇ¤ (fn+up)
+ mouse wheel â–˛: 위로 스í¬ëˇ¤ (fn+down)
+
+
+## 메인 íŚ¨ë„ (Patch Building)
+
+
+ â—„: ěť´ě „ hunk를 ě„ íť
+ â–ş: 다음 hunk를 ě„ íť
+ v: 드ëžę·¸ ě„ íť ě „í™
+ V: 드ëžę·¸ ě„ íť ě „í™
+ a: toggle select hunk
+ ctrl+o: ě„ íťí•ś 텍스트를 í´ë¦˝ëł´ë“śě— 복사
+ o: 파일 닫기
+ e: 파일 편집
+ space: line(s)ěť„ 패ěąě— 추가/ě‚ě ś
+ esc: exit custom patch builder
+
+
+## 메인 íŚ¨ë„ (Staging)
+
+
+ â—„: ěť´ě „ hunk를 ě„ íť
+ â–ş: 다음 hunk를 ě„ íť
+ v: 드ëžę·¸ ě„ íť ě „í™
+ V: 드ëžę·¸ ě„ íť ě „í™
+ a: toggle select hunk
+ ctrl+o: ě„ íťí•ś 텍스트를 í´ë¦˝ëł´ë“śě— 복사
+ o: 파일 닫기
+ e: 파일 편집
+ esc: 파일 목록으로 돌아가기
+ tab: íŚ¨ë„ ě „í™
+ space: ě„ íťí•ś 행을 staged / unstaged
+ d: 변경을 ě‚ě ś (git reset)
+ E: edit hunk
+
+
+## 브랜ěą
+
+
+ ctrl+o: 브랜ěąëŞ…ěť„ í´ë¦˝ëł´ë“śě— 복사
+ i: git-flow ěµě… 보기
+ space: 체í¬ě•„ě›
+ n: ě ë¸Śëžśěą ěťě„±
+ o: í’€ 리í€ěŠ¤íŠ¸ ěťě„±
+ O: í’€ 리í€ěŠ¤íŠ¸ ěťě„± ěµě…
+ ctrl+y: í’€ 리í€ěŠ¤íŠ¸ URLěť„ í´ë¦˝ëł´ë“śě— 복사
+ c: 이름으로 체í¬ě•„ě›
+ F: ę°•ě ś 체í¬ě•„ě›
+ d: ë¸Śëžśěą ě‚ě ś
+ r: 체í¬ě•„ě›ëś 브랜ěąëĄĽ ěť´ 브랜ěąě— ë¦¬ë˛ ěť´ěŠ¤
+ M: í„재 브랜ěąě— 병합
+ f: fast-forward this branch from its upstream
+ g: view reset options
+ R: ë¸Śëžśěą ěť´ë¦„ 변경
+ u: set/unset upstream
+ enter: 커밋 보기
+
+
+## ěíś
+
+
+ e: ě„¤ě • 파일 ěě •
+ o: ě„¤ě • 파일 열기
+ u: 업데이트 확인
+ enter: ěµśę·Ľě— ě‚¬ěš©í•ś ě €ěžĄě†Śëˇś ě „í™
+ a: ëŞ¨ë“ ë¸Śëžśěą ëˇśę·¸ 표시
+
+
+## 서브모ë“
+
+
+ ctrl+o: ě„śë¸ŚëŞ¨ë“ ěť´ë¦„ěť„ í´ë¦˝ëł´ë“śě— 복사
+ enter: ě„śë¸ŚëŞ¨ë“ ě—´ę¸°
+ d: ě„śë¸ŚëŞ¨ë“ ě‚ě ś
+ u: ě„śë¸ŚëŞ¨ë“ ě—…ëŤ°ěť´íŠ¸
+ n: ě로운 ě„śë¸ŚëŞ¨ë“ ě¶”ę°€
+ e: 서브모ë“ěť URLěť„ ěě •
+ i: ě„śë¸ŚëŞ¨ë“ ě´ę¸°í™”
+ b: view bulk submodule options
+
+
+## ě›ę˛©
+
+
+ f: ě›ę˛©ěť„ 업데이트
+ n: ě로운 Remote 추가
+ d: Remote를 ě‚ě ś
+ e: Remote를 ěě •
+
+
+## ě›ę˛© 브랜ěą
+
+
+ space: 체í¬ě•„ě›
+ n: ě ë¸Śëžśěą ěťě„±
+ M: í„재 브랜ěąě— 병합
+ r: 체í¬ě•„ě›ëś 브랜ěąëĄĽ ěť´ 브랜ěąě— ë¦¬ë˛ ěť´ěŠ¤
+ d: ë¸Śëžśěą ě‚ě ś
+ u: set as upstream of checked-out branch
+ esc: ě›ę˛©ëŞ©ëˇťěśĽëˇś 돌아가기
+ g: view reset options
+ enter: 커밋 보기
+
+
+## 커밋
+
+
+ ctrl+o: 커밋 SHA를 í´ë¦˝ëł´ë“śě— 복사
+ ctrl+r: reset cherry-picked (copied) commits selection
+ b: bisect ěµě… 보기
+ s: squash down
+ f: fixup commit
+ r: 커밋메시지 변경
+ R: ě—디터ě—서 커밋메시지 ěě •
+ d: 커밋 ě‚ě ś
+ e: 커밋을 편집
+ p: pick commit (when mid-rebase)
+ F: create fixup commit for this commit
+ S: squash all 'fixup!' commits above selected commit (autosquash)
+ ctrl+j: 커밋을 1ę°ś ě•„ëžëˇś 이동
+ ctrl+k: 커밋을 1개 위로 이동
+ v: 커밋을 붙여넣기 (cherry-pick)
+ A: amend commit with staged changes
+ a: reset commit author
+ t: 커밋 ë돌리기
+ T: tag commit
+ ctrl+l: 로그 메뉴 열기
+ space: 커밋을 체í¬ě•„ě›
+ y: 커밋 attribute 복사
+ o: ë¸ŚëťĽěš°ě €ě—서 커밋 열기
+ n: 커밋ě—서 ě 브랜ěąëĄĽ ë§Śë“ë‹ë‹¤.
+ g: view reset options
+ c: 커밋을 복사 (cherry-pick)
+ C: 커밋을 범위로 복사 (cherry-pick)
+ enter: view selected item's files
+
+
+## 커밋 파일
+
+
+ ctrl+o: 커밋한 파일명을 í´ë¦˝ëł´ë“śě— 복사
+ c: checkout file
+ d: discard this commit's changes to this file
+ o: 파일 닫기
+ e: 파일 편집
+ space: toggle file included in patch
+ a: toggle all files included in patch
+ enter: enter file to add selected lines to the patch (or toggle directory collapsed)
+ `: 파일 트리뷰로 ě „í™
+
+
+## íśę·¸
+
+
+ space: 체í¬ě•„ě›
+ d: íśę·¸ ě‚ě ś
+ P: íśę·¸ëĄĽ push
+ n: íśę·¸ëĄĽ ěťě„±
+ g: view reset options
+ enter: 커밋 보기
+
+
+## 파일
+
+
+ ctrl+o: 파일명을 í´ë¦˝ëł´ë“śě— 복사
+ ctrl+w: 공백문ěžëĄĽ Diff ë·°ě—서 표시 여부 ě „í™
+ d: view 'discard changes' options
+ space: Staged ě „í™
+ ctrl+b: 파일을 í•„í„°í•기 (Staged/unstaged)
+ c: 커밋 변경내용
+ w: commit changes without pre-commit hook
+ A: ë§ě§€ë§› 커밋 ěě •
+ C: Git 편집기를 사용í•ě—¬ 변경 내용을 커밋합ë‹ë‹¤.
+ e: 파일 편집
+ o: 파일 닫기
+ i: ignore file
+ r: 파일 ěëˇśęł ěą¨
+ s: 변경사í•ěť„ Stash
+ S: Stash ěµě… 보기
+ a: ëŞ¨ë“ ëł€ę˛˝ěť„ Staged/unstaged으로 ě „í™
+ enter: stage individual hunks/lines for file, or collapse/expand for directory
+ g: view upstream reset options
+ D: view reset options
+ `: 파일 트리뷰로 ě „í™
+ M: git mergetool를 열기
+ f: fetch
+
diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md
index 6f05b66b3..15484be73 100644
--- a/docs/keybindings/Keybindings_nl.md
+++ b/docs/keybindings/Keybindings_nl.md
@@ -5,109 +5,92 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
## Globale Sneltoetsen
- ctrl+r: wissel naar een recente repo ()
- pgup: scroll naar beneden vanaf hoofdpaneel (fn+up)
- pgdown: scroll naar beneden vanaf hoofdpaneel (fn+down)
+ ctrl+r: wissel naar een recente repo
+ pgup: scroll naar beneden vanaf hoofdpaneel (fn+up/shift+k)
+ pgdown: scroll naar beneden vanaf hoofdpaneel (fn+down/shift+j)
m: bekijk merge/rebase opties
ctrl+p: bekijk aangepaste patch opties
- P: push
- p: pull
R: verversen
x: open menu
- z: ongedaan maken (via reflog) (experimenteel)
- ctrl+z: redo (via reflog) (experimenteel)
+: volgende scherm modus (normaal/half/groot)
_: vorige scherm modus
- :: voor aangepaste commando uit
ctrl+s: bekijk scoping opties
W: open diff menu
ctrl+e: open diff menu
@: open command log menu
}: Increase the size of the context shown around changes in the diff view
{: Decrease the size of the context shown around changes in the diff view
+ :: voer aangepaste commando uit
+ z: ongedaan maken (via reflog) (experimenteel)
+ ctrl+z: redo (via reflog) (experimenteel)
+ P: push
+ p: pull
## Lijstpaneel Navigatie
- .: volgende pagina
,: vorige pagina
+ .: volgende pagina
<: scroll naar boven
- >: scroll naar beneden
/: start met zoeken
+ >: scroll naar beneden
+ H: scroll left
+ L: scroll right
]: volgende tabblad
[: vorige tabblad
-## Branches Paneel (Branches Tabblad)
+## Bestanden
+ ctrl+o: kopieer de bestandsnaam naar het klembord
+ ctrl+w: Toggle whether or not whitespace changes are shown in the diff view
+ d: bekijk 'veranderingen ongedaan maken' opties
+ space: toggle staged
+ ctrl+b: Filter files (staged/unstaged)
+ c: commit veranderingen
+ w: commit veranderingen zonder pre-commit hook
+ A: wijzig laatste commit
+ C: commit veranderingen met de git editor
+ e: verander bestand
+ o: open bestand
+ i: ignore or exclude file
+ r: refresh bestanden
+ s: stash-bestanden
+ S: bekijk stash opties
+ a: toggle staged alle
+ enter: stage individuele hunks/lijnen
+ g: bekijk upstream reset opties
+ D: bekijk reset opties
+ `: toggle bestandsboom weergave
+ M: open external merge tool (git mergetool)
+ f: fetch
+
+
+## Branches
+
+
+ ctrl+o: kopieer branch name naar klembord
+ i: laat git-flow opties zien
space: uitchecken
- o: maak of laat een pull-request zien
+ n: nieuwe branch
+ o: maak een pull-request
O: bekijk opties voor pull-aanvraag
ctrl+y: kopieer de URL van het pull-verzoek naar het klembord
c: uitchecken bij naam
F: forceer checkout
- n: nieuwe branch
d: verwijder branch
r: rebase branch
M: merge in met huidige checked out branch
- i: laat git-flow opties zien
f: fast-forward deze branch vanaf zijn upstream
g: bekijk reset opties
R: hernoem branch
- ctrl+o: kopieer branch name naar klembord
+ u: set/unset upstream
enter: bekijk commits
-## Branches Paneel (Remote Branches (in Remotes tabblad))
-
-
- esc: Ga terug naar remotes lijst
- g: bekijk reset opties
- enter: bekijk commits
- space: uitchecken
- n: nieuwe branch
- M: merge in met huidige checked out branch
- d: verwijder branch
- r: rebase branch
- u: stel in als upstream van uitgecheckte branch
-
-
-## Branches Paneel (Remotes Tabblad)
-
-
- f: fetch remote
- n: voeg een nieuwe remote toe
- d: verwijder remote
- e: wijzig remote
-
-
-## Branches Paneel (Sub-commits)
-
-
- enter: bekijk gecommite bestanden
- space: checkout commit
- g: bekijk reset opties
- n: nieuwe branch
- c: kopieer commit (cherry-pick)
- C: kopieer commit reeks (cherry-pick)
- ctrl+r: reset cherry-picked (gekopieerde) commits selectie
- ctrl+o: kopieer commit SHA naar klembord
-
-
-## Branches Paneel (Tags Tabblad)
-
-
- space: uitchecken
- d: verwijder tag
- P: push tag
- n: creëer tag
- g: bekijk reset opties
- enter: bekijk commits
-
-
-## Commit bestanden Paneel
+## Commit bestanden
ctrl+o: kopieer de vastgelegde bestandsnaam naar het klembord
@@ -116,93 +99,174 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
o: open bestand
e: verander bestand
space: toggle bestand inbegrepen in patch
+ a: toggle all files included in patch
enter: enter bestand om geselecteerde regels toe te voegen aan de patch
`: toggle bestandsboom weergave
-## Commits Paneel (Commits)
+## Commits
- ctrl+l: open log menu
+ ctrl+o: kopieer commit SHA naar klembord
+ ctrl+r: reset cherry-picked (gekopieerde) commits selectie
+ b: view bisect options
s: squash beneden
+ f: Fixup commit
r: hernoem commit
R: hernoem commit met editor
- g: reset naar deze commit
- f: Fixup commit
+ d: verwijder commit
+ e: wijzig commit
+ p: kies commit (wanneer midden in rebase)
F: creëer fixup commit voor deze commit
S: squash bovenstaande commits
- d: verwijder commit
ctrl+j: verplaats commit 1 naar beneden
ctrl+k: verplaats commit 1 naar boven
- e: wijzig commit
- A: wijzig commit met staged veranderingen
- p: kies commit (wanneer midden in rebase)
- t: commit ongedaan maken
- c: kopieer commit (cherry-pick)
- ctrl+o: kopieer commit SHA naar klembord
- C: kopieer commit reeks (cherry-pick)
v: plak commits (cherry-pick)
- enter: bekijk gecommite bestanden
- space: checkout commit
- n: creëer nieuwe branch van commit
+ A: wijzig commit met staged veranderingen
+ a: reset commit author
+ t: commit ongedaan maken
T: tag commit
- ctrl+r: reset cherry-picked (gekopieerde) commits selectie
- ctrl+y: kopieer commit bericht naar klembord
+ ctrl+l: open log menu
+ space: checkout commit
+ y: copy commit attribute
o: open commit in browser
- b: view bisect options
+ n: creëer nieuwe branch van commit
+ g: bekijk reset opties
+ c: kopieer commit (cherry-pick)
+ C: kopieer commit reeks (cherry-pick)
+ enter: bekijk gecommite bestanden
-## Commits Paneel (Reflog Tabblad)
+## Mergen
- enter: bekijk gecommite bestanden
+ e: verander bestand
+ o: open bestand
+ â—„: selecteer voorgaand conflict
+ â–ş: selecteer volgende conflict
+ â–˛: selecteer bovenste hunk
+ â–Ľ: selecteer onderste hunk
+ z: ongedaan maken
+ M: open external merge tool (git mergetool)
+ space: kies hunk
+ b: kies bijde hunks
+ esc: ga terug naar het bestanden paneel
+
+
+## Normaal
+
+
+ mouse wheel â–Ľ: scroll omlaag (fn+up)
+ mouse wheel â–˛: scroll omhoog (fn+down)
+
+
+## Patch Bouwen
+
+
+ â—„: selecteer de vorige hunk
+ â–ş: selecteer de volgende hunk
+ v: toggle drag selecteer
+ V: toggle drag selecteer
+ a: toggle selecteer hunk
+ ctrl+o: copy the selected text to the clipboard
+ o: open bestand
+ e: verander bestand
+ space: voeg toe/verwijder lijn(en) in patch
+ esc: sluit lijn-bij-lijn modus
+
+
+## Reflog
+
+
+ ctrl+o: kopieer commit SHA naar klembord
space: checkout commit
+ y: copy commit attribute
+ o: open commit in browser
+ n: creëer nieuwe branch van commit
g: bekijk reset opties
c: kopieer commit (cherry-pick)
C: kopieer commit reeks (cherry-pick)
ctrl+r: reset cherry-picked (gekopieerde) commits selectie
- ctrl+o: kopieer commit SHA naar klembord
+ enter: bekijk commits
-## Extras Paneel
+## Remote Branches
- @: open command log menu
+ space: uitchecken
+ n: nieuwe branch
+ M: merge in met huidige checked out branch
+ r: rebase branch
+ d: verwijder branch
+ u: stel in als upstream van uitgecheckte branch
+ esc: ga terug naar remotes lijst
+ g: bekijk reset opties
+ enter: bekijk commits
-## Bestanden Paneel
+## Remotes
- ctrl+b: Commit dossiers filteren
+ f: fetch remote
+ n: voeg een nieuwe remote toe
+ d: verwijder remote
+ e: wijzig remote
-## Bestanden Paneel (Bestanden)
+## Staging
- c: Commit veranderingen
- w: commit veranderingen zonder pre-commit hook
- A: wijzig laatste commit
- C: commit veranderingen met de git editor
- space: toggle staged
- d: bekijk 'veranderingen ongedaan maken' opties
- e: verander bestand
+ â—„: selecteer de vorige hunk
+ â–ş: selecteer de volgende hunk
+ v: toggle drag selecteer
+ V: toggle drag selecteer
+ a: toggle selecteer hunk
+ ctrl+o: copy the selected text to the clipboard
o: open bestand
- i: voeg toe aan .gitignore
- r: refresh bestanden
- s: stash-bestanden
- S: bekijk stash opties
- a: toggle staged alle
- D: bekijk reset opties
- enter: stage individuele hunks/lijnen
- f: fetch
- ctrl+o: kopieer de bestandsnaam naar het klembord
- g: bekijk upstream reset opties
- `: toggle bestandsboom weergave
- M: open external merge tool (git mergetool)
- ctrl+w: Toggle whether or not whitespace changes are shown in the diff view
+ e: verander bestand
+ esc: ga terug naar het bestanden paneel
+ tab: ga naar een ander paneel
+ space: toggle lijnen staged / unstaged
+ d: verwijdert change (git reset)
+ E: edit hunk
-## Bestanden Paneel (Submodules)
+## Stash
+
+
+ space: toepassen
+ g: pop
+ d: laten vallen
+ n: nieuwe branch
+ enter: bekijk gecommite bestanden
+
+
+## Status
+
+
+ e: verander config bestand
+ o: open config bestand
+ u: check voor updates
+ enter: wissel naar een recente repo
+ a: alle logs van de branch laten zien
+
+
+## Sub-commits
+
+
+ ctrl+o: kopieer commit SHA naar klembord
+ space: checkout commit
+ y: copy commit attribute
+ o: open commit in browser
+ n: creëer nieuwe branch van commit
+ g: bekijk reset opties
+ c: kopieer commit (cherry-pick)
+ C: kopieer commit reeks (cherry-pick)
+ ctrl+r: reset cherry-picked (gekopieerde) commits selectie
+ enter: bekijk gecommite bestanden
+
+
+## Submodules
ctrl+o: kopieer submodule naam naar klembord
@@ -215,94 +279,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
b: bekijk bulk submodule opties
-## Hoofd Paneel (Mergen)
+## Tags
- H: scroll left
- L: scroll right
- esc: ga terug naar het bestanden paneel
- M: open external merge tool (git mergetool)
- space: kies hunk
- b: kies bijde hunks
- â—„: selecteer voorgaand conflict
- â–ş: selecteer volgende conflict
- â–˛: selecteer bovenste hunk
- â–Ľ: selecteer onderste hunk
- z: ongedaan maken
-
-
-## Hoofd Paneel (Normaal)
-
-
- Ĺ: scroll omlaag (fn+up)
- Ĺ‘: scroll omhoog (fn+down)
-
-
-## Hoofd Paneel (Patch Bouwen)
-
-
- esc: sluit lijn-bij-lijn modus
- o: open bestand
- â–˛: selecteer de vorige lijn
- â–Ľ: selecteer de volgende lijn
- â—„: selecteer de vorige hunk
- â–ş: selecteer de volgende hunk
- ctrl+o: copy the selected text to the clipboard
- space: voeg toe/verwijder lijn(en) in patch
- v: toggle drag selecteer
- V: toggle drag selecteer
- a: toggle selecteer hunk
- H: scroll left
- L: scroll right
-
-
-## Hoofd Paneel (Staging)
-
-
- esc: ga terug naar het bestanden paneel
- space: toggle lijnen staged / unstaged
- d: verwijdert change (git reset)
- tab: ga naar een ander paneel
- o: open bestand
- â–˛: selecteer de vorige lijn
- â–Ľ: selecteer de volgende lijn
- â—„: selecteer de vorige hunk
- â–ş: selecteer de volgende hunk
- ctrl+o: copy the selected text to the clipboard
- e: verander bestand
- o: open bestand
- v: toggle drag selecteer
- V: toggle drag selecteer
- a: toggle selecteer hunk
- H: scroll left
- L: scroll right
- c: Commit veranderingen
- w: commit veranderingen zonder pre-commit hook
- C: commit veranderingen met de git editor
-
-
-## Menu Paneel
-
-
- esc: sluit menu
-
-
-## Stash Paneel
-
-
- enter: bekijk bestanden van stash entry
- space: toepassen
- g: pop
- d: laten vallen
- n: nieuwe branch
-
-
-## Status Paneel
-
-
- e: verander config bestand
- o: open config bestand
- u: check voor updates
- enter: wissel naar een recente repo
- a: alle logs van de branch laten zien
+ space: uitchecken
+ d: verwijder tag
+ P: push tag
+ n: creëer tag
+ g: bekijk reset opties
+ enter: bekijk commits
diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md
index e97e1dd88..9cfc64294 100644
--- a/docs/keybindings/Keybindings_pl.md
+++ b/docs/keybindings/Keybindings_pl.md
@@ -5,109 +5,140 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
## Globalne
- ctrl+r: switch to a recent repo ()
- pgup: scroll up main panel (fn+up)
- pgdown: scroll down main panel (fn+down)
+ ctrl+r: switch to a recent repo
+ pgup: scroll up main panel (fn+up/shift+k)
+ pgdown: scroll down main panel (fn+down/shift+j)
m: widok scalenia/opcje zmiany bazy
ctrl+p: view custom patch options
- P: push
- p: pull
R: odśwież
x: open menu
- z: undo (via reflog) (experimental)
- ctrl+z: redo (via reflog) (experimental)
+: next screen mode (normal/half/fullscreen)
_: prev screen mode
- :: wykonaj własną komendę
ctrl+s: view filter-by-path options
W: open diff menu
ctrl+e: open diff menu
@: open command log menu
}: Increase the size of the context shown around changes in the diff view
{: Decrease the size of the context shown around changes in the diff view
+ :: wykonaj własną komendę
+ z: undo (via reflog) (experimental)
+ ctrl+z: redo (via reflog) (experimental)
+ P: push
+ p: pull
## List Panel Navigation
- .: next page
,: previous page
+ .: next page
<: scroll to top
- >: scroll to bottom
/: start search
+ >: scroll to bottom
+ H: scroll left
+ L: scroll right
]: next tab
[: previous tab
-## Gałęzie Panel (Branches Tab)
+## Commity
+ ctrl+o: copy commit SHA to clipboard
+ ctrl+r: reset cherry-picked (copied) commits selection
+ b: view bisect options
+ s: ściśnij
+ f: napraw commit
+ r: zmień nazwę commita
+ R: zmień nazwę commita w edytorze
+ d: usuń commit
+ e: edytuj commit
+ p: wybierz commit (podczas zmiany bazy)
+ F: utwĂłrz commit naprawczy dla tego commita
+ S: spłaszcz wszystkie commity naprawcze powyżej zaznaczonych commitów (autosquash)
+ ctrl+j: przenieś commit 1 w dół
+ ctrl+k: przenieĹ› commit 1 w gĂłrÄ™
+ v: wklej commity (przebieranie)
+ A: popraw commit zmianami z poczekalni
+ a: reset commit author
+ t: odwróć commit
+ T: tag commit
+ ctrl+l: open log menu
+ space: checkout commit
+ y: copy commit attribute
+ o: open commit in browser
+ n: create new branch off of commit
+ g: wyświetl opcje resetu
+ c: kopiuj commit (przebieranie)
+ C: kopiuj zakres commitĂłw (przebieranie)
+ enter: przeglÄ…daj pliki commita
+
+
+## Local Branches
+
+
+ ctrl+o: copy branch name to clipboard
+ i: show git-flow options
space: przełącz
- o: maak of laat een pull-request zien
- O: utwĂłrz opcje ĹĽÄ…dania
+ n: nowa gałąź
+ o: utwĂłrz ĹĽÄ…danie pobrania
+ O: utwórz opcje żądania ściągnięcia
ctrl+y: skopiuj adres URL ĹĽÄ…dania pobrania do schowka
c: przełącz używając nazwy
F: wymuś przełączenie
- n: nowa gałąź
d: usuń gałąź
r: zmiana bazy gałęzi
M: scal do obecnej gałęzi
- i: show git-flow options
f: fast-forward this branch from its upstream
g: wyświetl opcje resetu
R: rename branch
- ctrl+o: copy branch name to clipboard
+ u: set/unset upstream
enter: view commits
-## Gałęzie Panel (Remote Branches (in Remotes tab))
+## Main Panel (Patch Building)
- esc: wróć do listy repozytoriów zdalnych
- g: wyświetl opcje resetu
- enter: view commits
- space: przełącz
- n: nowa gałąź
- M: scal do obecnej gałęzi
- d: usuń gałąź
- r: zmiana bazy gałęzi
- u: set as upstream of checked-out branch
+ ◄: poprzedni kawałek
+ ►: następny kawałek
+ v: toggle drag select
+ V: toggle drag select
+ a: toggle select hunk
+ ctrl+o: copy the selected text to the clipboard
+ o: otwĂłrz plik
+ e: edytuj plik
+ space: add/remove line(s) to patch
+ esc: wyście z trybu "linia po linii"
-## Gałęzie Panel (Remotes Tab)
+## Pliki
- f: fetch remote
- n: add new remote
- d: remove remote
- e: edit remote
+ ctrl+o: copy the file name to the clipboard
+ ctrl+w: Toggle whether or not whitespace changes are shown in the diff view
+ d: pokaĹĽ opcje porzucania zmian
+ space: przełącz stan poczekalni
+ ctrl+b: Filter files (staged/unstaged)
+ c: ZatwierdĹş zmiany
+ w: zatwierdĹş zmiany bez skryptu pre-commit
+ A: Zmień ostatni commit
+ C: ZatwierdĹş zmiany uĹĽywajÄ…c edytora
+ e: edytuj plik
+ o: otwĂłrz plik
+ i: ignore or exclude file
+ r: odśwież pliki
+ s: przechowaj zmiany
+ S: wyświetl opcje schowka
+ a: przełącz stan poczekalni wszystkich
+ enter: zatwierdĹş pojedyncze linie
+ g: view upstream reset options
+ D: wyświetl opcje resetu
+ `: toggle file tree view
+ M: open external merge tool (git mergetool)
+ f: pobierz
-## Gałęzie Panel (Sub-commits)
-
-
- enter: przeglÄ…daj pliki commita
- space: checkout commit
- g: wyświetl opcje resetu
- n: nowa gałąź
- c: kopiuj commit (przebieranie)
- C: kopiuj zakres commitĂłw (przebieranie)
- ctrl+r: reset cherry-picked (copied) commits selection
- ctrl+o: copy commit SHA to clipboard
-
-
-## Gałęzie Panel (Tags Tab)
-
-
- space: przełącz
- d: delete tag
- P: push tag
- n: create tag
- g: wyświetl opcje resetu
- enter: view commits
-
-
-## Pliki commita Panel
+## Pliki commita
ctrl+o: copy the committed file name to the clipboard
@@ -116,93 +147,119 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
o: otwĂłrz plik
e: edytuj plik
space: toggle file included in patch
+ a: toggle all files included in patch
enter: enter file to add selected lines to the patch (or toggle directory collapsed)
`: toggle file tree view
-## Commity Panel (Commity)
+## Poczekalnia
- ctrl+l: open log menu
- s: ściśnij
- r: zmień nazwę commita
- R: zmień nazwę commita w edytorze
- g: zresetuj do tego commita
- f: napraw commit
- F: utwĂłrz commit naprawczy dla tego commita
- S: spłaszcz wszystkie commity naprawcze powyżej zaznaczonych commitów (autosquash)
- d: usuń commit
- ctrl+j: przenieś commit 1 w dół
- ctrl+k: przenieĹ› commit 1 w gĂłrÄ™
- e: edytuj commit
- A: popraw commit zmianami z poczekalni
- p: wybierz commit (podczas zmiany bazy)
- t: odwróć commit
- c: kopiuj commit (przebieranie)
- ctrl+o: copy commit SHA to clipboard
- C: kopiuj zakres commitĂłw (przebieranie)
- v: wklej commity (przebieranie)
- enter: przeglÄ…daj pliki commita
- space: checkout commit
- n: create new branch off of commit
- T: tag commit
- ctrl+r: reset cherry-picked (copied) commits selection
- ctrl+y: copy commit message to clipboard
- o: open commit in browser
- b: view bisect options
+ ◄: poprzedni kawałek
+ ►: następny kawałek
+ v: toggle drag select
+ V: toggle drag select
+ a: toggle select hunk
+ ctrl+o: copy the selected text to the clipboard
+ o: otwĂłrz plik
+ e: edytuj plik
+ esc: wróć do panelu plików
+ tab: switch to other panel (staged/unstaged changes)
+ space: toggle line staged / unstaged
+ d: delete change (git reset)
+ E: edit hunk
-## Commity Panel (Reflog Tab)
+## Reflog
- enter: przeglÄ…daj pliki commita
+ ctrl+o: copy commit SHA to clipboard
space: checkout commit
+ y: copy commit attribute
+ o: open commit in browser
+ n: create new branch off of commit
g: wyświetl opcje resetu
c: kopiuj commit (przebieranie)
C: kopiuj zakres commitĂłw (przebieranie)
ctrl+r: reset cherry-picked (copied) commits selection
- ctrl+o: copy commit SHA to clipboard
+ enter: view commits
-## Extras Panel
+## Remote Branches
- @: open command log menu
+ space: przełącz
+ n: nowa gałąź
+ M: scal do obecnej gałęzi
+ r: zmiana bazy gałęzi
+ d: usuń gałąź
+ u: set as upstream of checked-out branch
+ esc: wróć do listy repozytoriów zdalnych
+ g: wyświetl opcje resetu
+ enter: view commits
-## Pliki Panel
+## Remotes
- ctrl+b: Filtrowanie commitĂłw
+ f: fetch remote
+ n: add new remote
+ d: remove remote
+ e: edit remote
-## Pliki Panel (Pliki)
+## Scalanie
- c: ZatwierdĹş zmiany
- w: zatwierdĹş zmiany bez skryptu pre-commit
- A: Zmień ostatni commit
- C: ZatwierdĹş zmiany uĹĽywajÄ…c edytora
- space: przełącz stan poczekalni
- d: pokaĹĽ opcje porzucania zmian
e: edytuj plik
o: otwĂłrz plik
- i: dodaj do .gitignore
- r: odśwież pliki
- s: przechowaj zmiany
- S: wyświetl opcje schowka
- a: przełącz stan poczekalni wszystkich
- D: wyświetl opcje resetu
- enter: zatwierdĹş pojedyncze linie
- f: pobierz
- ctrl+o: copy the file name to the clipboard
- g: view upstream reset options
- `: toggle file tree view
+ â—„: poprzedni konflikt
+ ►: następny konflikt
+ ▲: wybierz poprzedni kawałek
+ ▼: wybierz następny kawałek
+ z: cofnij
M: open external merge tool (git mergetool)
- ctrl+w: Toggle whether or not whitespace changes are shown in the diff view
+ space: wybierz kawałek
+ b: wybierz wszystkie kawałki
+ esc: wróć do panelu plików
-## Pliki Panel (Submodules)
+## Schowek
+
+
+ space: zastosuj
+ g: wyciÄ…gnij
+ d: porzuć
+ n: nowa gałąź
+ enter: przeglÄ…daj pliki commita
+
+
+## Status
+
+
+ e: edytuj konfiguracjÄ™
+ o: otwĂłrz konfiguracjÄ™
+ u: sprawdĹş aktualizacje
+ enter: switch to a recent repo
+ a: pokaż wszystkie logi gałęzi
+
+
+## Sub-commits
+
+
+ ctrl+o: copy commit SHA to clipboard
+ space: checkout commit
+ y: copy commit attribute
+ o: open commit in browser
+ n: create new branch off of commit
+ g: wyświetl opcje resetu
+ c: kopiuj commit (przebieranie)
+ C: kopiuj zakres commitĂłw (przebieranie)
+ ctrl+r: reset cherry-picked (copied) commits selection
+ enter: przeglÄ…daj pliki commita
+
+
+## Submodules
ctrl+o: copy submodule name to clipboard
@@ -215,94 +272,20 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
b: view bulk submodule options
-## Główne Panel (Scalanie)
+## Tags
- H: scroll left
- L: scroll right
- esc: wróć do panelu plików
- M: open external merge tool (git mergetool)
- space: wybierz kawałek
- b: wybierz wszystkie kawałki
- â—„: poprzedni konflikt
- ►: następny konflikt
- ▲: wybierz poprzedni kawałek
- ▼: wybierz następny kawałek
- z: cofnij
+ space: przełącz
+ d: delete tag
+ P: push tag
+ n: create tag
+ g: wyświetl opcje resetu
+ enter: view commits
-## Główne Panel (Zwykłe)
+## Zwykłe
- Ĺ: przewiĹ„ w dół (fn+up)
- ő: przewiń w górę (fn+down)
-
-
-## Główne Panel (Patch Building)
-
-
- esc: wyście z trybu "linia po linii"
- o: otwĂłrz plik
- â–˛: poprzednia linia
- ▼: następna linia
- ◄: poprzedni kawałek
- ►: następny kawałek
- ctrl+o: copy the selected text to the clipboard
- space: add/remove line(s) to patch
- v: toggle drag select
- V: toggle drag select
- a: toggle select hunk
- H: scroll left
- L: scroll right
-
-
-## Główne Panel (Poczekalnia)
-
-
- esc: wróć do panelu plików
- space: toggle line staged / unstaged
- d: delete change (git reset)
- tab: switch to other panel
- o: otwĂłrz plik
- â–˛: poprzednia linia
- ▼: następna linia
- ◄: poprzedni kawałek
- ►: następny kawałek
- ctrl+o: copy the selected text to the clipboard
- e: edytuj plik
- o: otwĂłrz plik
- v: toggle drag select
- V: toggle drag select
- a: toggle select hunk
- H: scroll left
- L: scroll right
- c: ZatwierdĹş zmiany
- w: zatwierdĹş zmiany bez skryptu pre-commit
- C: ZatwierdĹş zmiany uĹĽywajÄ…c edytora
-
-
-## Menu Panel
-
-
- esc: close menu
-
-
-## Schowek Panel
-
-
- enter: view stash entry's files
- space: zastosuj
- g: wyciÄ…gnij
- d: porzuć
- n: nowa gałąź
-
-
-## Status Panel
-
-
- e: edytuj konfiguracjÄ™
- o: otwĂłrz konfiguracjÄ™
- u: sprawdĹş aktualizacje
- enter: switch to a recent repo
- a: pokaż wszystkie logi gałęzi
+ mouse wheel ▼: przewiń w dół (fn+up)
+ mouse wheel ▲: przewiń w górę (fn+down)
diff --git a/docs/keybindings/Keybindings_zh.md b/docs/keybindings/Keybindings_zh.md
index 6c8a35c5b..7ffaf2292 100644
--- a/docs/keybindings/Keybindings_zh.md
+++ b/docs/keybindings/Keybindings_zh.md
@@ -5,204 +5,95 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
## 全局键绑定
- ctrl+r: ĺ‡ćŤ˘ĺ°ćś€čż‘的仓库 ()
- pgup: ĺ‘上滚动主面板 (fn+up)
- pgdown: ĺ‘下滚动主面板 (fn+down)
+ ctrl+r: ĺ‡ćŤ˘ĺ°ćś€čż‘的仓库
+ pgup: ĺ‘上滚动主面板 (fn+up/shift+k)
+ pgdown: ĺ‘下滚动主面板 (fn+down/shift+j)
m: 查看 ĺĺą¶/ĺŹĺźş é€‰éˇą
ctrl+p: 查看自定义补ä¸é€‰éˇą
- P: 推é€
- p: 拉取
R: ĺ·ć–°
x: 打开菜单
- z: ďĽé€ščż‡ reflog)撤销「实验功č˝ă€Ť
- ctrl+z: ďĽé€ščż‡ reflog)重ĺšă€Śĺ®žéŞŚĺŠźč˝ă€Ť
+: 下一屏模式ďĽćŁĺ¸¸/半屏/全屏)
_: 上一屏模式
- :: 执行自定义命令
ctrl+s: 查看按路径过滤选项
W: 打开 diff 菜单
ctrl+e: 打开 diff 菜单
@: 打开命令日志菜单
- }: Increase the size of the context shown around changes in the diff view
- {: Decrease the size of the context shown around changes in the diff view
+ }: 扩大差异视图ä¸ćľç¤şçš„上下文čŚĺ›´
+ {: 缩小差异视图ä¸ćľç¤şçš„上下文čŚĺ›´
+ :: 执行自定义命令
+ z: ďĽé€ščż‡ reflog)撤销「实验功č˝ă€Ť
+ ctrl+z: ďĽé€ščż‡ reflog)重ĺšă€Śĺ®žéŞŚĺŠźč˝ă€Ť
+ P: 推é€
+ p: 拉取
## ĺ—表面板导čŞ
- .: 下一页
,: 上一页
+ .: 下一页
<: 滚动ĺ°éˇ¶é¨
- >: 滚动ĺ°ĺş•é¨
/: 开始ćśç´˘
+ >: 滚动ĺ°ĺş•é¨
+ H: ĺ‘左滚动
+ L: ĺ‘右滚动
]: ä¸‹ä¸€ä¸Şć ‡çľ
[: ä¸Šä¸€ä¸Şć ‡çľ
-## ĺ†ć”Ż éť˘ćťż (ĺ†ć”Żć ‡çľ)
+## Reflog 页面
+ ctrl+o: ĺ°†ćŹäş¤çš„ SHA 复ĺ¶ĺ°ĺ‰Şč´´ćťż
+ space: 检出ćŹäş¤
+ y: copy commit attribute
+ o: 在浏č§ĺ™¨ä¸ć‰“开ćŹäş¤
+ n: 从ćŹäş¤ĺ›ĺ»şć–°ĺ†ć”Ż
+ g: 查看重置选项
+ c: 复ĺ¶ćŹäş¤ďĽć‹Łé€‰ďĽ‰
+ C: 复ĺ¶ćŹäş¤čŚĺ›´ďĽć‹Łé€‰ďĽ‰
+ ctrl+r: 重置已拣选ďĽĺ¤Ťĺ¶ďĽ‰çš„ćŹäş¤
+ enter: 查看ćŹäş¤
+
+
+## ĺ†ć”Żéˇµéť˘
+
+
+ ctrl+o: ĺ°†ĺ†ć”ŻĺŤç§°ĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż
+ i: ćľç¤ş git-flow 选项
space: 检出
+ n: ć–°ĺ†ć”Ż
o: ĺ›ĺ»şćŠ“ĺŹ–čŻ·ć±‚
O: ĺ›ĺ»şćŠ“ĺŹ–čŻ·ć±‚
ctrl+y: 将抓取请求 URL 复ĺ¶ĺ°ĺ‰Şč´´ćťż
c: 按ĺŤç§°ćŁ€ĺ‡ş
F: 强ĺ¶ćŁ€ĺ‡ş
- n: ć–°ĺ†ć”Ż
d: ĺ 除ĺ†ć”Ż
r: 将已检出的ĺ†ć”ŻĺŹĺźşĺ°čŻĄĺ†ć”Ż
M: ĺĺą¶ĺ°ĺ˝“前检出的ĺ†ć”Ż
- i: ćľç¤ş git-flow 选项
f: 从上游快进ć¤ĺ†ć”Ż
g: 查看重置选项
R: 重命ĺŤĺ†ć”Ż
- ctrl+o: ĺ°†ĺ†ć”ŻĺŤç§°ĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż
+ u: set/unset upstream
enter: 查看ćŹäş¤
-## ĺ†ć”Ż éť˘ćťż (远程ĺ†ć”ŻďĽĺś¨čżśç¨‹éˇµéť˘ä¸ďĽ‰)
+## ĺćŹäş¤
- esc: 返回远程仓库ĺ—表
- g: 查看重置选项
- enter: 查看ćŹäş¤
- space: 检出
- n: ć–°ĺ†ć”Ż
- M: ĺĺą¶ĺ°ĺ˝“前检出的ĺ†ć”Ż
- d: ĺ 除ĺ†ć”Ż
- r: 将已检出的ĺ†ć”ŻĺŹĺźşĺ°čŻĄĺ†ć”Ż
- u: 设置为检出ĺ†ć”Żçš„上游
-
-
-## ĺ†ć”Ż éť˘ćťż (远程页面)
-
-
- f: 抓取远程仓库
- n: ć·»ĺŠ ć–°çš„čżśç¨‹ä»“ĺş“
- d: ĺ 除远程
- e: 编辑远程仓库
-
-
-## ĺ†ć”Ż éť˘ćťż (ĺćŹäş¤)
-
-
- enter: 查看ćŹäş¤çš„文件
- space: 检出ćŹäş¤
- g: 查看重置选项
- n: ć–°ĺ†ć”Ż
- c: 复ĺ¶ćŹäş¤ďĽć‹Łé€‰ďĽ‰
- C: 复ĺ¶ćŹäş¤čŚĺ›´ďĽć‹Łé€‰ďĽ‰
- ctrl+r: 重置已拣选ďĽĺ¤Ťĺ¶ďĽ‰çš„ćŹäş¤
ctrl+o: ĺ°†ćŹäş¤çš„ SHA 复ĺ¶ĺ°ĺ‰Şč´´ćťż
-
-
-## ĺ†ć”Ż éť˘ćťż (ć ‡çľéˇµéť˘)
-
-
- space: 检出
- d: ĺ 除㠇çľ
- P: 推é€ć ‡çľ
- n: ĺ›ĺ»şć ‡çľ
- g: 查看重置选项
- enter: 查看ćŹäş¤
-
-
-## ćŹäş¤ć–‡ä»¶ 面板
-
-
- ctrl+o: ĺ°†ćŹäş¤çš„文件ĺŤĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż
- c: 检出文件
- d: 放ĺĽĺŻąć¤ć–‡ä»¶çš„ćŹäş¤ć›´ć”ą
- o: 打开文件
- e: 编辑文件
- space: 补ä¸ä¸ĺŚ…ĺ«çš„ĺ‡ćŤ˘ć–‡ä»¶
- enter: čľ“ĺ…Ąć–‡ä»¶ä»Ąĺ°†ć‰€é€‰čˇŚć·»ĺŠ ĺ°čˇĄä¸ä¸ďĽć–ĺ‡ćŤ˘ç›®ĺ˝•ćŠĺŹ ďĽ‰
- `: ĺ‡ćŤ˘ć–‡ä»¶ć ‘视图
-
-
-## ćŹäş¤ 面板 (ćŹäş¤)
-
-
- ctrl+l: open log menu
- s: ĺ‘下压缩
- r: 改写ćŹäş¤
- R: 使用编辑器重命ĺŤćŹäş¤
- g: 重置为ć¤ćŹäş¤
- f: äż®ćŁćŹäş¤ďĽfixup)
- F: 为ć¤ćŹäş¤ĺ›ĺ»şäż®ćŁ
- S: 压缩在所选ćŹäş¤äą‹ä¸Šçš„所有“fixup!”ćŹäş¤ďĽč‡ŞĺŠ¨ĺŽ‹çĽ©ďĽ‰
- d: ĺ 除ćŹäş¤
- ctrl+j: 下移ćŹäş¤
- ctrl+k: 上移ćŹäş¤
- e: 编辑ćŹäş¤
- A: 用已暂ĺ的更改来修补ćŹäş¤
- p: 选择ćŹäş¤ďĽĺŹĺźşčż‡ç¨‹ä¸ďĽ‰
- t: čżĺŽźćŹäş¤
- c: 复ĺ¶ćŹäş¤ďĽć‹Łé€‰ďĽ‰
- ctrl+o: ĺ°†ćŹäş¤çš„ SHA 复ĺ¶ĺ°ĺ‰Şč´´ćťż
- C: 复ĺ¶ćŹäş¤čŚĺ›´ďĽć‹Łé€‰ďĽ‰
- v: ç˛č´´ćŹäş¤ďĽć‹Łé€‰ďĽ‰
- enter: 查看ćŹäş¤çš„文件
space: 检出ćŹäş¤
+ y: copy commit attribute
+ o: 在浏č§ĺ™¨ä¸ć‰“开ćŹäş¤
n: 从ćŹäş¤ĺ›ĺ»şć–°ĺ†ć”Ż
- T: ć ‡çľćŹäş¤
- ctrl+r: 重置已拣选ďĽĺ¤Ťĺ¶ďĽ‰çš„ćŹäş¤
- ctrl+y: ĺ°†ćŹäş¤ć¶ćŻĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż
- o: open commit in browser
- b: view bisect options
-
-
-## ćŹäş¤ 面板 (Reflog)
-
-
- enter: 查看ćŹäş¤çš„文件
- space: 检出ćŹäş¤
g: 查看重置选项
c: 复ĺ¶ćŹäş¤ďĽć‹Łé€‰ďĽ‰
C: 复ĺ¶ćŹäş¤čŚĺ›´ďĽć‹Łé€‰ďĽ‰
ctrl+r: 重置已拣选ďĽĺ¤Ťĺ¶ďĽ‰çš„ćŹäş¤
- ctrl+o: ĺ°†ćŹäş¤çš„ SHA 复ĺ¶ĺ°ĺ‰Şč´´ćťż
+ enter: 查看ćŹäş¤çš„文件
-## Extras 面板
-
-
- @: 打开命令日志菜单
-
-
-## 文件 面板
-
-
- ctrl+b: 过滤ćŹäş¤ć–‡ä»¶
-
-
-## 文件 面板 (文件)
-
-
- c: ćŹäş¤ć›´ć”ą
- w: ćŹäş¤ć›´ć”ąč€Ść— 需预ĺ…ćŹäş¤é’©ĺ
- A: 修补最ĺŽä¸€ć¬ˇćŹäş¤
- C: ćŹäş¤ć›´ć”ąďĽä˝żç”¨çĽ–辑器编辑ćŹäş¤äżˇćŻďĽ‰
- space: ĺ‡ćŤ˘ćš‚ĺ状ć€
- d: 查看'放ĺĽć›´ć”ąâ€é€‰éˇą
- e: 编辑文件
- o: 打开文件
- i: ć·»ĺŠ ĺ° .gitignore
- r: ĺ·ć–°ć–‡ä»¶
- s: ĺ°†ć‰€ćś‰ć›´ć”ąĺŠ ĺ…Ąč´®č—Ź
- S: 查看éšč—Źé€‰éˇą
- a: ĺ‡ćŤ˘ć‰€ćś‰ć–‡ä»¶çš„ćš‚ĺ状ć€
- D: 查看重置选项
- enter: ćš‚ĺ单个 ĺť—/行 用于文件, ć– ćŠĺŹ /展开 目录
- f: 抓取
- ctrl+o: 将文件ĺŤĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż
- g: 查看上游重置选项
- `: ĺ‡ćŤ˘ć–‡ä»¶ć ‘视图
- M: 打开ĺĺą¶ĺ·Ąĺ…·
- ctrl+w: ĺ‡ćŤ˘ćŻĺ¦ĺś¨ĺ·®ĺĽ‚视图ä¸ćľç¤şç©şç™˝ć›´ć”ą
-
-
-## 文件 面板 (ĺ模块)
+## ĺ模块
ctrl+o: ĺ°†ĺ模块ĺŤç§°ĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż
@@ -215,89 +106,148 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
b: 查看批量ĺ模块选项
-## ä¸»č¦ éť˘ćťż (ĺĺą¶ä¸)
+## ćŹäş¤
- H: scroll left
- L: scroll right
- esc: 返回文件面板
- M: 打开ĺĺą¶ĺ·Ąĺ…·
- space: 选ä¸ĺŚşĺť—
- b: 选ä¸ć‰€ćś‰ĺŚşĺť—
+ ctrl+o: ĺ°†ćŹäş¤çš„ SHA 复ĺ¶ĺ°ĺ‰Şč´´ćťż
+ ctrl+r: 重置已拣选ďĽĺ¤Ťĺ¶ďĽ‰çš„ćŹäş¤
+ b: 查看二ĺ†ćźĄć‰ľé€‰éˇą
+ s: ĺ‘下压缩
+ f: äż®ćŁćŹäş¤ďĽfixup)
+ r: 改写ćŹäş¤
+ R: 使用编辑器重命ĺŤćŹäş¤
+ d: ĺ 除ćŹäş¤
+ e: 编辑ćŹäş¤
+ p: 选择ćŹäş¤ďĽĺŹĺźşčż‡ç¨‹ä¸ďĽ‰
+ F: 为ć¤ćŹäş¤ĺ›ĺ»şäż®ćŁ
+ S: 压缩在所选ćŹäş¤äą‹ä¸Šçš„所有“fixup!”ćŹäş¤ďĽč‡ŞĺŠ¨ĺŽ‹çĽ©ďĽ‰
+ ctrl+j: 下移ćŹäş¤
+ ctrl+k: 上移ćŹäş¤
+ v: ç˛č´´ćŹäş¤ďĽć‹Łé€‰ďĽ‰
+ A: 用已暂ĺ的更改来修补ćŹäş¤
+ a: reset commit author
+ t: čżĺŽźćŹäş¤
+ T: ć ‡çľćŹäş¤
+ ctrl+l: 打开日志菜单
+ space: 检出ćŹäş¤
+ y: copy commit attribute
+ o: 在浏č§ĺ™¨ä¸ć‰“开ćŹäş¤
+ n: 从ćŹäş¤ĺ›ĺ»şć–°ĺ†ć”Ż
+ g: 查看重置选项
+ c: 复ĺ¶ćŹäş¤ďĽć‹Łé€‰ďĽ‰
+ C: 复ĺ¶ćŹäş¤čŚĺ›´ďĽć‹Łé€‰ďĽ‰
+ enter: 查看ćŹäş¤çš„文件
+
+
+## ćŹäş¤ć–‡ä»¶
+
+
+ ctrl+o: ĺ°†ćŹäş¤çš„文件ĺŤĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż
+ c: 检出文件
+ d: 放ĺĽĺŻąć¤ć–‡ä»¶çš„ćŹäş¤ć›´ć”ą
+ o: 打开文件
+ e: 编辑文件
+ space: 补ä¸ä¸ĺŚ…ĺ«çš„ĺ‡ćŤ˘ć–‡ä»¶
+ a: toggle all files included in patch
+ enter: čľ“ĺ…Ąć–‡ä»¶ä»Ąĺ°†ć‰€é€‰čˇŚć·»ĺŠ ĺ°čˇĄä¸ä¸ďĽć–ĺ‡ćŤ˘ç›®ĺ˝•ćŠĺŹ ďĽ‰
+ `: ĺ‡ćŤ˘ć–‡ä»¶ć ‘视图
+
+
+## 文件
+
+
+ ctrl+o: 将文件ĺŤĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż
+ ctrl+w: ĺ‡ćŤ˘ćŻĺ¦ĺś¨ĺ·®ĺĽ‚视图ä¸ćľç¤şç©şç™˝ĺ—符差异
+ d: 查看'放ĺĽć›´ć”ą'选项
+ space: ĺ‡ćŤ˘ćš‚ĺ状ć€
+ ctrl+b: Filter files (staged/unstaged)
+ c: ćŹäş¤ć›´ć”ą
+ w: ćŹäş¤ć›´ć”ąč€Ść— 需预ĺ…ćŹäş¤é’©ĺ
+ A: 修补最ĺŽä¸€ć¬ˇćŹäş¤
+ C: ćŹäş¤ć›´ć”ąďĽä˝żç”¨çĽ–辑器编辑ćŹäş¤äżˇćŻďĽ‰
+ e: 编辑文件
+ o: 打开文件
+ i: 忽略文件
+ r: ĺ·ć–°ć–‡ä»¶
+ s: ĺ°†ć‰€ćś‰ć›´ć”ąĺŠ ĺ…Ąč´®č—Ź
+ S: 查看贮藏选项
+ a: ĺ‡ćŤ˘ć‰€ćś‰ć–‡ä»¶çš„ćš‚ĺ状ć€
+ enter: ćš‚ĺ单个 ĺť—/行 用于文件, ć– ćŠĺŹ /展开 目录
+ g: 查看上游重置选项
+ D: 查看重置选项
+ `: ĺ‡ćŤ˘ć–‡ä»¶ć ‘视图
+ M: 打开外é¨ĺĺą¶ĺ·Ąĺ…· (git mergetool)
+ f: 抓取
+
+
+## 构建补ä¸ä¸
+
+
+ ◄: 选择上一个区块
+ ►: 选择下一个区块
+ v: ĺ‡ćŤ˘ć‹–动选择
+ V: ĺ‡ćŤ˘ć‹–动选择
+ a: ĺ‡ćŤ˘é€‰ć‹©ĺŚşĺť—
+ ctrl+o: 将选ä¸ć–‡ćś¬ĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż
+ o: 打开文件
+ e: 编辑文件
+ space: ć·»ĺŠ /移除 行ĺ°čˇĄä¸
+ esc: 退出é€čˇŚć¨ˇĺĽŹ
+
+
+## ć ‡çľéˇµéť˘
+
+
+ space: 检出
+ d: ĺ 除㠇çľ
+ P: 推é€ć ‡çľ
+ n: ĺ›ĺ»şć ‡çľ
+ g: 查看重置选项
+ enter: 查看ćŹäş¤
+
+
+## ćŁĺś¨ĺĺą¶
+
+
+ e: 编辑文件
+ o: 打开文件
â—„: 选择上一个冲çŞ
â–ş: 选择下一个冲çŞ
â–˛: 选择顶é¨ĺť—
â–Ľ: 选择底é¨ĺť—
z: 撤销
+ M: 打开外é¨ĺĺą¶ĺ·Ąĺ…· (git mergetool)
+ space: 选ä¸ĺŚşĺť—
+ b: 选ä¸ć‰€ćś‰ĺŚşĺť—
+ esc: 返回文件面板
-## ä¸»č¦ éť˘ćťż (ćŁĺ¸¸)
+## ćŁĺś¨ćš‚ĺ
- Ĺ: ĺ‘下滚动 (fn+up)
- Ĺ‘: ĺ‘上滚动 (fn+down)
-
-
-## ä¸»č¦ éť˘ćťż (构建补ä¸ä¸)
-
-
- esc: 退出é€čˇŚć¨ˇĺĽŹ
- o: 打开文件
- ▲: 选择上一行
- ▼: 选择下一行
◄: 选择上一个区块
►: 选择下一个区块
- ctrl+o: copy the selected text to the clipboard
- space: ć·»ĺŠ /移除 行ĺ°čˇĄä¸
v: ĺ‡ćŤ˘ć‹–动选择
V: ĺ‡ćŤ˘ć‹–动选择
a: ĺ‡ćŤ˘é€‰ć‹©ĺŚşĺť—
- H: scroll left
- L: scroll right
-
-
-## ä¸»č¦ éť˘ćťż (ćŁĺś¨ćš‚ĺ)
-
-
+ ctrl+o: 将选ä¸ć–‡ćś¬ĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż
+ o: 打开文件
+ e: 编辑文件
esc: 返回文件面板
+ tab: ĺ‡ćŤ˘ĺ°ĺ…¶ä»–面板
space: ĺ‡ćŤ˘čˇŚćš‚ĺ状ć€
d: 取ć¶ĺŹć›´ (git reset)
- tab: ĺ‡ćŤ˘ĺ°ĺ…¶ä»–面板
- o: 打开文件
- ▲: 选择上一行
- ▼: 选择下一行
- ◄: 选择上一个区块
- ►: 选择下一个区块
- ctrl+o: copy the selected text to the clipboard
- e: 编辑文件
- o: 打开文件
- v: ĺ‡ćŤ˘ć‹–动选择
- V: ĺ‡ćŤ˘ć‹–动选择
- a: ĺ‡ćŤ˘é€‰ć‹©ĺŚşĺť—
- H: scroll left
- L: scroll right
- c: ćŹäş¤ć›´ć”ą
- w: ćŹäş¤ć›´ć”ąč€Ść— 需预ĺ…ćŹäş¤é’©ĺ
- C: ćŹäş¤ć›´ć”ąďĽä˝żç”¨çĽ–辑器编辑ćŹäş¤äżˇćŻďĽ‰
+ E: edit hunk
-## 菜单 面板
+## ćŁĺ¸¸
- esc: ĺ…łé—菜单
+ mouse wheel â–Ľ: ĺ‘下滚动 (fn+up)
+ mouse wheel â–˛: ĺ‘上滚动 (fn+down)
-## 贮藏 面板
-
-
- enter: 查看贮藏条目ä¸çš„文件
- space: 应用
- g: 应用并ĺ 除
- d: ĺ 除
- n: ć–°ĺ†ć”Ż
-
-
-## çŠ¶ć€ éť˘ćťż
+## 状ć€
e: 编辑配置文件
@@ -306,3 +256,36 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
enter: ĺ‡ćŤ˘ĺ°ćś€čż‘的仓库
a: ćľç¤şć‰€ćś‰ĺ†ć”Żçš„ć—Ąĺż—
+
+## 贮藏
+
+
+ space: 应用
+ g: 应用并ĺ 除
+ d: ĺ 除
+ n: ć–°ĺ†ć”Ż
+ enter: 查看ćŹäş¤çš„文件
+
+
+## 远程ĺ†ć”Ż
+
+
+ space: 检出
+ n: ć–°ĺ†ć”Ż
+ M: ĺĺą¶ĺ°ĺ˝“前检出的ĺ†ć”Ż
+ r: 将已检出的ĺ†ć”ŻĺŹĺźşĺ°čŻĄĺ†ć”Ż
+ d: ĺ 除ĺ†ć”Ż
+ u: 设置为检出ĺ†ć”Żçš„上游
+ esc: 返回远程仓库ĺ—表
+ g: 查看重置选项
+ enter: 查看ćŹäş¤
+
+
+## 远程页面
+
+
+ f: 抓取远程仓库
+ n: ć·»ĺŠ ć–°çš„čżśç¨‹ä»“ĺş“
+ d: ĺ 除远程
+ e: 编辑远程仓库
+
diff --git a/go.mod b/go.mod
index 860b7e6db..eb31e4713 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/jesseduffield/lazygit
-go 1.14
+go 1.18
require (
github.com/OpenPeeDeeP/xdg v1.0.0
@@ -9,42 +9,66 @@ require (
github.com/cli/safeexec v1.0.0
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.11
- github.com/fatih/color v1.9.0 // indirect
+ github.com/fsmiamoto/git-todo-parser v0.0.2
github.com/fsnotify/fsnotify v1.4.7
- github.com/gdamore/tcell/v2 v2.4.1-0.20210926162909-66f061b1fc9b // indirect
- github.com/go-errors/errors v1.4.1
- github.com/go-logfmt/logfmt v0.5.0 // indirect
- github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3
- github.com/golang/protobuf v1.3.2 // indirect
- github.com/google/go-cmp v0.5.6 // indirect
+ github.com/gdamore/tcell/v2 v2.5.2
+ github.com/go-errors/errors v1.4.2
github.com/gookit/color v1.4.2
github.com/imdario/mergo v0.3.11
github.com/integrii/flaggy v1.4.0
+ github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68
github.com/jesseduffield/go-git/v5 v5.1.2-0.20201006095850-341962be15a4
- github.com/jesseduffield/gocui v0.3.1-0.20220108045521-1945d7b9ed8b
+ github.com/jesseduffield/gocui v0.3.1-0.20220813101052-3a3ab26faa15
+ github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10
github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e
github.com/jesseduffield/yaml v2.1.0+incompatible
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
- github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
- github.com/kylelemons/godebug v1.1.0 // indirect
github.com/kyokomi/emoji/v2 v2.2.8
github.com/lucasb-eyer/go-colorful v1.2.0
- github.com/mattn/go-colorable v0.1.11 // indirect
github.com/mattn/go-runewidth v0.0.13
github.com/mgutz/str v1.2.0
- github.com/onsi/ginkgo v1.10.3 // indirect
- github.com/onsi/gomega v1.7.1 // indirect
github.com/pmezard/go-difflib v1.0.0
github.com/sahilm/fuzzy v0.1.0
+ github.com/samber/lo v1.10.1
github.com/sanity-io/litter v1.5.2
+ github.com/sasha-s/go-deadlock v0.3.1
github.com/sirupsen/logrus v1.4.2
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad
github.com/stretchr/testify v1.7.0
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778
- golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 // indirect
- golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c // indirect
- golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
- golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
- golang.org/x/text v0.3.7 // indirect
gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0
+ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/emirpasic/gods v1.12.0 // indirect
+ github.com/fatih/color v1.9.0 // indirect
+ github.com/gdamore/encoding v1.0.0 // indirect
+ github.com/go-git/gcfg v1.5.0 // indirect
+ github.com/go-git/go-billy/v5 v5.0.0 // indirect
+ github.com/go-logfmt/logfmt v0.5.0 // indirect
+ github.com/gobwas/glob v0.2.3 // indirect
+ github.com/google/go-cmp v0.5.6 // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+ github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect
+ github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
+ github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect
+ github.com/kylelemons/godebug v1.1.0 // indirect
+ github.com/mattn/go-colorable v0.1.11 // indirect
+ github.com/mattn/go-isatty v0.0.14 // indirect
+ github.com/mitchellh/go-homedir v1.1.0 // indirect
+ github.com/onsi/ginkgo v1.10.3 // indirect
+ github.com/onsi/gomega v1.7.1 // indirect
+ github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
+ github.com/rivo/uniseg v0.3.4 // indirect
+ github.com/sergi/go-diff v1.1.0 // indirect
+ github.com/xanzy/ssh-agent v0.2.1 // indirect
+ golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 // indirect
+ golang.org/x/exp v0.0.0-20220318154914-8dddf5d87bd8 // indirect
+ golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c // indirect
+ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
+ golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect
+ golang.org/x/text v0.3.7 // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
)
diff --git a/go.sum b/go.sum
index 69871e0ab..31b69c743 100644
--- a/go.sum
+++ b/go.sum
@@ -27,21 +27,21 @@ github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3
github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
-github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
+github.com/fsmiamoto/git-todo-parser v0.0.2 h1:l6Y+9q7jbM+yK/w6kASpHO7ejL9ARCErm3tCEqOT278=
+github.com/fsmiamoto/git-todo-parser v0.0.2/go.mod h1:B+AgTbNE2BARvJqzXygThzqxLIaEWvwr2sxKYYb0Fas=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
-github.com/gdamore/tcell/v2 v2.4.0 h1:W6dxJEmaxYvhICFoTY3WrLLEXsQ11SaFnKGVEXW57KM=
github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
-github.com/gdamore/tcell/v2 v2.4.1-0.20210926162909-66f061b1fc9b h1:eoaSI4eEwM5eTx/HvmRSwmicxuMhL73AyoEfM1oCJLc=
-github.com/gdamore/tcell/v2 v2.4.1-0.20210926162909-66f061b1fc9b/go.mod h1:ZPwXnysybtQqdqKcWMWXux9aGdtMHe+kr+cwEZEe+A4=
+github.com/gdamore/tcell/v2 v2.5.2 h1:tKzG29kO9p2V++3oBY2W9zUjYu7IK1MENFeY/BzJSVY=
+github.com/gdamore/tcell/v2 v2.5.2/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo=
github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
-github.com/go-errors/errors v1.4.1 h1:IvVlgbzSsaUNudsw5dcXSzF3EWyXTi5XrAdngnuhRyg=
-github.com/go-errors/errors v1.4.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
+github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM=
@@ -53,11 +53,7 @@ github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
-github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3 h1:zN2lZNZRflqFyxVaTIU61KNKQ9C0055u9CAfpmqUvo4=
-github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3/go.mod h1:nPpo7qLxd6XL3hWJG/O60sR8ZKfMCiIoNap5GvD12KU=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -72,10 +68,14 @@ github.com/integrii/flaggy v1.4.0 h1:A1x7SYx4jqu5NSrY14z8Z+0UyX2S5ygfJJrfolWR3zM
github.com/integrii/flaggy v1.4.0/go.mod h1:tnTxHeTJbah0gQ6/K0RW0J7fMUBk9MCF5blhm43LNpI=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68 h1:EQP2Tv8TIcC6Y4RI+1ZbJDOHfGJ570tPeYVCqo7/tws=
+github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68/go.mod h1:+LLj9/WUPAP8LqCchs7P+7X0R98HiFujVFANdNaxhGk=
github.com/jesseduffield/go-git/v5 v5.1.2-0.20201006095850-341962be15a4 h1:GOQrmaE8i+KEdB8NzAegKYd4tPn/inM0I1uo0NXFerg=
github.com/jesseduffield/go-git/v5 v5.1.2-0.20201006095850-341962be15a4/go.mod h1:nGNEErzf+NRznT+N2SWqmHnDnF9aLgANB1CUNEan09o=
-github.com/jesseduffield/gocui v0.3.1-0.20220108045521-1945d7b9ed8b h1:AUK5nDiPiaahBtGIsf8rITgZ9SC+uddvnNKs0/mrYA8=
-github.com/jesseduffield/gocui v0.3.1-0.20220108045521-1945d7b9ed8b/go.mod h1:znJuCDnF2Ph40YZSlBwdX/4GEofnIoWLGdT4mK5zRAU=
+github.com/jesseduffield/gocui v0.3.1-0.20220813101052-3a3ab26faa15 h1:DTVj8aCmINqLj5AXBEGmpWwfN1HJ3EWtUiYfcyIaSxs=
+github.com/jesseduffield/gocui v0.3.1-0.20220813101052-3a3ab26faa15/go.mod h1:znJuCDnF2Ph40YZSlBwdX/4GEofnIoWLGdT4mK5zRAU=
+github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 h1:jmpr7KpX2+2GRiE91zTgfq49QvgiqB0nbmlwZ8UnOx0=
+github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10/go.mod h1:aA97kHeNA+sj2Hbki0pvLslmE4CbDyhBeSSTUUnOuVo=
github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e h1:uw/oo+kg7t/oeMs6sqlAwr85ND/9cpO3up3VxphxY0U=
github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e/go.mod h1:u60qdFGXRd36jyEXxetz0vQceQIxzI13lIo3EFUDf4I=
github.com/jesseduffield/yaml v2.1.0+incompatible h1:HWQJ1gIv2zHKbDYNp0Jwjlj24K8aqpFHnMCynY1EpmE=
@@ -125,18 +125,25 @@ github.com/onsi/ginkgo v1.10.3 h1:OoxbjfXVZyod1fmWYhI7SEyaD8B00ynP3T+D5GiyHOY=
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.1 h1:K0jcRCwNQM3vFGh1ppMtDh/+7ApJrjldlX8fA0jDTLQ=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
+github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.3.4 h1:3Z3Eu6FGHZWSfNKJTOUiPatWwfc7DzJRU04jFUqJODw=
+github.com/rivo/uniseg v0.3.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/samber/lo v1.10.1 h1:0D3h7i0U3hRAbaCeQ82DLe67n0A7Bbl0/cEoWqFGp+U=
+github.com/samber/lo v1.10.1/go.mod h1:2I7tgIv8Q1SG2xEIkRq0F2i2zgxVpnyPOP0d3Gj2r+A=
github.com/sanity-io/litter v1.5.2 h1:AnC8s9BMORWH5a4atZ4D6FPVvKGzHcnc5/IVTa87myw=
github.com/sanity-io/litter v1.5.2/go.mod h1:5Z71SvaYy5kcGtyglXOC9rrUi3c1E8CamFWjQsazTh0=
+github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
+github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
@@ -152,6 +159,7 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M=
github.com/urfave/cli v1.20.1-0.20180226030253-8e01ec4cd3e2/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70=
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
@@ -163,6 +171,8 @@ golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 h1:hb9wdF1z5waM+dSIICn1l0DkLVDT3hqhhQsDNUmHPRE=
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/exp v0.0.0-20220318154914-8dddf5d87bd8 h1:s/+U+w0teGzcoH2mdIlFQ6KfVKGaYpgyGdUefZrn9TU=
+golang.org/x/exp v0.0.0-20220318154914-8dddf5d87bd8/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -181,20 +191,19 @@ golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
-golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc=
+golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -212,5 +221,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
index c6c097146..d7ce1db14 100644
--- a/main.go
+++ b/main.go
@@ -1,151 +1,24 @@
package main
import (
- "bytes"
- "fmt"
- "log"
- "os"
- "path/filepath"
- "runtime"
-
- "github.com/go-errors/errors"
- "github.com/integrii/flaggy"
"github.com/jesseduffield/lazygit/pkg/app"
- "github.com/jesseduffield/lazygit/pkg/config"
- "github.com/jesseduffield/lazygit/pkg/constants"
- "github.com/jesseduffield/lazygit/pkg/env"
- yaml "github.com/jesseduffield/yaml"
)
+// These values may be set by the build script via the LDFLAGS argument
var (
commit string
- version = "unversioned"
date string
+ version string
buildSource = "unknown"
)
func main() {
- flaggy.DefaultParser.ShowVersionWithVersionFlag = false
-
- repoPath := ""
- flaggy.String(&repoPath, "p", "path", "Path of git repo. (equivalent to --work-tree= --git-dir=/.git/)")
-
- filterPath := ""
- flaggy.String(&filterPath, "f", "filter", "Path to filter on in `git log -- `. When in filter mode, the commits, reflog, and stash are filtered based on the given path, and some operations are restricted")
-
- dump := ""
- flaggy.AddPositionalValue(&dump, "gitargs", 1, false, "Todo file")
- flaggy.DefaultParser.PositionalFlags[0].Hidden = true
-
- versionFlag := false
- flaggy.Bool(&versionFlag, "v", "version", "Print the current version")
-
- debuggingFlag := false
- flaggy.Bool(&debuggingFlag, "d", "debug", "Run in debug mode with logging (see --logs flag below). Use the LOG_LEVEL env var to set the log level (debug/info/warn/error)")
-
- logFlag := false
- flaggy.Bool(&logFlag, "l", "logs", "Tail lazygit logs (intended to be used when `lazygit --debug` is called in a separate terminal tab)")
-
- configFlag := false
- flaggy.Bool(&configFlag, "c", "config", "Print the default config")
-
- configDirFlag := false
- flaggy.Bool(&configDirFlag, "cd", "print-config-dir", "Print the config directory")
-
- useConfigDir := ""
- flaggy.String(&useConfigDir, "ucd", "use-config-dir", "override default config directory with provided directory")
-
- workTree := ""
- flaggy.String(&workTree, "w", "work-tree", "equivalent of the --work-tree git argument")
-
- gitDir := ""
- flaggy.String(&gitDir, "g", "git-dir", "equivalent of the --git-dir git argument")
-
- customConfig := ""
- flaggy.String(&customConfig, "ucf", "use-config-file", "Comma seperated list to custom config file(s)")
-
- flaggy.Parse()
-
- if repoPath != "" {
- if workTree != "" || gitDir != "" {
- log.Fatal("--path option is incompatible with the --work-tree and --git-dir options")
- }
-
- absRepoPath, err := filepath.Abs(repoPath)
- if err != nil {
- log.Fatal(err)
- }
- workTree = absRepoPath
- gitDir = filepath.Join(absRepoPath, ".git")
+ ldFlagsBuildInfo := &app.BuildInfo{
+ Commit: commit,
+ Date: date,
+ Version: version,
+ BuildSource: buildSource,
}
- if customConfig != "" {
- os.Setenv("LG_CONFIG_FILE", customConfig)
- }
-
- if useConfigDir != "" {
- os.Setenv("CONFIG_DIR", useConfigDir)
- }
-
- if workTree != "" {
- env.SetGitWorkTreeEnv(workTree)
- }
-
- if gitDir != "" {
- env.SetGitDirEnv(gitDir)
- }
-
- if versionFlag {
- fmt.Printf("commit=%s, build date=%s, build source=%s, version=%s, os=%s, arch=%s\n", commit, date, buildSource, version, runtime.GOOS, runtime.GOARCH)
- os.Exit(0)
- }
-
- if configFlag {
- var buf bytes.Buffer
- encoder := yaml.NewEncoder(&buf)
- err := encoder.Encode(config.GetDefaultConfig())
- if err != nil {
- log.Fatal(err.Error())
- }
- fmt.Printf("%s\n", buf.String())
- os.Exit(0)
- }
-
- if configDirFlag {
- fmt.Printf("%s\n", config.ConfigDir())
- os.Exit(0)
- }
-
- if logFlag {
- app.TailLogs()
- os.Exit(0)
- }
-
- if workTree != "" {
- if err := os.Chdir(workTree); err != nil {
- log.Fatal(err.Error())
- }
- }
-
- appConfig, err := config.NewAppConfig("lazygit", version, commit, date, buildSource, debuggingFlag)
- if err != nil {
- log.Fatal(err.Error())
- }
-
- app, err := app.NewApp(appConfig, filterPath)
-
- if err == nil {
- err = app.Run()
- }
-
- if err != nil {
- if errorMessage, known := app.KnownError(err); known {
- log.Fatal(errorMessage)
- }
- newErr := errors.Wrap(err, 0)
- stackTrace := newErr.ErrorStack()
- app.Log.Error(stackTrace)
-
- log.Fatal(fmt.Sprintf("%s: %s\n\n%s", app.Tr.ErrorOccurred, constants.Links.Issues, stackTrace))
- }
+ app.Start(ldFlagsBuildInfo, nil)
}
diff --git a/pkg/app/app.go b/pkg/app/app.go
index 680a62049..a1b53d96c 100644
--- a/pkg/app/app.go
+++ b/pkg/app/app.go
@@ -2,10 +2,8 @@ package app
import (
"bufio"
- "errors"
"fmt"
"io"
- "io/ioutil"
"log"
"os"
"path/filepath"
@@ -13,117 +11,91 @@ import (
"strconv"
"strings"
- "github.com/aybabtme/humanlog"
+ "github.com/go-errors/errors"
+
+ "github.com/jesseduffield/generics/slices"
+ appTypes "github.com/jesseduffield/lazygit/pkg/app/types"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/git_config"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/constants"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/gui"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/updates"
- "github.com/sirupsen/logrus"
)
-// App struct
+// App is the struct that's instantiated from within main.go and it manages
+// bootstrapping and running the application.
type App struct {
*common.Common
- closers []io.Closer
- Config config.AppConfigurer
- OSCommand *oscommands.OSCommand
- Gui *gui.Gui
- Updater *updates.Updater // may only need this on the Gui
- ClientContext string
+ closers []io.Closer
+ Config config.AppConfigurer
+ OSCommand *oscommands.OSCommand
+ Gui *gui.Gui
+ Updater *updates.Updater // may only need this on the Gui
}
-type errorMapping struct {
- originalError string
- newError string
-}
+func Run(
+ config config.AppConfigurer,
+ common *common.Common,
+ startArgs appTypes.StartArgs,
+) {
+ app, err := NewApp(config, common)
-func newProductionLogger() *logrus.Logger {
- log := logrus.New()
- log.Out = ioutil.Discard
- log.SetLevel(logrus.ErrorLevel)
- return log
-}
+ if err == nil {
+ err = app.Run(startArgs)
+ }
-func getLogLevel() logrus.Level {
- strLevel := os.Getenv("LOG_LEVEL")
- level, err := logrus.ParseLevel(strLevel)
if err != nil {
- return logrus.DebugLevel
+ if errorMessage, known := knownError(common.Tr, err); known {
+ log.Fatal(errorMessage)
+ }
+ newErr := errors.Wrap(err, 0)
+ stackTrace := newErr.ErrorStack()
+ app.Log.Error(stackTrace)
+
+ log.Fatalf("%s: %s\n\n%s", common.Tr.ErrorOccurred, constants.Links.Issues, stackTrace)
}
- return level
}
-func newDevelopmentLogger() *logrus.Logger {
- logger := logrus.New()
- logger.SetLevel(getLogLevel())
- logPath, err := config.LogPath()
- if err != nil {
- log.Fatal(err)
- }
- file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
- if err != nil {
- log.Fatalf("Unable to log to log file: %v", err)
- }
- logger.SetOutput(file)
- return logger
-}
-
-func newLogger(config config.AppConfigurer) *logrus.Entry {
- var log *logrus.Logger
- if config.GetDebug() || os.Getenv("DEBUG") == "TRUE" {
- log = newDevelopmentLogger()
- } else {
- log = newProductionLogger()
- }
-
- // highly recommended: tail -f development.log | humanlog
- // https://github.com/aybabtme/humanlog
- log.Formatter = &logrus.JSONFormatter{}
-
- return log.WithFields(logrus.Fields{
- "debug": config.GetDebug(),
- "version": config.GetVersion(),
- "commit": config.GetCommit(),
- "buildDate": config.GetBuildDate(),
- })
-}
-
-// NewApp bootstrap a new application
-func NewApp(config config.AppConfigurer, filterPath string) (*App, error) {
+func NewCommon(config config.AppConfigurer) (*common.Common, error) {
userConfig := config.GetUserConfig()
- app := &App{
- closers: []io.Closer{},
- Config: config,
- }
var err error
log := newLogger(config)
tr, err := i18n.NewTranslationSetFromConfig(log, userConfig.Gui.Language)
if err != nil {
- return app, err
+ return nil, err
}
- app.Common = &common.Common{
+ return &common.Common{
Log: log,
Tr: tr,
UserConfig: userConfig,
Debug: config.GetDebug(),
+ }, nil
+}
+
+// NewApp bootstrap a new application
+func NewApp(config config.AppConfigurer, common *common.Common) (*App, error) {
+ app := &App{
+ closers: []io.Closer{},
+ Config: config,
+ Common: common,
}
- // if we are being called in 'demon' mode, we can just return here
- app.ClientContext = os.Getenv("LAZYGIT_CLIENT_COMMAND")
- if app.ClientContext != "" {
- return app, nil
+ app.OSCommand = oscommands.NewOSCommand(common, config, oscommands.GetPlatform(), oscommands.NewNullGuiIO(app.Log))
+
+ var err error
+ app.Updater, err = updates.NewUpdater(common, config, app.OSCommand)
+ if err != nil {
+ return app, err
}
- app.OSCommand = oscommands.NewOSCommand(app.Common, oscommands.GetPlatform(), oscommands.NewNullGuiIO(log))
-
- app.Updater, err = updates.NewUpdater(app.Common, config, app.OSCommand)
+ dirName, err := os.Getwd()
if err != nil {
return app, err
}
@@ -135,7 +107,7 @@ func NewApp(config config.AppConfigurer, filterPath string) (*App, error) {
gitConfig := git_config.NewStdCachedGitConfig(app.Log)
- app.Gui, err = gui.NewGui(app.Common, config, gitConfig, app.Updater, filterPath, showRecentRepos)
+ app.Gui, err = gui.NewGui(common, config, gitConfig, app.Updater, showRecentRepos, dirName)
if err != nil {
return app, err
}
@@ -200,27 +172,9 @@ func isGitVersionValid(versionStr string) bool {
return true
}
-func isGhVersionValid(versionStr string) bool {
- // output should be something like:
- // gh version 2.0.0 (2021-08-23)
- // https://github.com/cli/cli/releases/tag/v2.0.0
- re := regexp.MustCompile(`[^\d]+([\d\.]+)`)
- matches := re.FindStringSubmatch(versionStr)
-
- if len(matches) == 0 {
- return false
- }
-
- ghVersion := matches[1]
- majorVersion, err := strconv.Atoi(ghVersion[0:1])
- if err != nil {
- return false
- }
- if majorVersion < 2 {
- return false
- }
-
- return true
+func isDirectoryAGitRepository(dir string) (bool, error) {
+ info, err := os.Stat(filepath.Join(dir, ".git"))
+ return info != nil, err
}
func (app *App) setupRepo() (bool, error) {
@@ -239,146 +193,69 @@ func (app *App) setupRepo() (bool, error) {
if err != nil {
return false, err
}
- info, _ := os.Stat(filepath.Join(cwd, ".git"))
- if info != nil && info.IsDir() {
- return false, err // Current directory appears to be a git repository.
+ if isRepo, err := isDirectoryAGitRepository(cwd); isRepo {
+ return false, err
}
- shouldInitRepo := true
- notARepository := app.UserConfig.NotARepository
- if notARepository == "prompt" {
+ var shouldInitRepo bool
+ initialBranchArg := ""
+ switch app.UserConfig.NotARepository {
+ case "prompt":
// Offer to initialize a new repository in current directory.
fmt.Print(app.Tr.CreateRepo)
response, _ := bufio.NewReader(os.Stdin).ReadString('\n')
- if strings.Trim(response, " \n") != "y" {
- shouldInitRepo = false
- }
- } else if notARepository == "skip" {
- shouldInitRepo = false
- }
-
- if !shouldInitRepo {
- // check if we have a recent repo we can open
- recentRepos := app.Config.GetAppState().RecentRepos
- if len(recentRepos) > 0 {
- var err error
- // try opening each repo in turn, in case any have been deleted
- for _, repoDir := range recentRepos {
- if err = os.Chdir(repoDir); err == nil {
- return true, nil
- }
+ shouldInitRepo = (strings.Trim(response, " \r\n") == "y")
+ if shouldInitRepo {
+ // Ask for the initial branch name
+ fmt.Print(app.Tr.InitialBranch)
+ response, _ := bufio.NewReader(os.Stdin).ReadString('\n')
+ if trimmedResponse := strings.Trim(response, " \r\n"); len(trimmedResponse) > 0 {
+ initialBranchArg += "--initial-branch=" + app.OSCommand.Quote(trimmedResponse)
}
- return false, err
}
-
+ case "create":
+ shouldInitRepo = true
+ case "skip":
+ shouldInitRepo = false
+ case "quit":
+ fmt.Fprintln(os.Stderr, app.Tr.NotARepository)
+ os.Exit(1)
+ default:
+ fmt.Fprintln(os.Stderr, app.Tr.IncorrectNotARepository)
os.Exit(1)
}
- if err := app.OSCommand.Cmd.New("git init").Run(); err != nil {
- return false, err
+
+ if shouldInitRepo {
+ if err := app.OSCommand.Cmd.New("git init " + initialBranchArg).Run(); err != nil {
+ return false, err
+ }
+ return false, nil
}
+
+ // check if we have a recent repo we can open
+ for _, repoDir := range app.Config.GetAppState().RecentRepos {
+ if isRepo, _ := isDirectoryAGitRepository(repoDir); isRepo {
+ if err := os.Chdir(repoDir); err == nil {
+ return true, nil
+ }
+ }
+ }
+
+ fmt.Fprintln(os.Stderr, app.Tr.NoRecentRepositories)
+ os.Exit(1)
}
return false, nil
}
-func (app *App) Run() error {
- if app.ClientContext == "INTERACTIVE_REBASE" {
- return app.Rebase()
- }
-
- if app.ClientContext == "EXIT_IMMEDIATELY" {
- os.Exit(0)
- }
-
- err := app.Gui.RunAndHandleError()
+func (app *App) Run(startArgs appTypes.StartArgs) error {
+ err := app.Gui.RunAndHandleError(startArgs)
return err
}
-func gitDir() string {
- dir := env.GetGitDirEnv()
- if dir == "" {
- return ".git"
- }
- return dir
-}
-
-// Rebase contains logic for when we've been run in demon mode, meaning we've
-// given lazygit as a command for git to call e.g. to edit a file
-func (app *App) Rebase() error {
- app.Log.Info("Lazygit invoked as interactive rebase demon")
- app.Log.Info("args: ", os.Args)
-
- if strings.HasSuffix(os.Args[1], "git-rebase-todo") {
- if err := ioutil.WriteFile(os.Args[1], []byte(os.Getenv("LAZYGIT_REBASE_TODO")), 0644); err != nil {
- return err
- }
-
- } else if strings.HasSuffix(os.Args[1], filepath.Join(gitDir(), "COMMIT_EDITMSG")) { // TODO: test
- // if we are rebasing and squashing, we'll see a COMMIT_EDITMSG
- // but in this case we don't need to edit it, so we'll just return
- } else {
- app.Log.Info("Lazygit demon did not match on any use cases")
- }
-
- return nil
-}
-
// Close closes any resources
func (app *App) Close() error {
- for _, closer := range app.closers {
- err := closer.Close()
- if err != nil {
- return err
- }
- }
- return nil
-}
-
-// KnownError takes an error and tells us whether it's an error that we know about where we can print a nicely formatted version of it rather than panicking with a stack trace
-func (app *App) KnownError(err error) (string, bool) {
- errorMessage := err.Error()
-
- knownErrorMessages := []string{app.Tr.MinGitVersionError}
-
- for _, message := range knownErrorMessages {
- if errorMessage == message {
- return message, true
- }
- }
-
- mappings := []errorMapping{
- {
- originalError: "fatal: not a git repository",
- newError: app.Tr.NotARepository,
- },
- }
-
- for _, mapping := range mappings {
- if strings.Contains(errorMessage, mapping.originalError) {
- return mapping.newError, true
- }
- }
- return "", false
-}
-
-func TailLogs() {
- logFilePath, err := config.LogPath()
- if err != nil {
- log.Fatal(err)
- }
-
- fmt.Printf("Tailing log file %s\n\n", logFilePath)
-
- opts := humanlog.DefaultOptions
- opts.Truncates = false
-
- _, err = os.Stat(logFilePath)
- if err != nil {
- if os.IsNotExist(err) {
- log.Fatal("Log file does not exist. Run `lazygit --debug` first to create the log file")
- }
- log.Fatal(err)
- }
-
- TailLogsForPlatform(logFilePath, opts)
+ return slices.TryForEach(app.closers, func(closer io.Closer) error {
+ return closer.Close()
+ })
}
diff --git a/pkg/app/daemon/daemon.go b/pkg/app/daemon/daemon.go
new file mode 100644
index 000000000..ea71bb956
--- /dev/null
+++ b/pkg/app/daemon/daemon.go
@@ -0,0 +1,107 @@
+package daemon
+
+import (
+ "io/ioutil"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/jesseduffield/lazygit/pkg/common"
+ "github.com/jesseduffield/lazygit/pkg/env"
+)
+
+// Sometimes lazygit will be invoked in daemon mode from a parent lazygit process.
+// We do this when git lets us supply a program to run within a git command.
+// For example, if we want to ensure that a git command doesn't hang due to
+// waiting for an editor to save a commit message, we can tell git to invoke lazygit
+// as the editor via 'GIT_EDITOR=lazygit', and use the env var
+// 'LAZYGIT_DAEMON_KIND=EXIT_IMMEDIATELY' to specify that we want to run lazygit
+// as a daemon which simply exits immediately. Any additional arguments we want
+// to pass to a daemon can be done via other env vars.
+
+type DaemonKind string
+
+const (
+ InteractiveRebase DaemonKind = "INTERACTIVE_REBASE"
+ ExitImmediately DaemonKind = "EXIT_IMMEDIATELY"
+)
+
+const (
+ DaemonKindEnvKey string = "LAZYGIT_DAEMON_KIND"
+ RebaseTODOEnvKey string = "LAZYGIT_REBASE_TODO"
+)
+
+type Daemon interface {
+ Run() error
+}
+
+func Handle(common *common.Common) {
+ d := getDaemon(common)
+ if d == nil {
+ return
+ }
+
+ if err := d.Run(); err != nil {
+ log.Fatal(err)
+ }
+
+ os.Exit(0)
+}
+
+func InDaemonMode() bool {
+ return getDaemonKind() != ""
+}
+
+func getDaemon(common *common.Common) Daemon {
+ switch getDaemonKind() {
+ case InteractiveRebase:
+ return &rebaseDaemon{c: common}
+ case ExitImmediately:
+ return &exitImmediatelyDaemon{c: common}
+ }
+
+ return nil
+}
+
+func getDaemonKind() DaemonKind {
+ return DaemonKind(os.Getenv(DaemonKindEnvKey))
+}
+
+type rebaseDaemon struct {
+ c *common.Common
+}
+
+func (self *rebaseDaemon) Run() error {
+ self.c.Log.Info("Lazygit invoked as interactive rebase demon")
+ self.c.Log.Info("args: ", os.Args)
+
+ if strings.HasSuffix(os.Args[1], "git-rebase-todo") {
+ if err := ioutil.WriteFile(os.Args[1], []byte(os.Getenv(RebaseTODOEnvKey)), 0o644); err != nil {
+ return err
+ }
+ } else if strings.HasSuffix(os.Args[1], filepath.Join(gitDir(), "COMMIT_EDITMSG")) { // TODO: test
+ // if we are rebasing and squashing, we'll see a COMMIT_EDITMSG
+ // but in this case we don't need to edit it, so we'll just return
+ } else {
+ self.c.Log.Info("Lazygit demon did not match on any use cases")
+ }
+
+ return nil
+}
+
+func gitDir() string {
+ dir := env.GetGitDirEnv()
+ if dir == "" {
+ return ".git"
+ }
+ return dir
+}
+
+type exitImmediatelyDaemon struct {
+ c *common.Common
+}
+
+func (self *exitImmediatelyDaemon) Run() error {
+ return nil
+}
diff --git a/pkg/app/entry_point.go b/pkg/app/entry_point.go
new file mode 100644
index 000000000..5a767bb94
--- /dev/null
+++ b/pkg/app/entry_point.go
@@ -0,0 +1,265 @@
+package app
+
+import (
+ "bytes"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "runtime"
+ "runtime/debug"
+ "strings"
+
+ "github.com/integrii/flaggy"
+ "github.com/jesseduffield/lazygit/pkg/app/daemon"
+ appTypes "github.com/jesseduffield/lazygit/pkg/app/types"
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/env"
+ integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
+ "github.com/jesseduffield/lazygit/pkg/logs"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/samber/lo"
+ "gopkg.in/yaml.v3"
+)
+
+type cliArgs struct {
+ RepoPath string
+ FilterPath string
+ GitArg string
+ PrintVersionInfo bool
+ Debug bool
+ TailLogs bool
+ PrintDefaultConfig bool
+ PrintConfigDir bool
+ UseConfigDir string
+ WorkTree string
+ GitDir string
+ CustomConfigFile string
+}
+
+type BuildInfo struct {
+ Commit string
+ Date string
+ Version string
+ BuildSource string
+}
+
+func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTest) {
+ cliArgs := parseCliArgsAndEnvVars()
+ mergeBuildInfo(buildInfo)
+
+ if cliArgs.RepoPath != "" {
+ if cliArgs.WorkTree != "" || cliArgs.GitDir != "" {
+ log.Fatal("--path option is incompatible with the --work-tree and --git-dir options")
+ }
+
+ absRepoPath, err := filepath.Abs(cliArgs.RepoPath)
+ if err != nil {
+ log.Fatal(err)
+ }
+ cliArgs.WorkTree = absRepoPath
+ cliArgs.GitDir = filepath.Join(absRepoPath, ".git")
+ }
+
+ if cliArgs.CustomConfigFile != "" {
+ os.Setenv("LG_CONFIG_FILE", cliArgs.CustomConfigFile)
+ }
+
+ if cliArgs.UseConfigDir != "" {
+ os.Setenv("CONFIG_DIR", cliArgs.UseConfigDir)
+ }
+
+ if cliArgs.WorkTree != "" {
+ env.SetGitWorkTreeEnv(cliArgs.WorkTree)
+ }
+
+ if cliArgs.GitDir != "" {
+ env.SetGitDirEnv(cliArgs.GitDir)
+ }
+
+ if cliArgs.PrintVersionInfo {
+ fmt.Printf("commit=%s, build date=%s, build source=%s, version=%s, os=%s, arch=%s\n", buildInfo.Commit, buildInfo.Date, buildInfo.BuildSource, buildInfo.Version, runtime.GOOS, runtime.GOARCH)
+ os.Exit(0)
+ }
+
+ if cliArgs.PrintDefaultConfig {
+ var buf bytes.Buffer
+ encoder := yaml.NewEncoder(&buf)
+ err := encoder.Encode(config.GetDefaultConfig())
+ if err != nil {
+ log.Fatal(err.Error())
+ }
+ fmt.Printf("%s\n", buf.String())
+ os.Exit(0)
+ }
+
+ if cliArgs.PrintConfigDir {
+ fmt.Printf("%s\n", config.ConfigDir())
+ os.Exit(0)
+ }
+
+ if cliArgs.TailLogs {
+ logs.TailLogs()
+ os.Exit(0)
+ }
+
+ if cliArgs.WorkTree != "" {
+ if err := os.Chdir(cliArgs.WorkTree); err != nil {
+ log.Fatal(err.Error())
+ }
+ }
+
+ tempDir, err := os.MkdirTemp("", "lazygit-*")
+ if err != nil {
+ log.Fatal(err.Error())
+ }
+ defer os.RemoveAll(tempDir)
+
+ appConfig, err := config.NewAppConfig("lazygit", buildInfo.Version, buildInfo.Commit, buildInfo.Date, buildInfo.BuildSource, cliArgs.Debug, tempDir)
+ if err != nil {
+ log.Fatal(err.Error())
+ }
+
+ if integrationTest != nil {
+ integrationTest.SetupConfig(appConfig)
+ }
+
+ common, err := NewCommon(appConfig)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if daemon.InDaemonMode() {
+ daemon.Handle(common)
+ return
+ }
+
+ parsedGitArg := parseGitArg(cliArgs.GitArg)
+
+ Run(appConfig, common, appTypes.NewStartArgs(cliArgs.FilterPath, parsedGitArg, integrationTest))
+}
+
+func parseCliArgsAndEnvVars() *cliArgs {
+ flaggy.DefaultParser.ShowVersionWithVersionFlag = false
+
+ repoPath := ""
+ flaggy.String(&repoPath, "p", "path", "Path of git repo. (equivalent to --work-tree= --git-dir=/.git/)")
+
+ filterPath := ""
+ flaggy.String(&filterPath, "f", "filter", "Path to filter on in `git log -- `. When in filter mode, the commits, reflog, and stash are filtered based on the given path, and some operations are restricted")
+
+ gitArg := ""
+ flaggy.AddPositionalValue(&gitArg, "git-arg", 1, false, "Panel to focus upon opening lazygit. Accepted values (based on git terminology): status, branch, log, stash. Ignored if --filter arg is passed.")
+
+ printVersionInfo := false
+ flaggy.Bool(&printVersionInfo, "v", "version", "Print the current version")
+
+ debug := false
+ flaggy.Bool(&debug, "d", "debug", "Run in debug mode with logging (see --logs flag below). Use the LOG_LEVEL env var to set the log level (debug/info/warn/error)")
+
+ tailLogs := false
+ flaggy.Bool(&tailLogs, "l", "logs", "Tail lazygit logs (intended to be used when `lazygit --debug` is called in a separate terminal tab)")
+
+ printDefaultConfig := false
+ flaggy.Bool(&printDefaultConfig, "c", "config", "Print the default config")
+
+ printConfigDir := false
+ flaggy.Bool(&printConfigDir, "cd", "print-config-dir", "Print the config directory")
+
+ useConfigDir := ""
+ flaggy.String(&useConfigDir, "ucd", "use-config-dir", "override default config directory with provided directory")
+
+ workTree := ""
+ flaggy.String(&workTree, "w", "work-tree", "equivalent of the --work-tree git argument")
+
+ gitDir := ""
+ flaggy.String(&gitDir, "g", "git-dir", "equivalent of the --git-dir git argument")
+
+ customConfigFile := ""
+ flaggy.String(&customConfigFile, "ucf", "use-config-file", "Comma separated list to custom config file(s)")
+
+ flaggy.Parse()
+
+ if os.Getenv("DEBUG") == "TRUE" {
+ debug = true
+ }
+
+ return &cliArgs{
+ RepoPath: repoPath,
+ FilterPath: filterPath,
+ GitArg: gitArg,
+ PrintVersionInfo: printVersionInfo,
+ Debug: debug,
+ TailLogs: tailLogs,
+ PrintDefaultConfig: printDefaultConfig,
+ PrintConfigDir: printConfigDir,
+ UseConfigDir: useConfigDir,
+ WorkTree: workTree,
+ GitDir: gitDir,
+ CustomConfigFile: customConfigFile,
+ }
+}
+
+func parseGitArg(gitArg string) appTypes.GitArg {
+ typedArg := appTypes.GitArg(gitArg)
+
+ // using switch so that linter catches when a new git arg value is defined but not handled here
+ switch typedArg {
+ case appTypes.GitArgNone, appTypes.GitArgStatus, appTypes.GitArgBranch, appTypes.GitArgLog, appTypes.GitArgStash:
+ return typedArg
+ }
+
+ permittedValues := []string{
+ string(appTypes.GitArgStatus),
+ string(appTypes.GitArgBranch),
+ string(appTypes.GitArgLog),
+ string(appTypes.GitArgStash),
+ }
+
+ log.Fatalf("Invalid git arg value: '%s'. Must be one of the following values: %s. e.g. 'lazygit status'. See 'lazygit --help'.",
+ gitArg,
+ strings.Join(permittedValues, ", "),
+ )
+
+ panic("unreachable")
+}
+
+// the buildInfo struct we get passed in is based on what's baked into the lazygit
+// binary via the LDFLAGS argument. Some lazygit distributions will make use of these
+// arguments and some will not. Go recently started baking in build info
+// into the binary by default e.g. the git commit hash. So in this function
+// we merge the two together, giving priority to the stuff set by LDFLAGS.
+// Note: this mutates the argument passed in
+func mergeBuildInfo(buildInfo *BuildInfo) {
+ // if the version has already been set by build flags then we'll honour that.
+ // chances are it's something like v0.31.0 which is more informative than a
+ // commit hash.
+ if buildInfo.Version != "" {
+ return
+ }
+
+ buildInfo.Version = "unversioned"
+
+ goBuildInfo, ok := debug.ReadBuildInfo()
+ if !ok {
+ return
+ }
+
+ revision, ok := lo.Find(goBuildInfo.Settings, func(setting debug.BuildSetting) bool {
+ return setting.Key == "vcs.revision"
+ })
+ if ok {
+ buildInfo.Commit = revision.Value
+ // if lazygit was built from source we'll show the version as the
+ // abbreviated commit hash
+ buildInfo.Version = utils.ShortSha(revision.Value)
+ }
+
+ // if version hasn't been set we assume that neither has the date
+ time, ok := lo.Find(goBuildInfo.Settings, func(setting debug.BuildSetting) bool {
+ return setting.Key == "vcs.time"
+ })
+ if ok {
+ buildInfo.Date = time.Value
+ }
+}
diff --git a/pkg/app/errors.go b/pkg/app/errors.go
new file mode 100644
index 000000000..1556e58fe
--- /dev/null
+++ b/pkg/app/errors.go
@@ -0,0 +1,39 @@
+package app
+
+import (
+ "strings"
+
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/i18n"
+)
+
+type errorMapping struct {
+ originalError string
+ newError string
+}
+
+// knownError takes an error and tells us whether it's an error that we know about where we can print a nicely formatted version of it rather than panicking with a stack trace
+func knownError(tr *i18n.TranslationSet, err error) (string, bool) {
+ errorMessage := err.Error()
+
+ knownErrorMessages := []string{tr.MinGitVersionError}
+
+ if slices.Contains(knownErrorMessages, errorMessage) {
+ return errorMessage, true
+ }
+
+ mappings := []errorMapping{
+ {
+ originalError: "fatal: not a git repository",
+ newError: tr.NotARepository,
+ },
+ }
+
+ if mapping, ok := slices.Find(mappings, func(mapping errorMapping) bool {
+ return strings.Contains(errorMessage, mapping.originalError)
+ }); ok {
+ return mapping.newError, true
+ }
+
+ return "", false
+}
diff --git a/pkg/app/logging.go b/pkg/app/logging.go
index 7df0bb0c2..7a5eef74e 100644
--- a/pkg/app/logging.go
+++ b/pkg/app/logging.go
@@ -1,31 +1,56 @@
-//go:build !windows
-// +build !windows
-
package app
import (
+ "io/ioutil"
"log"
"os"
- "github.com/aybabtme/humanlog"
- "github.com/jesseduffield/lazygit/pkg/secureexec"
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/sirupsen/logrus"
)
-func TailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) {
- cmd := secureexec.Command("tail", "-f", logFilePath)
-
- stdout, _ := cmd.StdoutPipe()
- if err := cmd.Start(); err != nil {
- log.Fatal(err)
+func newLogger(config config.AppConfigurer) *logrus.Entry {
+ var log *logrus.Logger
+ if config.GetDebug() {
+ log = newDevelopmentLogger()
+ } else {
+ log = newProductionLogger()
}
- if err := humanlog.Scanner(stdout, os.Stdout, opts); err != nil {
- log.Fatal(err)
- }
+ // highly recommended: tail -f development.log | humanlog
+ // https://github.com/aybabtme/humanlog
+ log.Formatter = &logrus.JSONFormatter{}
- if err := cmd.Wait(); err != nil {
- log.Fatal(err)
- }
-
- os.Exit(0)
+ return log.WithFields(logrus.Fields{})
+}
+
+func newProductionLogger() *logrus.Logger {
+ log := logrus.New()
+ log.Out = ioutil.Discard
+ log.SetLevel(logrus.ErrorLevel)
+ return log
+}
+
+func newDevelopmentLogger() *logrus.Logger {
+ logger := logrus.New()
+ logger.SetLevel(getLogLevel())
+ logPath, err := config.LogPath()
+ if err != nil {
+ log.Fatal(err)
+ }
+ file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
+ if err != nil {
+ log.Fatalf("Unable to log to log file: %v", err)
+ }
+ logger.SetOutput(file)
+ return logger
+}
+
+func getLogLevel() logrus.Level {
+ strLevel := os.Getenv("LOG_LEVEL")
+ level, err := logrus.ParseLevel(strLevel)
+ if err != nil {
+ return logrus.DebugLevel
+ }
+ return level
}
diff --git a/pkg/app/logging_windows.go b/pkg/app/logging_windows.go
deleted file mode 100644
index f8b3d4990..000000000
--- a/pkg/app/logging_windows.go
+++ /dev/null
@@ -1,72 +0,0 @@
-//go:build windows
-// +build windows
-
-package app
-
-import (
- "bufio"
- "github.com/aybabtme/humanlog"
- "log"
- "os"
- "strings"
- "time"
-)
-
-func TailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) {
- var lastModified int64 = 0
- var lastOffset int64 = 0
- for {
- stat, err := os.Stat(logFilePath)
- if err != nil {
- log.Fatal(err)
- }
- if stat.ModTime().Unix() > lastModified {
- err = TailFrom(lastOffset, logFilePath, opts)
- if err != nil {
- log.Fatal(err)
- }
- }
- lastOffset = stat.Size()
- time.Sleep(1 * time.Second)
- }
-}
-
-func OpenAndSeek(filepath string, offset int64) (*os.File, error) {
- file, err := os.Open(filepath)
- if err != nil {
- return nil, err
- }
-
- _, err = file.Seek(offset, 0)
- if err != nil {
- _ = file.Close()
- return nil, err
- }
- return file, nil
-}
-
-func TailFrom(lastOffset int64, logFilePath string, opts *humanlog.HandlerOptions) error {
- file, err := OpenAndSeek(logFilePath, lastOffset)
- if err != nil {
- return err
- }
-
- fileScanner := bufio.NewScanner(file)
- var lines []string
- for fileScanner.Scan() {
- lines = append(lines, fileScanner.Text())
- }
- file.Close()
- lineCount := len(lines)
- lastTen := lines
- if lineCount > 10 {
- lastTen = lines[lineCount-10:]
- }
- for _, line := range lastTen {
- reader := strings.NewReader(line)
- if err := humanlog.Scanner(reader, os.Stdout, opts); err != nil {
- log.Fatal(err)
- }
- }
- return nil
-}
diff --git a/pkg/app/types/types.go b/pkg/app/types/types.go
new file mode 100644
index 000000000..002111087
--- /dev/null
+++ b/pkg/app/types/types.go
@@ -0,0 +1,33 @@
+package app
+
+import (
+ integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
+)
+
+// StartArgs is the struct that represents some things we want to do on program start
+type StartArgs struct {
+ // FilterPath determines which path we're going to filter on so that we only see commits from that file.
+ FilterPath string
+ // GitArg determines what context we open in
+ GitArg GitArg
+ // integration test (only relevant when invoking lazygit in the context of an integration test)
+ IntegrationTest integrationTypes.IntegrationTest
+}
+
+type GitArg string
+
+const (
+ GitArgNone GitArg = ""
+ GitArgStatus GitArg = "status"
+ GitArgBranch GitArg = "branch"
+ GitArgLog GitArg = "log"
+ GitArgStash GitArg = "stash"
+)
+
+func NewStartArgs(filterPath string, gitArg GitArg, test integrationTypes.IntegrationTest) StartArgs {
+ return StartArgs{
+ FilterPath: filterPath,
+ GitArg: gitArg,
+ IntegrationTest: test,
+ }
+}
diff --git a/pkg/cheatsheet/check.go b/pkg/cheatsheet/check.go
index 03f65d910..a0c40e775 100644
--- a/pkg/cheatsheet/check.go
+++ b/pkg/cheatsheet/check.go
@@ -17,11 +17,11 @@ func Check() {
tmpDir := filepath.Join(os.TempDir(), "lazygit_cheatsheet")
err := os.RemoveAll(tmpDir)
if err != nil {
- log.Fatalf("Error occured while checking if cheatsheets are up to date: %v", err)
+ log.Fatalf("Error occurred while checking if cheatsheets are up to date: %v", err)
}
- err = os.Mkdir(tmpDir, 0700)
+ err = os.Mkdir(tmpDir, 0o700)
if err != nil {
- log.Fatalf("Error occured while checking if cheatsheets are up to date: %v", err)
+ log.Fatalf("Error occurred while checking if cheatsheets are up to date: %v", err)
}
generateAtDir(tmpDir)
@@ -45,9 +45,9 @@ func Check() {
Context: 1,
})
if err != nil {
- log.Fatalf("Error occured while checking if cheatsheets are up to date: %v", err)
+ log.Fatalf("Error occurred while checking if cheatsheets are up to date: %v", err)
}
- fmt.Printf("\nCheatsheets are out of date. Please run `%s` at the project root and commit the changes\n", CommandToRun())
+ fmt.Printf("\nCheatsheets are out of date. Please run `%s` at the project root and commit the changes. If you run the script and no keybindings files are updated as a result, try rebasing onto master and trying again.\n", CommandToRun())
os.Exit(1)
}
@@ -62,7 +62,7 @@ func obtainContent(dir string) string {
if re.MatchString(path) {
bytes, err := ioutil.ReadFile(path)
if err != nil {
- log.Fatalf("Error occured while checking if cheatsheets are up to date: %v", err)
+ log.Fatalf("Error occurred while checking if cheatsheets are up to date: %v", err)
}
content += fmt.Sprintf("\n%s\n\n", filepath.Base(path))
content += string(bytes)
@@ -70,9 +70,8 @@ func obtainContent(dir string) string {
return nil
})
-
if err != nil {
- log.Fatalf("Error occured while checking if cheatsheets are up to date: %v", err)
+ log.Fatalf("Error occurred while checking if cheatsheets are up to date: %v", err)
}
return content
diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go
index 94eea0687..3b9d9c2d1 100644
--- a/pkg/cheatsheet/generate.go
+++ b/pkg/cheatsheet/generate.go
@@ -12,18 +12,32 @@ import (
"fmt"
"log"
"os"
- "sort"
+ "github.com/jesseduffield/generics/maps"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/app"
"github.com/jesseduffield/lazygit/pkg/config"
- "github.com/jesseduffield/lazygit/pkg/gui"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
- "github.com/jesseduffield/lazygit/pkg/integration"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/samber/lo"
)
type bindingSection struct {
title string
- bindings []*gui.Binding
+ bindings []*types.Binding
+}
+
+type header struct {
+ // priority decides the order of the headers in the cheatsheet (lower means higher)
+ priority int
+ title string
+}
+
+type headerWithBindings struct {
+ header header
+ bindings []*types.Binding
}
func CommandToRun() string {
@@ -31,25 +45,28 @@ func CommandToRun() string {
}
func GetDir() string {
- return integration.GetRootDirectory() + "/docs/keybindings"
+ return utils.GetLazygitRootDirectory() + "/docs/keybindings"
}
func generateAtDir(cheatsheetDir string) {
- os.Setenv("LANG", "en")
-
translationSetsByLang := i18n.GetTranslationSets()
mConfig := config.NewDummyAppConfig()
for lang := range translationSetsByLang {
- os.Setenv("LC_ALL", lang)
- mApp, _ := app.NewApp(mConfig, "")
+ mConfig.GetUserConfig().Gui.Language = lang
+ common, err := app.NewCommon(mConfig)
+ if err != nil {
+ log.Fatal(err)
+ }
+ mApp, _ := app.NewApp(mConfig, common)
path := cheatsheetDir + "/Keybindings_" + lang + ".md"
file, err := os.Create(path)
if err != nil {
panic(err)
}
- bindingSections := getBindingSections(mApp)
+ bindings := mApp.Gui.GetCheatsheetKeybindings()
+ bindingSections := getBindingSections(bindings, mApp.Tr)
content := formatSections(mApp.Tr, bindingSections)
content = fmt.Sprintf("_This file is auto-generated. To update, make the changes in the "+
"pkg/i18n directory and then run `%s` from the project root._\n\n%s", CommandToRun(), content)
@@ -68,9 +85,7 @@ func writeString(file *os.File, str string) {
}
}
-func localisedTitle(mApp *app.App, str string) string {
- tr := mApp.Tr
-
+func localisedTitle(tr *i18n.TranslationSet, str string) string {
contextTitleMap := map[string]string{
"global": tr.GlobalTitle,
"navigation": tr.NavigationTitle,
@@ -88,12 +103,10 @@ func localisedTitle(mApp *app.App, str string) string {
"commitMessage": tr.CommitMessageTitle,
"commits": tr.CommitsTitle,
"confirmation": tr.ConfirmationTitle,
- "credentials": tr.CredentialsTitle,
"information": tr.InformationTitle,
- "main": tr.MainTitle,
+ "main": tr.NormalTitle,
"patchBuilding": tr.PatchBuildingTitle,
- "merging": tr.MergingTitle,
- "normal": tr.NormalTitle,
+ "mergeConflicts": tr.MergingTitle,
"staging": tr.StagingTitle,
"menu": tr.MenuTitle,
"search": tr.SearchTitle,
@@ -111,142 +124,59 @@ func localisedTitle(mApp *app.App, str string) string {
return title
}
-func formatTitle(title string) string {
- return fmt.Sprintf("\n## %s\n\n", title)
-}
-
-func formatBinding(binding *gui.Binding) string {
- if binding.Alternative != "" {
- return fmt.Sprintf(" %s: %s (%s)\n", gui.GetKeyDisplay(binding.Key), binding.Description, binding.Alternative)
- }
- return fmt.Sprintf(" %s: %s\n", gui.GetKeyDisplay(binding.Key), binding.Description)
-}
-
-func getBindingSections(mApp *app.App) []*bindingSection {
- bindingSections := []*bindingSection{}
-
- bindings := mApp.Gui.GetInitialKeybindings()
-
- type contextAndViewType struct {
- subtitle string
- title string
- }
-
- contextAndViewBindingMap := map[contextAndViewType][]*gui.Binding{}
-
-outer:
- for _, binding := range bindings {
- if binding.Tag == "navigation" {
- key := contextAndViewType{subtitle: "", title: "navigation"}
- existing := contextAndViewBindingMap[key]
- if existing == nil {
- contextAndViewBindingMap[key] = []*gui.Binding{binding}
- } else {
- for _, navBinding := range contextAndViewBindingMap[key] {
- if navBinding.Description == binding.Description {
- continue outer
- }
- }
- contextAndViewBindingMap[key] = append(contextAndViewBindingMap[key], binding)
- }
-
- continue outer
- }
-
- contexts := []string{}
- if len(binding.Contexts) == 0 {
- contexts = append(contexts, "")
- } else {
- contexts = append(contexts, binding.Contexts...)
- }
-
- for _, context := range contexts {
- key := contextAndViewType{subtitle: context, title: binding.ViewName}
- existing := contextAndViewBindingMap[key]
- if existing == nil {
- contextAndViewBindingMap[key] = []*gui.Binding{binding}
- } else {
- contextAndViewBindingMap[key] = append(contextAndViewBindingMap[key], binding)
- }
- }
- }
-
- type groupedBindingsType struct {
- contextAndView contextAndViewType
- bindings []*gui.Binding
- }
-
- groupedBindings := make([]groupedBindingsType, len(contextAndViewBindingMap))
-
- for contextAndView, contextBindings := range contextAndViewBindingMap {
- groupedBindings = append(groupedBindings, groupedBindingsType{contextAndView: contextAndView, bindings: contextBindings})
- }
-
- sort.Slice(groupedBindings, func(i, j int) bool {
- first := groupedBindings[i].contextAndView
- second := groupedBindings[j].contextAndView
- if first.title == "" {
- return true
- }
- if second.title == "" {
+func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*bindingSection {
+ excludedViews := []string{"stagingSecondary", "patchBuildingSecondary"}
+ bindingsToDisplay := slices.Filter(bindings, func(binding *types.Binding) bool {
+ if lo.Contains(excludedViews, binding.ViewName) {
return false
}
- if first.title == "navigation" {
- return true
- }
- if second.title == "navigation" {
- return false
- }
- return first.title < second.title || (first.title == second.title && first.subtitle < second.subtitle)
+
+ return (binding.Description != "" || binding.Alternative != "")
})
- for _, group := range groupedBindings {
- contextAndView := group.contextAndView
- contextBindings := group.bindings
- mApp.Log.Info("viewname: " + contextAndView.title + ", context: " + contextAndView.subtitle)
- viewName := contextAndView.title
- if viewName == "" {
- viewName = "global"
- }
- translatedView := localisedTitle(mApp, viewName)
- var title string
- if contextAndView.subtitle == "" {
- addendum := " " + mApp.Tr.Panel
- if viewName == "global" || viewName == "navigation" {
- addendum = ""
+ bindingsByHeader := lo.GroupBy(bindingsToDisplay, func(binding *types.Binding) header {
+ return getHeader(binding, tr)
+ })
+
+ bindingGroups := maps.MapToSlice(
+ bindingsByHeader,
+ func(header header, hBindings []*types.Binding) headerWithBindings {
+ uniqBindings := lo.UniqBy(hBindings, func(binding *types.Binding) string {
+ return binding.Description + keybindings.LabelFromKey(binding.Key)
+ })
+
+ return headerWithBindings{
+ header: header,
+ bindings: uniqBindings,
}
- title = fmt.Sprintf("%s%s", translatedView, addendum)
- } else {
- translatedContextName := localisedTitle(mApp, contextAndView.subtitle)
- title = fmt.Sprintf("%s %s (%s)", translatedView, mApp.Tr.Panel, translatedContextName)
- }
+ },
+ )
- for _, binding := range contextBindings {
- bindingSections = addBinding(title, bindingSections, binding)
+ slices.SortFunc(bindingGroups, func(a, b headerWithBindings) bool {
+ if a.header.priority != b.header.priority {
+ return a.header.priority > b.header.priority
}
- }
+ return a.header.title < b.header.title
+ })
- return bindingSections
+ return slices.Map(bindingGroups, func(hb headerWithBindings) *bindingSection {
+ return &bindingSection{
+ title: hb.header.title,
+ bindings: hb.bindings,
+ }
+ })
}
-func addBinding(title string, bindingSections []*bindingSection, binding *gui.Binding) []*bindingSection {
- if binding.Description == "" && binding.Alternative == "" {
- return bindingSections
+func getHeader(binding *types.Binding, tr *i18n.TranslationSet) header {
+ if binding.Tag == "navigation" {
+ return header{priority: 2, title: localisedTitle(tr, "navigation")}
}
- for _, section := range bindingSections {
- if title == section.title {
- section.bindings = append(section.bindings, binding)
- return bindingSections
- }
+ if binding.ViewName == "" {
+ return header{priority: 3, title: localisedTitle(tr, "global")}
}
- section := &bindingSection{
- title: title,
- bindings: []*gui.Binding{binding},
- }
-
- return append(bindingSections, section)
+ return header{priority: 1, title: localisedTitle(tr, binding.ViewName)}
}
func formatSections(tr *i18n.TranslationSet, bindingSections []*bindingSection) string {
@@ -263,3 +193,19 @@ func formatSections(tr *i18n.TranslationSet, bindingSections []*bindingSection)
return content
}
+
+func formatTitle(title string) string {
+ return fmt.Sprintf("\n## %s\n\n", title)
+}
+
+func formatBinding(binding *types.Binding) string {
+ if binding.Alternative != "" {
+ return fmt.Sprintf(
+ " %s: %s (%s)\n",
+ keybindings.LabelFromKey(binding.Key),
+ binding.Description,
+ binding.Alternative,
+ )
+ }
+ return fmt.Sprintf(" %s: %s\n", keybindings.LabelFromKey(binding.Key), binding.Description)
+}
diff --git a/pkg/cheatsheet/generate_test.go b/pkg/cheatsheet/generate_test.go
new file mode 100644
index 000000000..44c49a461
--- /dev/null
+++ b/pkg/cheatsheet/generate_test.go
@@ -0,0 +1,240 @@
+package cheatsheet
+
+import (
+ "testing"
+
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/i18n"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetBindingSections(t *testing.T) {
+ tr := i18n.EnglishTranslationSet()
+
+ tests := []struct {
+ testName string
+ bindings []*types.Binding
+ expected []*bindingSection
+ }{
+ {
+ testName: "no bindings",
+ bindings: []*types.Binding{},
+ expected: []*bindingSection{},
+ },
+ {
+ testName: "one binding",
+ bindings: []*types.Binding{
+ {
+ ViewName: "files",
+ Description: "stage file",
+ },
+ },
+ expected: []*bindingSection{
+ {
+ title: "Files",
+ bindings: []*types.Binding{
+ {
+ ViewName: "files",
+ Description: "stage file",
+ },
+ },
+ },
+ },
+ },
+ {
+ testName: "global binding",
+ bindings: []*types.Binding{
+ {
+ ViewName: "",
+ Description: "quit",
+ },
+ },
+ expected: []*bindingSection{
+ {
+ title: "Global Keybindings",
+ bindings: []*types.Binding{
+ {
+ ViewName: "",
+ Description: "quit",
+ },
+ },
+ },
+ },
+ },
+ {
+ testName: "grouped bindings",
+ bindings: []*types.Binding{
+ {
+ ViewName: "files",
+ Description: "stage file",
+ },
+ {
+ ViewName: "files",
+ Description: "unstage file",
+ },
+ {
+ ViewName: "submodules",
+ Description: "drop submodule",
+ },
+ },
+ expected: []*bindingSection{
+ {
+ title: "Files",
+ bindings: []*types.Binding{
+ {
+ ViewName: "files",
+ Description: "stage file",
+ },
+ {
+ ViewName: "files",
+ Description: "unstage file",
+ },
+ },
+ },
+ {
+ title: "Submodules",
+ bindings: []*types.Binding{
+ {
+ ViewName: "submodules",
+ Description: "drop submodule",
+ },
+ },
+ },
+ },
+ },
+ {
+ testName: "with navigation bindings",
+ bindings: []*types.Binding{
+ {
+ ViewName: "files",
+ Description: "stage file",
+ },
+ {
+ ViewName: "files",
+ Description: "unstage file",
+ },
+ {
+ ViewName: "files",
+ Description: "scroll",
+ Tag: "navigation",
+ },
+ {
+ ViewName: "commits",
+ Description: "revert commit",
+ },
+ },
+ expected: []*bindingSection{
+ {
+ title: "List Panel Navigation",
+ bindings: []*types.Binding{
+ {
+ ViewName: "files",
+ Description: "scroll",
+ Tag: "navigation",
+ },
+ },
+ },
+ {
+ title: "Commits",
+ bindings: []*types.Binding{
+ {
+ ViewName: "commits",
+ Description: "revert commit",
+ },
+ },
+ },
+ {
+ title: "Files",
+ bindings: []*types.Binding{
+ {
+ ViewName: "files",
+ Description: "stage file",
+ },
+ {
+ ViewName: "files",
+ Description: "unstage file",
+ },
+ },
+ },
+ },
+ },
+ {
+ testName: "with duplicate navigation bindings",
+ bindings: []*types.Binding{
+ {
+ ViewName: "files",
+ Description: "stage file",
+ },
+ {
+ ViewName: "files",
+ Description: "unstage file",
+ },
+ {
+ ViewName: "files",
+ Description: "scroll",
+ Tag: "navigation",
+ },
+ {
+ ViewName: "commits",
+ Description: "revert commit",
+ },
+ {
+ ViewName: "commits",
+ Description: "scroll",
+ Tag: "navigation",
+ },
+ {
+ ViewName: "commits",
+ Description: "page up",
+ Tag: "navigation",
+ },
+ },
+ expected: []*bindingSection{
+ {
+ title: "List Panel Navigation",
+ bindings: []*types.Binding{
+ {
+ ViewName: "files",
+ Description: "scroll",
+ Tag: "navigation",
+ },
+ {
+ ViewName: "commits",
+ Description: "page up",
+ Tag: "navigation",
+ },
+ },
+ },
+ {
+ title: "Commits",
+ bindings: []*types.Binding{
+ {
+ ViewName: "commits",
+ Description: "revert commit",
+ },
+ },
+ },
+ {
+ title: "Files",
+ bindings: []*types.Binding{
+ {
+ ViewName: "files",
+ Description: "stage file",
+ },
+ {
+ ViewName: "files",
+ Description: "unstage file",
+ },
+ },
+ },
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.testName, func(t *testing.T) {
+ actual := getBindingSections(test.bindings, &tr)
+ assert.EqualValues(t, test.expected, actual)
+ })
+ }
+}
diff --git a/pkg/commands/git.go b/pkg/commands/git.go
index 31ba2f42a..0e1c27219 100644
--- a/pkg/commands/git.go
+++ b/pkg/commands/git.go
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/go-errors/errors"
+ "github.com/sasha-s/go-deadlock"
gogit "github.com/jesseduffield/go-git/v5"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
@@ -57,12 +58,13 @@ func NewGitCommand(
cmn *common.Common,
osCommand *oscommands.OSCommand,
gitConfig git_config.IGitConfig,
+ syncMutex *deadlock.Mutex,
) (*GitCommand, error) {
if err := navigateToRepoRootDirectory(os.Stat, os.Chdir); err != nil {
return nil, err
}
- repo, err := setupRepository(gogit.PlainOpen, cmn.Tr.GitconfigParseErr)
+ repo, err := setupRepository(gogit.PlainOpenWithOptions, gogit.PlainOpenOptions{DetectDotGit: false, EnableDotGitCommonDir: true}, cmn.Tr.GitconfigParseErr)
if err != nil {
return nil, err
}
@@ -78,6 +80,7 @@ func NewGitCommand(
gitConfig,
dotGitDir,
repo,
+ syncMutex,
), nil
}
@@ -87,6 +90,7 @@ func NewGitCommandAux(
gitConfig git_config.IGitConfig,
dotGitDir string,
repo *gogit.Repository,
+ syncMutex *deadlock.Mutex,
) *GitCommand {
cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd)
@@ -96,7 +100,7 @@ func NewGitCommandAux(
// on the one struct.
// common ones are: cmn, osCommand, dotGitDir, configCommands
configCommands := git_commands.NewConfigCommands(cmn, gitConfig, repo)
- gitCommon := git_commands.NewGitCommon(cmn, cmd, osCommand, dotGitDir, repo, configCommands)
+ gitCommon := git_commands.NewGitCommon(cmn, cmd, osCommand, dotGitDir, repo, configCommands, syncMutex)
statusCommands := git_commands.NewStatusCommands(gitCommon)
fileLoader := loaders.NewFileLoader(cmn, cmd, configCommands)
@@ -206,7 +210,7 @@ func resolvePath(path string) (string, error) {
return filepath.EvalSymlinks(path)
}
-func setupRepository(openGitRepository func(string) (*gogit.Repository, error), gitConfigParseErrorStr string) (*gogit.Repository, error) {
+func setupRepository(openGitRepository func(string, *gogit.PlainOpenOptions) (*gogit.Repository, error), options gogit.PlainOpenOptions, gitConfigParseErrorStr string) (*gogit.Repository, error) {
unresolvedPath := env.GetGitDirEnv()
if unresolvedPath == "" {
var err error
@@ -221,8 +225,7 @@ func setupRepository(openGitRepository func(string) (*gogit.Repository, error),
return nil, err
}
- repository, err := openGitRepository(path)
-
+ repository, err := openGitRepository(path, &options)
if err != nil {
if strings.Contains(err.Error(), `unquoted '\' must be followed by new line`) {
return nil, errors.New(gitConfigParseErrorStr)
@@ -254,7 +257,7 @@ func findDotGitDir(stat func(string) (os.FileInfo, error), readFile func(filenam
}
fileContent := string(fileBytes)
if !strings.HasPrefix(fileContent, "gitdir: ") {
- return "", errors.New(".git is a file which suggests we are in a submodule but the file's contents do not contain a gitdir pointing to the actual .git directory")
+ return "", errors.New(".git is a file which suggests we are in a submodule or a worktree but the file's contents do not contain a gitdir pointing to the actual .git directory")
}
return strings.TrimSpace(strings.TrimPrefix(fileContent, "gitdir: ")), nil
}
diff --git a/pkg/commands/git_commands/bisect_info.go b/pkg/commands/git_commands/bisect_info.go
index 5b3b1f028..ea20d0d38 100644
--- a/pkg/commands/git_commands/bisect_info.go
+++ b/pkg/commands/git_commands/bisect_info.go
@@ -1,6 +1,10 @@
package git_commands
-import "github.com/sirupsen/logrus"
+import (
+ "github.com/jesseduffield/generics/maps"
+ "github.com/jesseduffield/generics/slices"
+ "github.com/sirupsen/logrus"
+)
// although the typical terms in a git bisect are 'bad' and 'good', they're more
// generally known as 'new' and 'old'. Semi-recently git allowed the user to define
@@ -93,11 +97,5 @@ func (self *BisectInfo) Bisecting() bool {
return false
}
- for _, status := range self.statusMap {
- if status == BisectStatusOld {
- return true
- }
- }
-
- return false
+ return slices.Contains(maps.Values(self.statusMap), BisectStatusOld)
}
diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go
index f6b084c9f..d8be71deb 100644
--- a/pkg/commands/git_commands/branch.go
+++ b/pkg/commands/git_commands/branch.go
@@ -11,7 +11,7 @@ import (
// this takes something like:
// * (HEAD detached at 264fc6f5)
-// remotes
+// remotes
// and returns '264fc6f5' as the second match
const CurrentBranchNameRegex = `(?m)^\*.*?([^ ]*?)\)?$`
@@ -109,6 +109,10 @@ func (self *BranchCommands) SetUpstream(remoteName string, remoteBranchName stri
return self.cmd.New(fmt.Sprintf("git branch --set-upstream-to=%s/%s %s", self.cmd.Quote(remoteName), self.cmd.Quote(remoteBranchName), self.cmd.Quote(branchName))).Run()
}
+func (self *BranchCommands) UnsetUpstream(branchName string) error {
+ return self.cmd.New(fmt.Sprintf("git branch --unset-upstream %s", self.cmd.Quote(branchName))).Run()
+}
+
func (self *BranchCommands) GetCurrentBranchUpstreamDifferenceCount() (string, string) {
return self.GetCommitDifferences("HEAD", "HEAD@{u}")
}
@@ -142,7 +146,7 @@ func (self *BranchCommands) Rename(oldName string, newName string) error {
}
func (self *BranchCommands) GetRawBranches() (string, error) {
- return self.cmd.New(`git for-each-ref --sort=-committerdate --format="%(HEAD)|%(refname:short)|%(upstream:short)|%(upstream:track)" refs/heads`).DontLog().RunWithOutput()
+ return self.cmd.New(`git for-each-ref --sort=-committerdate --format="%(HEAD)%00%(refname:short)%00%(upstream:short)%00%(upstream:track)" refs/heads`).DontLog().RunWithOutput()
}
type MergeOpts struct {
diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go
index 38f50bdda..444ec17ac 100644
--- a/pkg/commands/git_commands/commit.go
+++ b/pkg/commands/git_commands/commit.go
@@ -4,6 +4,7 @@ import (
"fmt"
"strings"
+ "github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
)
@@ -22,6 +23,17 @@ func (self *CommitCommands) RewordLastCommit(message string) error {
return self.cmd.New("git commit --allow-empty --amend --only -m " + self.cmd.Quote(message)).Run()
}
+// ResetAuthor resets the author of the topmost commit
+func (self *CommitCommands) ResetAuthor() error {
+ return self.cmd.New("git commit --allow-empty --only --no-edit --amend --reset-author").Run()
+}
+
+// Sets the commit's author to the supplied value. Value is expected to be of the form 'Name '
+func (self *CommitCommands) SetAuthor(value string) error {
+ commandStr := fmt.Sprintf("git commit --allow-empty --only --no-edit --amend --author=%s", self.cmd.Quote(value))
+ return self.cmd.New(commandStr).Run()
+}
+
// ResetToCommit reset to commit
func (self *CommitCommands) ResetToCommit(sha string, strength string, envVars []string) error {
return self.cmd.New(fmt.Sprintf("git reset --%s %s", strength, sha)).
@@ -70,10 +82,37 @@ func (self *CommitCommands) GetHeadCommitMessage() (string, error) {
func (self *CommitCommands) GetCommitMessage(commitSha string) (string, error) {
cmdStr := "git rev-list --format=%B --max-count=1 " + commitSha
messageWithHeader, err := self.cmd.New(cmdStr).DontLog().RunWithOutput()
- message := strings.Join(strings.SplitAfter(messageWithHeader, "\n")[1:], "\n")
+ message := strings.Join(strings.SplitAfter(messageWithHeader, "\n")[1:], "")
return strings.TrimSpace(message), err
}
+func (self *CommitCommands) GetCommitDiff(commitSha string) (string, error) {
+ cmdStr := "git show --no-color " + commitSha
+ diff, err := self.cmd.New(cmdStr).DontLog().RunWithOutput()
+ return diff, err
+}
+
+type Author struct {
+ Name string
+ Email string
+}
+
+func (self *CommitCommands) GetCommitAuthor(commitSha string) (Author, error) {
+ cmdStr := "git show --no-patch --pretty=format:'%an%x00%ae' " + commitSha
+ output, err := self.cmd.New(cmdStr).DontLog().RunWithOutput()
+ if err != nil {
+ return Author{}, err
+ }
+
+ split := strings.SplitN(strings.TrimSpace(output), "\x00", 2)
+ if len(split) < 2 {
+ return Author{}, errors.New("unexpected git output")
+ }
+
+ author := Author{Name: split[0], Email: split[1]}
+ return author, err
+}
+
func (self *CommitCommands) GetCommitMessageFirstLine(sha string) (string, error) {
return self.GetCommitMessagesFirstLine([]string{sha})
}
diff --git a/pkg/commands/git_commands/commit_test.go b/pkg/commands/git_commands/commit_test.go
index 08ee7e5cc..96a46ecf4 100644
--- a/pkg/commands/git_commands/commit_test.go
+++ b/pkg/commands/git_commands/commit_test.go
@@ -161,3 +161,49 @@ func TestCommitShowCmdObj(t *testing.T) {
})
}
}
+
+func TestGetCommitMsg(t *testing.T) {
+ type scenario struct {
+ testName string
+ input string
+ expectedOutput string
+ }
+ scenarios := []scenario{
+ {
+ "empty",
+ ` commit deadbeef`,
+ ``,
+ },
+ {
+ "no line breaks (single line)",
+ `commit deadbeef
+use generics to DRY up context code`,
+ `use generics to DRY up context code`,
+ },
+ {
+ "with line breaks",
+ `commit deadbeef
+Merge pull request #1750 from mark2185/fix-issue-template
+
+'git-rev parse' should be 'git rev-parse'`,
+ `Merge pull request #1750 from mark2185/fix-issue-template
+
+'git-rev parse' should be 'git rev-parse'`,
+ },
+ }
+
+ for _, s := range scenarios {
+ s := s
+ t.Run(s.testName, func(t *testing.T) {
+ instance := buildCommitCommands(commonDeps{
+ runner: oscommands.NewFakeRunner(t).Expect("git rev-list --format=%B --max-count=1 deadbeef", s.input, nil),
+ })
+
+ output, err := instance.GetCommitMessage("deadbeef")
+
+ assert.NoError(t, err)
+
+ assert.Equal(t, s.expectedOutput, output)
+ })
+ }
+}
diff --git a/pkg/commands/git_commands/common.go b/pkg/commands/git_commands/common.go
index a045be75a..09694110d 100644
--- a/pkg/commands/git_commands/common.go
+++ b/pkg/commands/git_commands/common.go
@@ -4,6 +4,7 @@ import (
gogit "github.com/jesseduffield/go-git/v5"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
+ "github.com/sasha-s/go-deadlock"
)
type GitCommon struct {
@@ -13,6 +14,8 @@ type GitCommon struct {
dotGitDir string
repo *gogit.Repository
config *ConfigCommands
+ // mutex for doing things like push/pull/fetch
+ syncMutex *deadlock.Mutex
}
func NewGitCommon(
@@ -22,6 +25,7 @@ func NewGitCommon(
dotGitDir string,
repo *gogit.Repository,
config *ConfigCommands,
+ syncMutex *deadlock.Mutex,
) *GitCommon {
return &GitCommon{
Common: cmn,
@@ -30,5 +34,6 @@ func NewGitCommon(
dotGitDir: dotGitDir,
repo: repo,
config: config,
+ syncMutex: syncMutex,
}
}
diff --git a/pkg/commands/git_commands/file.go b/pkg/commands/git_commands/file.go
index 026d79cb0..898c26e33 100644
--- a/pkg/commands/git_commands/file.go
+++ b/pkg/commands/git_commands/file.go
@@ -58,5 +58,17 @@ func (self *FileCommands) GetEditCmdStr(filename string, lineNumber int) (string
}
editCmdTemplate := self.UserConfig.OS.EditCommandTemplate
+ if len(editCmdTemplate) == 0 {
+ switch editor {
+ case "emacs", "nano", "vi", "vim", "nvim":
+ editCmdTemplate = "{{editor}} +{{line}} -- {{filename}}"
+ case "subl":
+ editCmdTemplate = "{{editor}} -- {{filename}}:{{line}}"
+ case "code":
+ editCmdTemplate = "{{editor}} -r --goto -- {{filename}}:{{line}}"
+ default:
+ editCmdTemplate = "{{editor}} -- {{filename}}"
+ }
+ }
return utils.ResolvePlaceholderString(editCmdTemplate, templateValues), nil
}
diff --git a/pkg/commands/git_commands/file_test.go b/pkg/commands/git_commands/file_test.go
index a26699b3e..4b9128dfe 100644
--- a/pkg/commands/git_commands/file_test.go
+++ b/pkg/commands/git_commands/file_test.go
@@ -79,6 +79,7 @@ func TestEditFileCmdStr(t *testing.T) {
gitConfigMockResponses: nil,
test: func(cmdStr string, err error) {
assert.NoError(t, err)
+ assert.Equal(t, `nano "test"`, cmdStr)
},
},
{
@@ -143,6 +144,20 @@ func TestEditFileCmdStr(t *testing.T) {
assert.Equal(t, `vim +1 "open file/at line"`, cmdStr)
},
},
+ {
+ filename: "default edit command template",
+ configEditCommand: "vim",
+ configEditCommandTemplate: "",
+ runner: oscommands.NewFakeRunner(t),
+ getenv: func(env string) string {
+ return ""
+ },
+ gitConfigMockResponses: nil,
+ test: func(cmdStr string, err error) {
+ assert.NoError(t, err)
+ assert.Equal(t, `vim +1 -- "default edit command template"`, cmdStr)
+ },
+ },
}
for _, s := range scenarios {
diff --git a/pkg/commands/git_commands/patch.go b/pkg/commands/git_commands/patch.go
index 8f9ce5784..a805c7f21 100644
--- a/pkg/commands/git_commands/patch.go
+++ b/pkg/commands/git_commands/patch.go
@@ -105,16 +105,16 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s
}
baseIndex := sourceCommitIdx + 1
- todo := ""
- for i, commit := range commits[0:baseIndex] {
- a := "pick"
- if i == sourceCommitIdx || i == destinationCommitIdx {
- a = "edit"
- }
- todo = a + " " + commit.Sha + " " + commit.Name + "\n" + todo
- }
- err := self.rebase.PrepareInteractiveRebaseCommand(commits[baseIndex].Sha, todo, true).Run()
+ todoLines := self.rebase.BuildTodoLines(commits[0:baseIndex], func(commit *models.Commit, i int) string {
+ if i == sourceCommitIdx || i == destinationCommitIdx {
+ return "edit"
+ } else {
+ return "pick"
+ }
+ })
+
+ err := self.rebase.PrepareInteractiveRebaseCommand(commits[baseIndex].Sha, todoLines, true).Run()
if err != nil {
return err
}
diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go
index c726cad7e..48a613e41 100644
--- a/pkg/commands/git_commands/rebase.go
+++ b/pkg/commands/git_commands/rebase.go
@@ -7,6 +7,8 @@ import (
"strings"
"github.com/go-errors/errors"
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/app/daemon"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
)
@@ -52,7 +54,7 @@ func (self *RebaseCommands) RewordCommit(commits []*models.Commit, index int, me
}
func (self *RebaseCommands) RewordCommitInEditor(commits []*models.Commit, index int) (oscommands.ICmdObj, error) {
- todo, sha, err := self.GenerateGenericRebaseTodo(commits, index, "reword")
+ todo, sha, err := self.BuildSingleActionTodo(commits, index, "reword")
if err != nil {
return nil, err
}
@@ -60,6 +62,38 @@ func (self *RebaseCommands) RewordCommitInEditor(commits []*models.Commit, index
return self.PrepareInteractiveRebaseCommand(sha, todo, false), nil
}
+func (self *RebaseCommands) ResetCommitAuthor(commits []*models.Commit, index int) error {
+ return self.GenericAmend(commits, index, func() error {
+ return self.commit.ResetAuthor()
+ })
+}
+
+func (self *RebaseCommands) SetCommitAuthor(commits []*models.Commit, index int, value string) error {
+ return self.GenericAmend(commits, index, func() error {
+ return self.commit.SetAuthor(value)
+ })
+}
+
+func (self *RebaseCommands) GenericAmend(commits []*models.Commit, index int, f func() error) error {
+ if index == 0 {
+ // we've selected the top commit so no rebase is required
+ return f()
+ }
+
+ err := self.BeginInteractiveRebaseForCommit(commits, index)
+ if err != nil {
+ return err
+ }
+
+ // now the selected commit should be our head so we'll amend it
+ err = f()
+ if err != nil {
+ return err
+ }
+
+ return self.ContinueRebase()
+}
+
func (self *RebaseCommands) MoveCommitDown(commits []*models.Commit, index int) error {
// we must ensure that we have at least two commits after the selected one
if len(commits) <= index+2 {
@@ -67,17 +101,15 @@ func (self *RebaseCommands) MoveCommitDown(commits []*models.Commit, index int)
return errors.New(self.Tr.NoRoom)
}
- todo := ""
orderedCommits := append(commits[0:index], commits[index+1], commits[index])
- for _, commit := range orderedCommits {
- todo = "pick " + commit.Sha + " " + commit.Name + "\n" + todo
- }
- return self.PrepareInteractiveRebaseCommand(commits[index+2].Sha, todo, true).Run()
+ todoLines := self.BuildTodoLinesSingleAction(orderedCommits, "pick")
+
+ return self.PrepareInteractiveRebaseCommand(commits[index+2].Sha, todoLines, true).Run()
}
func (self *RebaseCommands) InteractiveRebase(commits []*models.Commit, index int, action string) error {
- todo, sha, err := self.GenerateGenericRebaseTodo(commits, index, action)
+ todo, sha, err := self.BuildSingleActionTodo(commits, index, action)
if err != nil {
return err
}
@@ -88,7 +120,8 @@ func (self *RebaseCommands) InteractiveRebase(commits []*models.Commit, index in
// PrepareInteractiveRebaseCommand returns the cmd for an interactive rebase
// we tell git to run lazygit to edit the todo list, and we pass the client
// lazygit a todo string to write to the todo file
-func (self *RebaseCommands) PrepareInteractiveRebaseCommand(baseSha string, todo string, overrideEditor bool) oscommands.ICmdObj {
+func (self *RebaseCommands) PrepareInteractiveRebaseCommand(baseSha string, todoLines []TodoLine, overrideEditor bool) oscommands.ICmdObj {
+ todo := self.buildTodo(todoLines)
ex := oscommands.GetLazygitPath()
debug := "FALSE"
@@ -97,7 +130,7 @@ func (self *RebaseCommands) PrepareInteractiveRebaseCommand(baseSha string, todo
}
cmdStr := fmt.Sprintf("git rebase --interactive --autostash --keep-empty %s", baseSha)
- self.Log.WithField("command", cmdStr).Info("RunCommand")
+ self.Log.WithField("command", cmdStr).Debug("RunCommand")
cmdObj := self.cmd.New(cmdStr)
@@ -109,8 +142,8 @@ func (self *RebaseCommands) PrepareInteractiveRebaseCommand(baseSha string, todo
}
cmdObj.AddEnvVars(
- "LAZYGIT_CLIENT_COMMAND=INTERACTIVE_REBASE",
- "LAZYGIT_REBASE_TODO="+todo,
+ daemon.DaemonKindEnvKey+"="+string(daemon.InteractiveRebase),
+ daemon.RebaseTODOEnvKey+"="+todo,
"DEBUG="+debug,
"LANG=en_US.UTF-8", // Force using EN as language
"LC_ALL=en_US.UTF-8", // Force using EN as language
@@ -124,38 +157,37 @@ func (self *RebaseCommands) PrepareInteractiveRebaseCommand(baseSha string, todo
return cmdObj
}
-func (self *RebaseCommands) GenerateGenericRebaseTodo(commits []*models.Commit, actionIndex int, action string) (string, string, error) {
+// produces TodoLines where every commit is picked (or dropped for merge commits) except for the commit at the given index, which
+// will have the given action applied to it.
+func (self *RebaseCommands) BuildSingleActionTodo(commits []*models.Commit, actionIndex int, action string) ([]TodoLine, string, error) {
baseIndex := actionIndex + 1
if len(commits) <= baseIndex {
- return "", "", errors.New(self.Tr.CannotRebaseOntoFirstCommit)
+ return nil, "", errors.New(self.Tr.CannotRebaseOntoFirstCommit)
}
if action == "squash" || action == "fixup" {
baseIndex++
if len(commits) <= baseIndex {
- return "", "", errors.New(self.Tr.CannotSquashOntoSecondCommit)
+ return nil, "", errors.New(self.Tr.CannotSquashOntoSecondCommit)
}
}
- todo := ""
- for i, commit := range commits[0:baseIndex] {
- var commitAction string
+ todoLines := self.BuildTodoLines(commits[0:baseIndex], func(commit *models.Commit, i int) string {
if i == actionIndex {
- commitAction = action
+ return action
} else if commit.IsMerge() {
// your typical interactive rebase will actually drop merge commits by default. Damn git CLI, you scary!
// doing this means we don't need to worry about rebasing over merges which always causes problems.
// you typically shouldn't be doing rebases that pass over merge commits anyway.
- commitAction = "drop"
+ return "drop"
} else {
- commitAction = "pick"
+ return "pick"
}
- todo = commitAction + " " + commit.Sha + " " + commit.Name + "\n" + todo
- }
+ })
- return todo, commits[baseIndex].Sha, nil
+ return todoLines, commits[baseIndex].Sha, nil
}
// AmendTo amends the given commit with whatever files are staged
@@ -185,7 +217,7 @@ func (self *RebaseCommands) EditRebaseTodo(index int, action string) error {
content[contentIndex] = action + " " + strings.Join(splitLine[1:], " ")
result := strings.Join(content, "\n")
- return ioutil.WriteFile(fileName, []byte(result), 0644)
+ return ioutil.WriteFile(fileName, []byte(result), 0o644)
}
func (self *RebaseCommands) getTodoCommitCount(content []string) int {
@@ -215,7 +247,7 @@ func (self *RebaseCommands) MoveTodoDown(index int) error {
rearrangedContent = append(rearrangedContent, content[contentIndex+1:]...)
result := strings.Join(rearrangedContent, "\n")
- return ioutil.WriteFile(fileName, []byte(result), 0644)
+ return ioutil.WriteFile(fileName, []byte(result), 0o644)
}
// SquashAllAboveFixupCommits squashes all fixup! commits above the given one
@@ -244,7 +276,7 @@ func (self *RebaseCommands) BeginInteractiveRebaseForCommit(commits []*models.Co
return errors.New(self.Tr.DisabledForGPG)
}
- todo, sha, err := self.GenerateGenericRebaseTodo(commits, commitIndex, "edit")
+ todo, sha, err := self.BuildSingleActionTodo(commits, commitIndex, "edit")
if err != nil {
return err
}
@@ -254,7 +286,7 @@ func (self *RebaseCommands) BeginInteractiveRebaseForCommit(commits []*models.Co
// RebaseBranch interactive rebases onto a branch
func (self *RebaseCommands) RebaseBranch(branchName string) error {
- return self.PrepareInteractiveRebaseCommand(branchName, "", false).Run()
+ return self.PrepareInteractiveRebaseCommand(branchName, nil, false).Run()
}
func (self *RebaseCommands) GenericMergeOrRebaseActionCmdObj(commandType string, command string) oscommands.ICmdObj {
@@ -298,7 +330,7 @@ func (self *RebaseCommands) runSkipEditorCommand(cmdObj oscommands.ICmdObj) erro
lazyGitPath := oscommands.GetLazygitPath()
return cmdObj.
AddEnvVars(
- "LAZYGIT_CLIENT_COMMAND=EXIT_IMMEDIATELY",
+ daemon.DaemonKindEnvKey+"="+string(daemon.ExitImmediately),
"GIT_EDITOR="+lazyGitPath,
"EDITOR="+lazyGitPath,
"VISUAL="+lazyGitPath,
@@ -336,10 +368,36 @@ func (self *RebaseCommands) DiscardOldFileChanges(commits []*models.Commit, comm
// CherryPickCommits begins an interactive rebase with the given shas being cherry picked onto HEAD
func (self *RebaseCommands) CherryPickCommits(commits []*models.Commit) error {
- todo := ""
- for _, commit := range commits {
- todo = "pick " + commit.Sha + " " + commit.Name + "\n" + todo
- }
+ todoLines := self.BuildTodoLinesSingleAction(commits, "pick")
- return self.PrepareInteractiveRebaseCommand("HEAD", todo, false).Run()
+ return self.PrepareInteractiveRebaseCommand("HEAD", todoLines, false).Run()
+}
+
+func (self *RebaseCommands) buildTodo(todoLines []TodoLine) string {
+ lines := slices.Map(todoLines, func(todoLine TodoLine) string {
+ return todoLine.ToString()
+ })
+
+ return strings.Join(slices.Reverse(lines), "")
+}
+
+func (self *RebaseCommands) BuildTodoLines(commits []*models.Commit, f func(*models.Commit, int) string) []TodoLine {
+ return slices.MapWithIndex(commits, func(commit *models.Commit, i int) TodoLine {
+ return TodoLine{Action: f(commit, i), Commit: commit}
+ })
+}
+
+func (self *RebaseCommands) BuildTodoLinesSingleAction(commits []*models.Commit, action string) []TodoLine {
+ return self.BuildTodoLines(commits, func(commit *models.Commit, i int) string {
+ return action
+ })
+}
+
+type TodoLine struct {
+ Action string
+ Commit *models.Commit
+}
+
+func (self *TodoLine) ToString() string {
+ return self.Action + " " + self.Commit.Sha + " " + self.Commit.Name + "\n"
}
diff --git a/pkg/commands/git_commands/rebase_test.go b/pkg/commands/git_commands/rebase_test.go
index 56df77a86..c4d18000f 100644
--- a/pkg/commands/git_commands/rebase_test.go
+++ b/pkg/commands/git_commands/rebase_test.go
@@ -5,10 +5,11 @@ import (
"testing"
"github.com/go-errors/errors"
+ "github.com/jesseduffield/lazygit/pkg/app/daemon"
"github.com/jesseduffield/lazygit/pkg/commands/git_config"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
- "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
@@ -61,10 +62,10 @@ func TestRebaseSkipEditorCommand(t *testing.T) {
`^VISUAL=.*$`,
`^EDITOR=.*$`,
`^GIT_EDITOR=.*$`,
- "^LAZYGIT_CLIENT_COMMAND=EXIT_IMMEDIATELY$",
+ "^" + daemon.DaemonKindEnvKey + "=" + string(daemon.ExitImmediately) + "$",
} {
regexStr := regexStr
- foundMatch := utils.IncludesStringFunc(envVars, func(envVar string) bool {
+ foundMatch := lo.ContainsBy(envVars, func(envVar string) bool {
return regexp.MustCompile(regexStr).MatchString(envVar)
})
if !foundMatch {
diff --git a/pkg/commands/git_commands/remote.go b/pkg/commands/git_commands/remote.go
index 3116c764a..1245a8cf0 100644
--- a/pkg/commands/git_commands/remote.go
+++ b/pkg/commands/git_commands/remote.go
@@ -40,7 +40,7 @@ func (self *RemoteCommands) UpdateRemoteUrl(remoteName string, updatedUrl string
func (self *RemoteCommands) DeleteRemoteBranch(remoteName string, branchName string) error {
command := fmt.Sprintf("git push %s --delete %s", self.cmd.Quote(remoteName), self.cmd.Quote(branchName))
- return self.cmd.New(command).PromptOnCredentialRequest().Run()
+ return self.cmd.New(command).PromptOnCredentialRequest().WithMutex(self.syncMutex).Run()
}
// CheckRemoteBranchExists Returns remote branch
diff --git a/pkg/commands/git_commands/stash.go b/pkg/commands/git_commands/stash.go
index d20024aa9..c0d187a13 100644
--- a/pkg/commands/git_commands/stash.go
+++ b/pkg/commands/git_commands/stash.go
@@ -25,6 +25,10 @@ func NewStashCommands(
}
}
+func (self *StashCommands) DropNewest() error {
+ return self.cmd.New("git stash drop").Run()
+}
+
func (self *StashCommands) Drop(index int) error {
return self.cmd.New(fmt.Sprintf("git stash drop stash@{%d}", index)).Run()
}
@@ -38,7 +42,6 @@ func (self *StashCommands) Apply(index int) error {
}
// Save save stash
-// TODO: before calling this, check if there is anything to save
func (self *StashCommands) Save(message string) error {
return self.cmd.New("git stash save " + self.cmd.Quote(message)).Run()
}
@@ -49,6 +52,23 @@ func (self *StashCommands) ShowStashEntryCmdObj(index int) oscommands.ICmdObj {
return self.cmd.New(cmdStr).DontLog()
}
+func (self *StashCommands) StashAndKeepIndex(message string) error {
+ return self.cmd.New(fmt.Sprintf("git stash save %s --keep-index", self.cmd.Quote(message))).Run()
+}
+
+func (self *StashCommands) StashUnstagedChanges(message string) error {
+ if err := self.cmd.New("git commit --no-verify -m \"[lazygit] stashing unstaged changes\"").Run(); err != nil {
+ return err
+ }
+ if err := self.Save(message); err != nil {
+ return err
+ }
+ if err := self.cmd.New("git reset --soft HEAD^").Run(); err != nil {
+ return err
+ }
+ return nil
+}
+
// SaveStagedChanges stashes only the currently staged changes. This takes a few steps
// shoutouts to Joe on https://stackoverflow.com/questions/14759748/stashing-only-staged-changes-in-git-is-it-possible
func (self *StashCommands) SaveStagedChanges(message string) error {
diff --git a/pkg/commands/git_commands/sync.go b/pkg/commands/git_commands/sync.go
index 8a6933522..fb1aa9648 100644
--- a/pkg/commands/git_commands/sync.go
+++ b/pkg/commands/git_commands/sync.go
@@ -47,7 +47,7 @@ func (self *SyncCommands) PushCmdObj(opts PushOpts) (oscommands.ICmdObj, error)
cmdStr += " " + self.cmd.Quote(opts.UpstreamBranch)
}
- cmdObj := self.cmd.New(cmdStr).PromptOnCredentialRequest()
+ cmdObj := self.cmd.New(cmdStr).PromptOnCredentialRequest().WithMutex(self.syncMutex)
return cmdObj, nil
}
@@ -83,7 +83,7 @@ func (self *SyncCommands) Fetch(opts FetchOptions) error {
} else {
cmdObj.PromptOnCredentialRequest()
}
- return cmdObj.Run()
+ return cmdObj.WithMutex(self.syncMutex).Run()
}
type PullOptions struct {
@@ -108,15 +108,15 @@ func (self *SyncCommands) Pull(opts PullOptions) error {
// setting GIT_SEQUENCE_EDITOR to ':' as a way of skipping it, in case the user
// has 'pull.rebase = interactive' configured.
- return self.cmd.New(cmdStr).AddEnvVars("GIT_SEQUENCE_EDITOR=:").PromptOnCredentialRequest().Run()
+ return self.cmd.New(cmdStr).AddEnvVars("GIT_SEQUENCE_EDITOR=:").PromptOnCredentialRequest().WithMutex(self.syncMutex).Run()
}
func (self *SyncCommands) FastForward(branchName string, remoteName string, remoteBranchName string) error {
cmdStr := fmt.Sprintf("git fetch %s %s:%s", self.cmd.Quote(remoteName), self.cmd.Quote(remoteBranchName), self.cmd.Quote(branchName))
- return self.cmd.New(cmdStr).PromptOnCredentialRequest().Run()
+ return self.cmd.New(cmdStr).PromptOnCredentialRequest().WithMutex(self.syncMutex).Run()
}
func (self *SyncCommands) FetchRemote(remoteName string) error {
cmdStr := fmt.Sprintf("git fetch %s", self.cmd.Quote(remoteName))
- return self.cmd.New(cmdStr).PromptOnCredentialRequest().Run()
+ return self.cmd.New(cmdStr).PromptOnCredentialRequest().WithMutex(self.syncMutex).Run()
}
diff --git a/pkg/commands/git_commands/tag.go b/pkg/commands/git_commands/tag.go
index 94b0d8ac1..5abad0dc5 100644
--- a/pkg/commands/git_commands/tag.go
+++ b/pkg/commands/git_commands/tag.go
@@ -27,5 +27,5 @@ func (self *TagCommands) Delete(tagName string) error {
}
func (self *TagCommands) Push(remoteName string, tagName string) error {
- return self.cmd.New(fmt.Sprintf("git push %s %s", self.cmd.Quote(remoteName), self.cmd.Quote(tagName))).PromptOnCredentialRequest().Run()
+ return self.cmd.New(fmt.Sprintf("git push %s %s", self.cmd.Quote(remoteName), self.cmd.Quote(tagName))).PromptOnCredentialRequest().WithMutex(self.syncMutex).Run()
}
diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go
index f594a639b..8fd1fb177 100644
--- a/pkg/commands/git_commands/working_tree.go
+++ b/pkg/commands/git_commands/working_tree.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/go-errors/errors"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/loaders"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
@@ -46,10 +47,9 @@ func (self *WorkingTreeCommands) StageFile(path string) error {
}
func (self *WorkingTreeCommands) StageFiles(paths []string) error {
- quotedPaths := make([]string, len(paths))
- for i, path := range paths {
- quotedPaths[i] = self.cmd.Quote(path)
- }
+ quotedPaths := slices.Map(paths, func(path string) string {
+ return self.cmd.Quote(path)
+ })
return self.cmd.New(fmt.Sprintf("git add -- %s", strings.Join(quotedPaths, " "))).Run()
}
@@ -218,6 +218,11 @@ func (self *WorkingTreeCommands) Ignore(filename string) error {
return self.os.AppendLineToFile(".gitignore", filename)
}
+// Exclude adds a file to the .git/info/exclude for the repo
+func (self *WorkingTreeCommands) Exclude(filename string) error {
+ return self.os.AppendLineToFile(".git/info/exclude", filename)
+}
+
// WorktreeFileDiff returns the diff of a file
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool, ignoreWhitespace bool) string {
// for now we assume an error means the file was deleted
@@ -230,6 +235,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
trackedArg := "--"
colorArg := self.UserConfig.Git.Paging.ColorArg
quotedPath := self.cmd.Quote(node.GetPath())
+ quotedPrevPath := ""
ignoreWhitespaceArg := ""
contextSize := self.UserConfig.Git.DiffContextSize
if cached {
@@ -244,19 +250,25 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
if ignoreWhitespace {
ignoreWhitespaceArg = " --ignore-all-space"
}
+ if prevPath := node.GetPreviousPath(); prevPath != "" {
+ quotedPrevPath = " " + self.cmd.Quote(prevPath)
+ }
- cmdStr := fmt.Sprintf("git diff --submodule --no-ext-diff --unified=%d --color=%s%s%s %s %s", contextSize, colorArg, ignoreWhitespaceArg, cachedArg, trackedArg, quotedPath)
+ cmdStr := fmt.Sprintf("git diff --submodule --no-ext-diff --unified=%d --color=%s%s%s %s %s%s", contextSize, colorArg, ignoreWhitespaceArg, cachedArg, trackedArg, quotedPath, quotedPrevPath)
return self.cmd.New(cmdStr).DontLog()
}
func (self *WorkingTreeCommands) ApplyPatch(patch string, flags ...string) error {
- filepath := filepath.Join(oscommands.GetTempDir(), utils.GetCurrentRepoName(), time.Now().Format("Jan _2 15.04.05.000000000")+".patch")
- self.Log.Infof("saving temporary patch to %s", filepath)
- if err := self.os.CreateFileWithContent(filepath, patch); err != nil {
+ filepath, err := self.SaveTemporaryPatch(patch)
+ if err != nil {
return err
}
+ return self.ApplyPatchFile(filepath, flags...)
+}
+
+func (self *WorkingTreeCommands) ApplyPatchFile(filepath string, flags ...string) error {
flagStr := ""
for _, flag := range flags {
flagStr += " --" + flag
@@ -265,6 +277,15 @@ func (self *WorkingTreeCommands) ApplyPatch(patch string, flags ...string) error
return self.cmd.New(fmt.Sprintf("git apply%s %s", flagStr, self.cmd.Quote(filepath))).Run()
}
+func (self *WorkingTreeCommands) SaveTemporaryPatch(patch string) (string, error) {
+ filepath := filepath.Join(self.os.GetTempDir(), utils.GetCurrentRepoName(), time.Now().Format("Jan _2 15.04.05.000000000")+".patch")
+ self.Log.Infof("saving temporary patch to %s", filepath)
+ if err := self.os.CreateFileWithContent(filepath, patch); err != nil {
+ return "", err
+ }
+ return filepath, nil
+}
+
// ShowFileDiff get the diff of specified from and to. Typically this will be used for a single commit so it'll be 123abc^..123abc
// 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.
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, plain bool) (string, error) {
diff --git a/pkg/commands/git_test.go b/pkg/commands/git_test.go
index 684696a8c..3531f14ca 100644
--- a/pkg/commands/git_test.go
+++ b/pkg/commands/git_test.go
@@ -11,6 +11,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/git_config"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/sasha-s/go-deadlock"
"github.com/stretchr/testify/assert"
)
@@ -76,7 +77,7 @@ func TestNavigateToRepoRootDirectory(t *testing.T) {
},
},
{
- "An error occurred when getting path informations",
+ "An error occurred when getting path information",
func(string) (os.FileInfo, error) {
return nil, fmt.Errorf("An error occurred")
},
@@ -115,18 +116,20 @@ func TestNavigateToRepoRootDirectory(t *testing.T) {
func TestSetupRepository(t *testing.T) {
type scenario struct {
testName string
- openGitRepository func(string) (*gogit.Repository, error)
+ openGitRepository func(string, *gogit.PlainOpenOptions) (*gogit.Repository, error)
errorStr string
+ options gogit.PlainOpenOptions
test func(*gogit.Repository, error)
}
scenarios := []scenario{
{
"A gitconfig parsing error occurred",
- func(string) (*gogit.Repository, error) {
+ func(string, *gogit.PlainOpenOptions) (*gogit.Repository, error) {
return nil, fmt.Errorf(`unquoted '\' must be followed by new line`)
},
"error translated",
+ gogit.PlainOpenOptions{},
func(r *gogit.Repository, err error) {
assert.Error(t, err)
assert.EqualError(t, err, "error translated")
@@ -134,10 +137,11 @@ func TestSetupRepository(t *testing.T) {
},
{
"A gogit error occurred",
- func(string) (*gogit.Repository, error) {
+ func(string, *gogit.PlainOpenOptions) (*gogit.Repository, error) {
return nil, fmt.Errorf("Error from inside gogit")
},
"",
+ gogit.PlainOpenOptions{},
func(r *gogit.Repository, err error) {
assert.Error(t, err)
assert.EqualError(t, err, "Error from inside gogit")
@@ -145,13 +149,14 @@ func TestSetupRepository(t *testing.T) {
},
{
"Setup done properly",
- func(string) (*gogit.Repository, error) {
+ func(string, *gogit.PlainOpenOptions) (*gogit.Repository, error) {
assert.NoError(t, os.RemoveAll("/tmp/lazygit-test"))
r, err := gogit.PlainInit("/tmp/lazygit-test", false)
assert.NoError(t, err)
return r, nil
},
"",
+ gogit.PlainOpenOptions{},
func(r *gogit.Repository, err error) {
assert.NoError(t, err)
assert.NotNil(t, r)
@@ -162,7 +167,7 @@ func TestSetupRepository(t *testing.T) {
for _, s := range scenarios {
s := s
t.Run(s.testName, func(t *testing.T) {
- s.test(setupRepository(s.openGitRepository, s.errorStr))
+ s.test(setupRepository(s.openGitRepository, s.options, s.errorStr))
})
}
}
@@ -211,7 +216,12 @@ func TestNewGitCommand(t *testing.T) {
s := s
t.Run(s.testName, func(t *testing.T) {
s.setup()
- s.test(NewGitCommand(utils.NewDummyCommon(), oscommands.NewDummyOSCommand(), git_config.NewFakeGitConfig(nil)))
+ s.test(
+ NewGitCommand(utils.NewDummyCommon(),
+ oscommands.NewDummyOSCommand(),
+ git_config.NewFakeGitConfig(nil),
+ &deadlock.Mutex{},
+ ))
})
}
}
diff --git a/pkg/commands/hosting_service/definitions.go b/pkg/commands/hosting_service/definitions.go
index c70062a67..c53ee0507 100644
--- a/pkg/commands/hosting_service/definitions.go
+++ b/pkg/commands/hosting_service/definitions.go
@@ -6,6 +6,7 @@ var defaultUrlRegexStrings = []string{
`^(?:https?|ssh)://.*/(?P.*)/(?P.*?)(?:\.git)?$`,
`^git@.*:(?P.*)/(?P.*?)(?:\.git)?$`,
}
+var defaultRepoURLTemplate = "https://{{.webDomain}}/{{.owner}}/{{.repo}}"
// we've got less type safety using go templates but this lends itself better to
// users adding custom service definitions in their config
@@ -15,6 +16,7 @@ var githubServiceDef = ServiceDefinition{
pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}?expand=1",
commitURL: "/commit/{{.CommitSha}}",
regexStrings: defaultUrlRegexStrings,
+ repoURLTemplate: defaultRepoURLTemplate,
}
var bitbucketServiceDef = ServiceDefinition{
@@ -22,7 +24,11 @@ var bitbucketServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/pull-requests/new?source={{.From}}&t=1",
pullRequestURLIntoTargetBranch: "/pull-requests/new?source={{.From}}&dest={{.To}}&t=1",
commitURL: "/commits/{{.CommitSha}}",
- regexStrings: defaultUrlRegexStrings,
+ regexStrings: []string{
+ `^(?:https?|ssh)://.*/(?P.*)/(?P.*?)(?:\.git)?$`,
+ `^.*@.*:(?P.*)/(?P.*?)(?:\.git)?$`,
+ },
+ repoURLTemplate: defaultRepoURLTemplate,
}
var gitLabServiceDef = ServiceDefinition{
@@ -31,9 +37,40 @@ var gitLabServiceDef = ServiceDefinition{
pullRequestURLIntoTargetBranch: "/merge_requests/new?merge_request[source_branch]={{.From}}&merge_request[target_branch]={{.To}}",
commitURL: "/commit/{{.CommitSha}}",
regexStrings: defaultUrlRegexStrings,
+ repoURLTemplate: defaultRepoURLTemplate,
}
-var serviceDefinitions = []ServiceDefinition{githubServiceDef, bitbucketServiceDef, gitLabServiceDef}
+var azdoServiceDef = ServiceDefinition{
+ provider: "azuredevops",
+ pullRequestURLIntoDefaultBranch: "/pullrequestcreate?sourceRef={{.From}}",
+ pullRequestURLIntoTargetBranch: "/pullrequestcreate?sourceRef={{.From}}&targetRef={{.To}}",
+ commitURL: "/commit/{{.CommitSha}}",
+ regexStrings: []string{
+ `^git@ssh.dev.azure.com.*/(?P.*)/(?P.*)/(?P.*?)(?:\.git)?$`,
+ `^https://.*@dev.azure.com/(?P.*?)/(?P.*?)/_git/(?P.*?)(?:\.git)?$`,
+ },
+ repoURLTemplate: "https://{{.webDomain}}/{{.org}}/{{.project}}/_git/{{.repo}}",
+}
+
+var bitbucketServerServiceDef = ServiceDefinition{
+ provider: "bitbucketServer",
+ pullRequestURLIntoDefaultBranch: "/pull-requests?create&sourceBranch={{.From}}",
+ pullRequestURLIntoTargetBranch: "/pull-requests?create&targetBranch={{.To}}&sourceBranch={{.From}}",
+ commitURL: "/commits/{{.CommitSha}}",
+ regexStrings: []string{
+ `^ssh://git@.*/(?P.*)/(?P.*?)(?:\.git)?$`,
+ `^https://.*/scm/(?P.*)/(?P.*?)(?:\.git)?$`,
+ },
+ repoURLTemplate: "https://{{.webDomain}}/projects/{{.project}}/repos/{{.repo}}",
+}
+
+var serviceDefinitions = []ServiceDefinition{
+ githubServiceDef,
+ bitbucketServiceDef,
+ gitLabServiceDef,
+ azdoServiceDef,
+ bitbucketServerServiceDef,
+}
var defaultServiceDomains = []ServiceDomain{
{
@@ -51,4 +88,9 @@ var defaultServiceDomains = []ServiceDomain{
gitDomain: "gitlab.com",
webDomain: "gitlab.com",
},
+ {
+ serviceDefinition: azdoServiceDef,
+ gitDomain: "dev.azure.com",
+ webDomain: "dev.azure.com",
+ },
}
diff --git a/pkg/commands/hosting_service/hosting_service.go b/pkg/commands/hosting_service/hosting_service.go
index 01e07e9eb..091da3ebb 100644
--- a/pkg/commands/hosting_service/hosting_service.go
+++ b/pkg/commands/hosting_service/hosting_service.go
@@ -1,7 +1,6 @@
package hosting_service
import (
- "fmt"
"net/url"
"regexp"
"strings"
@@ -10,6 +9,8 @@ import (
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sirupsen/logrus"
+
+ "github.com/jesseduffield/generics/slices"
)
// This package is for handling logic specific to a git hosting service like github, gitlab, bitbucket, etc.
@@ -66,13 +67,13 @@ func (self *HostingServiceMgr) getService() (*Service, error) {
return nil, err
}
- root, err := serviceDomain.getRootFromRemoteURL(self.remoteURL)
+ repoURL, err := serviceDomain.serviceDefinition.getRepoURLFromRemoteURL(self.remoteURL, serviceDomain.webDomain)
if err != nil {
return nil, err
}
return &Service{
- root: root,
+ repoURL: repoURL,
ServiceDefinition: serviceDomain.serviceDefinition,
}, nil
}
@@ -95,8 +96,7 @@ func (self *HostingServiceMgr) getCandidateServiceDomains() []ServiceDomain {
serviceDefinitionByProvider[serviceDefinition.provider] = serviceDefinition
}
- var serviceDomains = make([]ServiceDomain, len(defaultServiceDomains))
- copy(serviceDomains, defaultServiceDomains)
+ serviceDomains := slices.Clone(defaultServiceDomains)
if len(self.configServiceDomains) > 0 {
for gitDomain, typeAndDomain := range self.configServiceDomains {
@@ -111,10 +111,10 @@ func (self *HostingServiceMgr) getCandidateServiceDomains() []ServiceDomain {
serviceDefinition, ok := serviceDefinitionByProvider[provider]
if !ok {
- providerNames := []string{}
- for _, serviceDefinition := range serviceDefinitions {
- providerNames = append(providerNames, serviceDefinition.provider)
- }
+ providerNames := slices.Map(serviceDefinitions, func(serviceDefinition ServiceDefinition) string {
+ return serviceDefinition.provider
+ })
+
self.log.Errorf("Unknown git service type: '%s'. Expected one of %s", provider, strings.Join(providerNames, ", "))
continue
}
@@ -139,47 +139,32 @@ type ServiceDomain struct {
serviceDefinition ServiceDefinition
}
-func (self ServiceDomain) getRootFromRemoteURL(repoURL string) (string, error) {
- // we may want to make this more specific to the service in future e.g. if
- // some new service comes along which has a different root url structure.
- repoInfo, err := self.serviceDefinition.getRepoInfoFromURL(repoURL)
- if err != nil {
- return "", err
- }
- return fmt.Sprintf("https://%s/%s/%s", self.webDomain, repoInfo.Owner, repoInfo.Repository), nil
-}
-
-// RepoInformation holds some basic information about the repo
-type RepoInformation struct {
- Owner string
- Repository string
-}
-
type ServiceDefinition struct {
provider string
pullRequestURLIntoDefaultBranch string
pullRequestURLIntoTargetBranch string
commitURL string
regexStrings []string
+
+ // can expect 'webdomain' to be passed in. Otherwise, you get to pick what we match in the regex
+ repoURLTemplate string
}
-func (self ServiceDefinition) getRepoInfoFromURL(url string) (*RepoInformation, error) {
+func (self ServiceDefinition) getRepoURLFromRemoteURL(url string, webDomain string) (string, error) {
for _, regexStr := range self.regexStrings {
re := regexp.MustCompile(regexStr)
- matches := utils.FindNamedMatches(re, url)
- if matches != nil {
- return &RepoInformation{
- Owner: matches["owner"],
- Repository: matches["repo"],
- }, nil
+ input := utils.FindNamedMatches(re, url)
+ if input != nil {
+ input["webDomain"] = webDomain
+ return utils.ResolvePlaceholderString(self.repoURLTemplate, input), nil
}
}
- return nil, errors.New("Failed to parse repo information from url")
+ return "", errors.New("Failed to parse repo information from url")
}
type Service struct {
- root string
+ repoURL string
ServiceDefinition
}
@@ -196,5 +181,5 @@ func (self *Service) getCommitURL(commitSha string) string {
}
func (self *Service) resolveUrl(templateString string, args map[string]string) string {
- return self.root + utils.ResolvePlaceholderString(templateString, args)
+ return self.repoURL + utils.ResolvePlaceholderString(templateString, args)
}
diff --git a/pkg/commands/hosting_service/hosting_service_test.go b/pkg/commands/hosting_service/hosting_service_test.go
index 98c097a33..b92daa98d 100644
--- a/pkg/commands/hosting_service/hosting_service_test.go
+++ b/pkg/commands/hosting_service/hosting_service_test.go
@@ -8,63 +8,6 @@ import (
"github.com/stretchr/testify/assert"
)
-func TestGetRepoInfoFromURL(t *testing.T) {
- type scenario struct {
- serviceDefinition ServiceDefinition
- testName string
- repoURL string
- test func(*RepoInformation)
- }
-
- scenarios := []scenario{
- {
- githubServiceDef,
- "Returns repository information for git remote url",
- "git@github.com:petersmith/super_calculator",
- func(repoInfo *RepoInformation) {
- assert.EqualValues(t, repoInfo.Owner, "petersmith")
- assert.EqualValues(t, repoInfo.Repository, "super_calculator")
- },
- },
- {
- githubServiceDef,
- "Returns repository information for git remote url, trimming trailing '.git'",
- "git@github.com:petersmith/super_calculator.git",
- func(repoInfo *RepoInformation) {
- assert.EqualValues(t, repoInfo.Owner, "petersmith")
- assert.EqualValues(t, repoInfo.Repository, "super_calculator")
- },
- },
- {
- githubServiceDef,
- "Returns repository information for ssh remote url",
- "ssh://git@github.com/petersmith/super_calculator",
- func(repoInfo *RepoInformation) {
- assert.EqualValues(t, repoInfo.Owner, "petersmith")
- assert.EqualValues(t, repoInfo.Repository, "super_calculator")
- },
- },
- {
- githubServiceDef,
- "Returns repository information for http remote url",
- "https://my_username@bitbucket.org/johndoe/social_network.git",
- func(repoInfo *RepoInformation) {
- assert.EqualValues(t, repoInfo.Owner, "johndoe")
- assert.EqualValues(t, repoInfo.Repository, "social_network")
- },
- },
- }
-
- for _, s := range scenarios {
- s := s
- t.Run(s.testName, func(t *testing.T) {
- result, err := s.serviceDefinition.getRepoInfoFromURL(s.repoURL)
- assert.NoError(t, err)
- s.test(result)
- })
- }
-}
-
func TestGetPullRequestURL(t *testing.T) {
type scenario struct {
testName string
@@ -172,6 +115,107 @@ func TestGetPullRequestURL(t *testing.T) {
assert.Equal(t, "https://gitlab.com/peter/public/calculator/merge_requests/new?merge_request[source_branch]=feature%2Fcommit-ui&merge_request[target_branch]=epic%2Fui", url)
},
},
+ {
+ testName: "Opens a link to new pull request on bitbucket with a custom SSH username",
+ from: "feature/profile-page",
+ remoteUrl: "john@bitbucket.org:johndoe/social_network.git",
+ test: func(url string, err error) {
+ assert.NoError(t, err)
+ assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page&t=1", url)
+ },
+ },
+ {
+ testName: "Opens a link to new pull request on Azure DevOps (SSH)",
+ from: "feature/new",
+ remoteUrl: "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo",
+ test: func(url string, err error) {
+ assert.NoError(t, err)
+ assert.Equal(t, "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequestcreate?sourceRef=feature%2Fnew", url)
+ },
+ },
+ {
+ testName: "Opens a link to new pull request on Azure DevOps (SSH) with specific target",
+ from: "feature/new",
+ to: "dev",
+ remoteUrl: "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo",
+ test: func(url string, err error) {
+ assert.NoError(t, err)
+ assert.Equal(t, "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequestcreate?sourceRef=feature%2Fnew&targetRef=dev", url)
+ },
+ },
+ {
+ testName: "Opens a link to new pull request on Azure DevOps (HTTP)",
+ from: "feature/new",
+ remoteUrl: "https://myorg@dev.azure.com/myorg/myproject/_git/myrepo",
+ test: func(url string, err error) {
+ assert.NoError(t, err)
+ assert.Equal(t, "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequestcreate?sourceRef=feature%2Fnew", url)
+ },
+ },
+ {
+ testName: "Opens a link to new pull request on Azure DevOps (HTTP) with specific target",
+ from: "feature/new",
+ to: "dev",
+ remoteUrl: "https://myorg@dev.azure.com/myorg/myproject/_git/myrepo",
+ test: func(url string, err error) {
+ assert.NoError(t, err)
+ assert.Equal(t, "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequestcreate?sourceRef=feature%2Fnew&targetRef=dev", url)
+ },
+ },
+ {
+ testName: "Opens a link to new pull request on Bitbucket Server (SSH)",
+ from: "feature/new",
+ remoteUrl: "ssh://git@mycompany.bitbucket.com/myproject/myrepo.git",
+ configServiceDomains: map[string]string{
+ // valid configuration for a bitbucket server URL
+ "mycompany.bitbucket.com": "bitbucketServer:mycompany.bitbucket.com",
+ },
+ test: func(url string, err error) {
+ assert.NoError(t, err)
+ assert.Equal(t, "https://mycompany.bitbucket.com/projects/myproject/repos/myrepo/pull-requests?create&sourceBranch=feature%2Fnew", url)
+ },
+ },
+ {
+ testName: "Opens a link to new pull request on Bitbucket Server (SSH) with specific target",
+ from: "feature/new",
+ to: "dev",
+ remoteUrl: "ssh://git@mycompany.bitbucket.com/myproject/myrepo.git",
+ configServiceDomains: map[string]string{
+ // valid configuration for a bitbucket server URL
+ "mycompany.bitbucket.com": "bitbucketServer:mycompany.bitbucket.com",
+ },
+ test: func(url string, err error) {
+ assert.NoError(t, err)
+ assert.Equal(t, "https://mycompany.bitbucket.com/projects/myproject/repos/myrepo/pull-requests?create&targetBranch=dev&sourceBranch=feature%2Fnew", url)
+ },
+ },
+ {
+ testName: "Opens a link to new pull request on Bitbucket Server (HTTP)",
+ from: "feature/new",
+ remoteUrl: "https://mycompany.bitbucket.com/scm/myproject/myrepo.git",
+ configServiceDomains: map[string]string{
+ // valid configuration for a bitbucket server URL
+ "mycompany.bitbucket.com": "bitbucketServer:mycompany.bitbucket.com",
+ },
+ test: func(url string, err error) {
+ assert.NoError(t, err)
+ assert.Equal(t, "https://mycompany.bitbucket.com/projects/myproject/repos/myrepo/pull-requests?create&sourceBranch=feature%2Fnew", url)
+ },
+ },
+ {
+ testName: "Opens a link to new pull request on Bitbucket Server (HTTP) with specific target",
+ from: "feature/new",
+ to: "dev",
+ remoteUrl: "https://mycompany.bitbucket.com/scm/myproject/myrepo.git",
+ configServiceDomains: map[string]string{
+ // valid configuration for a bitbucket server URL
+ "mycompany.bitbucket.com": "bitbucketServer:mycompany.bitbucket.com",
+ },
+ test: func(url string, err error) {
+ assert.NoError(t, err)
+ assert.Equal(t, "https://mycompany.bitbucket.com/projects/myproject/repos/myrepo/pull-requests?create&targetBranch=dev&sourceBranch=feature%2Fnew", url)
+ },
+ },
{
testName: "Throws an error if git service is unsupported",
from: "feature/divide-operation",
@@ -218,7 +262,7 @@ func TestGetPullRequestURL(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature%2Fprofile-page&t=1", url)
},
- expectedLoggedErrors: []string{"Unknown git service type: 'noservice'. Expected one of github, bitbucket, gitlab"},
+ expectedLoggedErrors: []string{"Unknown git service type: 'noservice'. Expected one of github, bitbucket, gitlab, azuredevops, bitbucketServer"},
},
{
testName: "Escapes reserved URL characters in from branch name",
diff --git a/pkg/commands/loaders/branches.go b/pkg/commands/loaders/branches.go
index c54e65ee9..5a4502085 100644
--- a/pkg/commands/loaders/branches.go
+++ b/pkg/commands/loaders/branches.go
@@ -4,6 +4,8 @@ import (
"regexp"
"strings"
+ "github.com/jesseduffield/generics/set"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/go-git/v5/config"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
@@ -64,21 +66,20 @@ outer:
if strings.EqualFold(reflogBranch.Name, branch.Name) {
branch.Recency = reflogBranch.Recency
branchesWithRecency = append(branchesWithRecency, branch)
- branches = append(branches[0:j], branches[j+1:]...)
+ branches = slices.Remove(branches, j)
continue outer
}
}
}
- branches = append(branchesWithRecency, branches...)
+ branches = slices.Prepend(branches, branchesWithRecency...)
foundHead := false
for i, branch := range branches {
if branch.Head {
foundHead = true
branch.Recency = " *"
- branches = append(branches[0:i], branches[i+1:]...)
- branches = append([]*models.Branch{branch}, branches...)
+ branches = slices.Move(branches, i, 0)
break
}
}
@@ -87,7 +88,7 @@ outer:
if err != nil {
return nil, err
}
- branches = append([]*models.Branch{{Name: currentBranchName, DisplayName: currentBranchDisplayName, Head: true, Recency: " *"}}, branches...)
+ branches = slices.Prepend(branches, &models.Branch{Name: currentBranchName, DisplayName: currentBranchDisplayName, Head: true, Recency: " *"})
}
configBranches, err := self.config.Branches()
@@ -114,38 +115,47 @@ func (self *BranchLoader) obtainBranches() []*models.Branch {
trimmedOutput := strings.TrimSpace(output)
outputLines := strings.Split(trimmedOutput, "\n")
- branches := make([]*models.Branch, 0, len(outputLines))
- for _, line := range outputLines {
+
+ return slices.FilterMap(outputLines, func(line string) (*models.Branch, bool) {
if line == "" {
- continue
+ return nil, false
}
- split := strings.Split(line, SEPARATION_CHAR)
+ split := strings.Split(line, "\x00")
if len(split) != 4 {
// Ignore line if it isn't separated into 4 parts
// This is probably a warning message, for more info see:
// https://github.com/jesseduffield/lazygit/issues/1385#issuecomment-885580439
- continue
+ return nil, false
}
- name := strings.TrimPrefix(split[1], "heads/")
- branch := &models.Branch{
- Name: name,
- Pullables: "?",
- Pushables: "?",
- Head: split[0] == "*",
- }
+ return obtainBranch(split), true
+ })
+}
- upstreamName := split[2]
- if upstreamName == "" {
- // if we're here then it means we do not have a local version of the remote.
- // The branch might still be tracking a remote though, we just don't know
- // how many commits ahead/behind it is
- branches = append(branches, branch)
- continue
- }
+// Obtain branch information from parsed line output of getRawBranches()
+// split contains the '|' separated tokens in the line of output
+func obtainBranch(split []string) *models.Branch {
+ name := strings.TrimPrefix(split[1], "heads/")
+ branch := &models.Branch{
+ Name: name,
+ Pullables: "?",
+ Pushables: "?",
+ Head: split[0] == "*",
+ }
- track := split[3]
+ upstreamName := split[2]
+ if upstreamName == "" {
+ // if we're here then it means we do not have a local version of the remote.
+ // The branch might still be tracking a remote though, we just don't know
+ // how many commits ahead/behind it is
+ return branch
+ }
+
+ track := split[3]
+ if track == "[gone]" {
+ branch.UpstreamGone = true
+ } else {
re := regexp.MustCompile(`ahead (\d+)`)
match := re.FindStringSubmatch(track)
if len(match) > 1 {
@@ -161,30 +171,32 @@ func (self *BranchLoader) obtainBranches() []*models.Branch {
} else {
branch.Pullables = "0"
}
-
- branches = append(branches, branch)
}
- return branches
+ return branch
}
// TODO: only look at the new reflog commits, and otherwise store the recencies in
// int form against the branch to recalculate the time ago
func (self *BranchLoader) obtainReflogBranches(reflogCommits []*models.Commit) []*models.Branch {
- foundBranchesMap := map[string]bool{}
+ foundBranches := set.New[string]()
re := regexp.MustCompile(`checkout: moving from ([\S]+) to ([\S]+)`)
reflogBranches := make([]*models.Branch, 0, len(reflogCommits))
+
for _, commit := range reflogCommits {
- if match := re.FindStringSubmatch(commit.Name); len(match) == 3 {
- recency := utils.UnixToTimeAgo(commit.UnixTimestamp)
- for _, branchName := range match[1:] {
- if !foundBranchesMap[branchName] {
- foundBranchesMap[branchName] = true
- reflogBranches = append(reflogBranches, &models.Branch{
- Recency: recency,
- Name: branchName,
- })
- }
+ match := re.FindStringSubmatch(commit.Name)
+ if len(match) != 3 {
+ continue
+ }
+
+ recency := utils.UnixToTimeAgo(commit.UnixTimestamp)
+ for _, branchName := range match[1:] {
+ if !foundBranches.Includes(branchName) {
+ foundBranches.Add(branchName)
+ reflogBranches = append(reflogBranches, &models.Branch{
+ Recency: recency,
+ Name: branchName,
+ })
}
}
}
diff --git a/pkg/commands/loaders/branches_test.go b/pkg/commands/loaders/branches_test.go
new file mode 100644
index 000000000..70f02dcf7
--- /dev/null
+++ b/pkg/commands/loaders/branches_test.go
@@ -0,0 +1,52 @@
+package loaders
+
+// "*|feat/detect-purge|origin/feat/detect-purge|[ahead 1]"
+import (
+ "testing"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestObtainBanch(t *testing.T) {
+ type scenario struct {
+ testName string
+ input []string
+ expectedBranch *models.Branch
+ }
+
+ scenarios := []scenario{
+ {
+ testName: "TrimHeads",
+ input: []string{"", "heads/a_branch", "", ""},
+ expectedBranch: &models.Branch{Name: "a_branch", Pushables: "?", Pullables: "?", Head: false},
+ },
+ {
+ testName: "NoUpstream",
+ input: []string{"", "a_branch", "", ""},
+ expectedBranch: &models.Branch{Name: "a_branch", Pushables: "?", Pullables: "?", Head: false},
+ },
+ {
+ testName: "IsHead",
+ input: []string{"*", "a_branch", "", ""},
+ expectedBranch: &models.Branch{Name: "a_branch", Pushables: "?", Pullables: "?", Head: true},
+ },
+ {
+ testName: "IsBehindAndAhead",
+ input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]"},
+ expectedBranch: &models.Branch{Name: "a_branch", Pushables: "3", Pullables: "2", Head: false},
+ },
+ {
+ testName: "RemoteBranchIsGone",
+ input: []string{"", "a_branch", "a_remote/a_branch", "[gone]"},
+ expectedBranch: &models.Branch{Name: "a_branch", UpstreamGone: true, Pushables: "?", Pullables: "?", Head: false},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ branch := obtainBranch(s.input)
+ assert.EqualValues(t, s.expectedBranch, branch)
+ })
+ }
+}
diff --git a/pkg/commands/loaders/commit_files.go b/pkg/commands/loaders/commit_files.go
index 755db768d..d68571edb 100644
--- a/pkg/commands/loaders/commit_files.go
+++ b/pkg/commands/loaders/commit_files.go
@@ -4,9 +4,11 @@ import (
"fmt"
"strings"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
+ "github.com/samber/lo"
)
type CommitFileLoader struct {
@@ -33,25 +35,22 @@ func (self *CommitFileLoader) GetFilesInDiff(from string, to string, reverse boo
return nil, err
}
- return self.getCommitFilesFromFilenames(filenames), nil
+ return getCommitFilesFromFilenames(filenames), nil
}
-// filenames string is something like "file1\nfile2\nfile3"
-func (self *CommitFileLoader) getCommitFilesFromFilenames(filenames string) []*models.CommitFile {
- commitFiles := make([]*models.CommitFile, 0)
-
+// filenames string is something like "MM\x00file1\x00MU\x00file2\x00AA\x00file3\x00"
+// so we need to split it by the null character and then map each status-name pair to a commit file
+func getCommitFilesFromFilenames(filenames string) []*models.CommitFile {
lines := strings.Split(strings.TrimRight(filenames, "\x00"), "\x00")
- n := len(lines)
- for i := 0; i < n-1; i += 2 {
- // typical result looks like 'A my_file' meaning my_file was added
- changeStatus := lines[i]
- name := lines[i+1]
-
- commitFiles = append(commitFiles, &models.CommitFile{
- Name: name,
- ChangeStatus: changeStatus,
- })
+ if len(lines) == 1 {
+ return []*models.CommitFile{}
}
- return commitFiles
+ // typical result looks like 'A my_file' meaning my_file was added
+ return slices.Map(lo.Chunk(lines, 2), func(chunk []string) *models.CommitFile {
+ return &models.CommitFile{
+ ChangeStatus: chunk[0],
+ Name: chunk[1],
+ }
+ })
}
diff --git a/pkg/commands/loaders/commit_files_test.go b/pkg/commands/loaders/commit_files_test.go
new file mode 100644
index 000000000..a07390052
--- /dev/null
+++ b/pkg/commands/loaders/commit_files_test.go
@@ -0,0 +1,71 @@
+package loaders
+
+import (
+ "testing"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetCommitFilesFromFilenames(t *testing.T) {
+ tests := []struct {
+ testName string
+ input string
+ output []*models.CommitFile
+ }{
+ {
+ testName: "no files",
+ input: "",
+ output: []*models.CommitFile{},
+ },
+ {
+ testName: "one file",
+ input: "MM\x00Myfile\x00",
+ output: []*models.CommitFile{
+ {
+ Name: "Myfile",
+ ChangeStatus: "MM",
+ },
+ },
+ },
+ {
+ testName: "two files",
+ input: "MM\x00Myfile\x00M \x00MyOtherFile\x00",
+ output: []*models.CommitFile{
+ {
+ Name: "Myfile",
+ ChangeStatus: "MM",
+ },
+ {
+ Name: "MyOtherFile",
+ ChangeStatus: "M ",
+ },
+ },
+ },
+ {
+ testName: "three files",
+ input: "MM\x00Myfile\x00M \x00MyOtherFile\x00 M\x00YetAnother\x00",
+ output: []*models.CommitFile{
+ {
+ Name: "Myfile",
+ ChangeStatus: "MM",
+ },
+ {
+ Name: "MyOtherFile",
+ ChangeStatus: "M ",
+ },
+ {
+ Name: "YetAnother",
+ ChangeStatus: " M",
+ },
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.testName, func(t *testing.T) {
+ result := getCommitFilesFromFilenames(test.input)
+ assert.Equal(t, test.output, result)
+ })
+ }
+}
diff --git a/pkg/commands/loaders/commits.go b/pkg/commands/loaders/commits.go
index ea54a4e76..69c88ccf5 100644
--- a/pkg/commands/loaders/commits.go
+++ b/pkg/commands/loaders/commits.go
@@ -1,6 +1,7 @@
package loaders
import (
+ "bytes"
"fmt"
"io/ioutil"
"os"
@@ -9,6 +10,8 @@ import (
"strconv"
"strings"
+ "github.com/fsmiamoto/git-todo-parser/todo"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
@@ -22,8 +25,6 @@ import (
// be processed as part of a rebase (these won't appear in git log but we
// grab them from the rebase-related files in the .git directory to show them
-const SEPARATION_CHAR = "|"
-
// CommitLoader returns a list of Commit objects for the current repo
type CommitLoader struct {
*common.Common
@@ -90,14 +91,12 @@ func (self *CommitLoader) GetCommits(opts GetCommitsOptions) ([]*models.Commit,
}
err = self.getLogCmd(opts).RunAndProcessLines(func(line string) (bool, error) {
- if canExtractCommit(line) {
- commit := self.extractCommitFromLine(line)
- if commit.Sha == firstPushedCommit {
- passedFirstPushedCommit = true
- }
- commit.Status = map[bool]string{true: "unpushed", false: "pushed"}[!passedFirstPushedCommit]
- commits = append(commits, commit)
+ commit := self.extractCommitFromLine(line)
+ if commit.Sha == firstPushedCommit {
+ passedFirstPushedCommit = true
}
+ commit.Status = map[bool]string{true: "unpushed", false: "pushed"}[!passedFirstPushedCommit]
+ commits = append(commits, commit)
return false, nil
})
if err != nil {
@@ -158,15 +157,16 @@ func (self *CommitLoader) MergeRebasingCommits(commits []*models.Commit) ([]*mod
// example input:
// 8ad01fe32fcc20f07bc6693f87aa4977c327f1e1|10 hours ago|Jesse Duffield| (HEAD -> master, tag: v0.15.2)|refresh commits when adding a tag
func (self *CommitLoader) extractCommitFromLine(line string) *models.Commit {
- split := strings.Split(line, SEPARATION_CHAR)
+ split := strings.SplitN(line, "\x00", 7)
sha := split[0]
unixTimestamp := split[1]
- author := split[2]
- extraInfo := strings.TrimSpace(split[3])
- parentHashes := split[4]
+ authorName := split[2]
+ authorEmail := split[3]
+ extraInfo := strings.TrimSpace(split[4])
+ parentHashes := split[5]
+ message := split[6]
- message := strings.Join(split[5:], SEPARATION_CHAR)
tags := []string{}
if extraInfo != "" {
@@ -179,14 +179,20 @@ func (self *CommitLoader) extractCommitFromLine(line string) *models.Commit {
unitTimestampInt, _ := strconv.Atoi(unixTimestamp)
+ parents := []string{}
+ if len(parentHashes) > 0 {
+ parents = strings.Split(parentHashes, " ")
+ }
+
return &models.Commit{
Sha: sha,
Name: message,
Tags: tags,
ExtraInfo: extraInfo,
UnixTimestamp: int64(unitTimestampInt),
- Author: author,
- Parents: strings.Split(parentHashes, " "),
+ AuthorName: authorName,
+ AuthorEmail: authorEmail,
+ Parents: parents,
}
}
@@ -200,16 +206,15 @@ func (self *CommitLoader) getHydratedRebasingCommits(rebaseMode enums.RebaseMode
return nil, nil
}
- commitShas := make([]string, len(commits))
- for i, commit := range commits {
- commitShas[i] = commit.Sha
- }
+ commitShas := slices.Map(commits, func(commit *models.Commit) string {
+ return commit.Sha
+ })
// note that we're not filtering these as we do non-rebasing commits just because
// I suspect that will cause some damage
cmdObj := self.cmd.New(
fmt.Sprintf(
- "git show %s --no-patch --oneline %s --abbrev=%d",
+ "git -c log.showSignature=false show %s --no-patch --oneline %s --abbrev=%d",
strings.Join(commitShas, " "),
prettyFormat,
20,
@@ -219,14 +224,12 @@ func (self *CommitLoader) getHydratedRebasingCommits(rebaseMode enums.RebaseMode
hydratedCommits := make([]*models.Commit, 0, len(commits))
i := 0
err = cmdObj.RunAndProcessLines(func(line string) (bool, error) {
- if canExtractCommit(line) {
- commit := self.extractCommitFromLine(line)
- matchingCommit := commits[i]
- commit.Action = matchingCommit.Action
- commit.Status = matchingCommit.Status
- hydratedCommits = append(hydratedCommits, commit)
- i++
- }
+ commit := self.extractCommitFromLine(line)
+ matchingCommit := commits[i]
+ commit.Action = matchingCommit.Action
+ commit.Status = matchingCommit.Status
+ hydratedCommits = append(hydratedCommits, commit)
+ i++
return false, nil
})
if err != nil {
@@ -306,21 +309,24 @@ func (self *CommitLoader) getInteractiveRebasingCommits() ([]*models.Commit, err
}
commits := []*models.Commit{}
- lines := strings.Split(string(bytesContent), "\n")
- for _, line := range lines {
- if line == "" || line == "noop" {
- return commits, nil
- }
- if strings.HasPrefix(line, "#") {
+
+ todos, err := todo.Parse(bytes.NewBuffer(bytesContent))
+ if err != nil {
+ self.Log.Error(fmt.Sprintf("error occurred while parsing git-rebase-todo file: %s", err.Error()))
+ return nil, nil
+ }
+
+ for _, t := range todos {
+ if t.Commit == "" {
+ // Command does not have a commit associated, skip
continue
}
- splitLine := strings.Split(line, " ")
- commits = append([]*models.Commit{{
- Sha: splitLine[1],
- Name: strings.Join(splitLine[2:], " "),
+ commits = slices.Prepend(commits, &models.Commit{
+ Sha: t.Commit,
+ Name: t.Msg,
Status: "rebasing",
- Action: splitLine[0],
- }}, commits...)
+ Action: t.Command.String(),
+ })
}
return commits, nil
@@ -429,27 +435,26 @@ func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj {
return self.cmd.New(
fmt.Sprintf(
- "git log %s %s %s --oneline %s%s --abbrev=%d%s",
+ "git -c log.showSignature=false log %s %s %s --oneline %s%s --abbrev=%d%s",
self.cmd.Quote(opts.RefName),
orderFlag,
allFlag,
prettyFormat,
limitFlag,
- 20,
+ 40,
filterFlag,
),
).DontLog()
}
var prettyFormat = fmt.Sprintf(
- "--pretty=format:\"%%H%s%%at%s%%aN%s%%d%s%%p%s%%s\"",
- SEPARATION_CHAR,
- SEPARATION_CHAR,
- SEPARATION_CHAR,
- SEPARATION_CHAR,
- SEPARATION_CHAR,
+ "--pretty=format:\"%%H%s%%at%s%%aN%s%%ae%s%%d%s%%p%s%%s\"",
+ NULL_CODE,
+ NULL_CODE,
+ NULL_CODE,
+ NULL_CODE,
+ NULL_CODE,
+ NULL_CODE,
)
-func canExtractCommit(line string) bool {
- return line != "" && strings.Split(line, " ")[0] != "gpg:"
-}
+const NULL_CODE = "%x00"
diff --git a/pkg/commands/loaders/commits_test.go b/pkg/commands/loaders/commits_test.go
index 23406abcc..a2a68fccc 100644
--- a/pkg/commands/loaders/commits_test.go
+++ b/pkg/commands/loaders/commits_test.go
@@ -2,6 +2,7 @@ package loaders
import (
"path/filepath"
+ "strings"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
@@ -11,32 +12,14 @@ import (
"github.com/stretchr/testify/assert"
)
-func NewDummyCommitLoader() *CommitLoader {
- cmn := utils.NewDummyCommon()
-
- return &CommitLoader{
- Common: cmn,
- cmd: nil,
- getCurrentBranchName: func() (string, string, error) { return "master", "master", nil },
- getRebaseMode: func() (enums.RebaseMode, error) { return enums.REBASE_MODE_NONE, nil },
- dotGitDir: ".git",
- readFile: func(filename string) ([]byte, error) {
- return []byte(""), nil
- },
- walkFiles: func(root string, fn filepath.WalkFunc) error {
- return nil
- },
- }
-}
-
-const commitsOutput = `0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield| (HEAD -> better-tests)|b21997d6b4cbdf84b149|better typing for rebase mode
-b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164|1640824515|Jesse Duffield| (origin/better-tests)|e94e8fc5b6fab4cb755f|fix logging
-e94e8fc5b6fab4cb755f29f1bdb3ee5e001df35c|1640823749|Jesse Duffield||d8084cd558925eb7c9c3|refactor
-d8084cd558925eb7c9c38afeed5725c21653ab90|1640821426|Jesse Duffield||65f910ebd85283b5cce9|WIP
-65f910ebd85283b5cce9bf67d03d3f1a9ea3813a|1640821275|Jesse Duffield||26c07b1ab33860a1a759|WIP
-26c07b1ab33860a1a7591a0638f9925ccf497ffa|1640750752|Jesse Duffield||3d4470a6c072208722e5|WIP
-3d4470a6c072208722e5ae9a54bcb9634959a1c5|1640748818|Jesse Duffield||053a66a7be3da43aacdc|WIP
-053a66a7be3da43aacdc7aa78e1fe757b82c4dd2|1640739815|Jesse Duffield||985fe482e806b172aea4|refactoring the config struct`
+var commitsOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com| (HEAD -> better-tests)|b21997d6b4cbdf84b149|better typing for rebase mode
+b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164|1640824515|Jesse Duffield|jessedduffield@gmail.com| (origin/better-tests)|e94e8fc5b6fab4cb755f|fix logging
+e94e8fc5b6fab4cb755f29f1bdb3ee5e001df35c|1640823749|Jesse Duffield|jessedduffield@gmail.com||d8084cd558925eb7c9c3|refactor
+d8084cd558925eb7c9c38afeed5725c21653ab90|1640821426|Jesse Duffield|jessedduffield@gmail.com||65f910ebd85283b5cce9|WIP
+65f910ebd85283b5cce9bf67d03d3f1a9ea3813a|1640821275|Jesse Duffield|jessedduffield@gmail.com||26c07b1ab33860a1a759|WIP
+26c07b1ab33860a1a7591a0638f9925ccf497ffa|1640750752|Jesse Duffield|jessedduffield@gmail.com||3d4470a6c072208722e5|WIP
+3d4470a6c072208722e5ae9a54bcb9634959a1c5|1640748818|Jesse Duffield|jessedduffield@gmail.com||053a66a7be3da43aacdc|WIP
+053a66a7be3da43aacdc7aa78e1fe757b82c4dd2|1640739815|Jesse Duffield|jessedduffield@gmail.com||985fe482e806b172aea4|refactoring the config struct`, "|", "\x00", -1)
func TestGetCommits(t *testing.T) {
type scenario struct {
@@ -57,7 +40,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false},
runner: oscommands.NewFakeRunner(t).
Expect(`git merge-base "HEAD" "HEAD"@{u}`, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
- Expect(`git log "HEAD" --topo-order --oneline --pretty=format:"%H|%at|%aN|%d|%p|%s" --abbrev=20`, "", nil),
+ Expect(`git -c log.showSignature=false log "HEAD" --topo-order --oneline --pretty=format:"%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s" --abbrev=40`, "", nil),
expectedCommits: []*models.Commit{},
expectedError: nil,
@@ -71,7 +54,7 @@ func TestGetCommits(t *testing.T) {
// here it's seeing which commits are yet to be pushed
Expect(`git merge-base "HEAD" "HEAD"@{u}`, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
// here it's actually getting all the commits in a formatted form, one per line
- Expect(`git log "HEAD" --topo-order --oneline --pretty=format:"%H|%at|%aN|%d|%p|%s" --abbrev=20`, commitsOutput, nil).
+ Expect(`git -c log.showSignature=false log "HEAD" --topo-order --oneline --pretty=format:"%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s" --abbrev=40`, commitsOutput, nil).
// here it's seeing where our branch diverged from the master branch so that we can mark that commit and parent commits as 'merged'
Expect(`git merge-base "HEAD" "master"`, "26c07b1ab33860a1a7591a0638f9925ccf497ffa", nil),
@@ -83,7 +66,8 @@ func TestGetCommits(t *testing.T) {
Action: "",
Tags: []string{},
ExtraInfo: "(HEAD -> better-tests)",
- Author: "Jesse Duffield",
+ AuthorName: "Jesse Duffield",
+ AuthorEmail: "jessedduffield@gmail.com",
UnixTimestamp: 1640826609,
Parents: []string{
"b21997d6b4cbdf84b149",
@@ -96,7 +80,8 @@ func TestGetCommits(t *testing.T) {
Action: "",
Tags: []string{},
ExtraInfo: "(origin/better-tests)",
- Author: "Jesse Duffield",
+ AuthorName: "Jesse Duffield",
+ AuthorEmail: "jessedduffield@gmail.com",
UnixTimestamp: 1640824515,
Parents: []string{
"e94e8fc5b6fab4cb755f",
@@ -109,7 +94,8 @@ func TestGetCommits(t *testing.T) {
Action: "",
Tags: []string{},
ExtraInfo: "",
- Author: "Jesse Duffield",
+ AuthorName: "Jesse Duffield",
+ AuthorEmail: "jessedduffield@gmail.com",
UnixTimestamp: 1640823749,
Parents: []string{
"d8084cd558925eb7c9c3",
@@ -122,7 +108,8 @@ func TestGetCommits(t *testing.T) {
Action: "",
Tags: []string{},
ExtraInfo: "",
- Author: "Jesse Duffield",
+ AuthorName: "Jesse Duffield",
+ AuthorEmail: "jessedduffield@gmail.com",
UnixTimestamp: 1640821426,
Parents: []string{
"65f910ebd85283b5cce9",
@@ -135,7 +122,8 @@ func TestGetCommits(t *testing.T) {
Action: "",
Tags: []string{},
ExtraInfo: "",
- Author: "Jesse Duffield",
+ AuthorName: "Jesse Duffield",
+ AuthorEmail: "jessedduffield@gmail.com",
UnixTimestamp: 1640821275,
Parents: []string{
"26c07b1ab33860a1a759",
@@ -148,7 +136,8 @@ func TestGetCommits(t *testing.T) {
Action: "",
Tags: []string{},
ExtraInfo: "",
- Author: "Jesse Duffield",
+ AuthorName: "Jesse Duffield",
+ AuthorEmail: "jessedduffield@gmail.com",
UnixTimestamp: 1640750752,
Parents: []string{
"3d4470a6c072208722e5",
@@ -161,7 +150,8 @@ func TestGetCommits(t *testing.T) {
Action: "",
Tags: []string{},
ExtraInfo: "",
- Author: "Jesse Duffield",
+ AuthorName: "Jesse Duffield",
+ AuthorEmail: "jessedduffield@gmail.com",
UnixTimestamp: 1640748818,
Parents: []string{
"053a66a7be3da43aacdc",
@@ -174,7 +164,8 @@ func TestGetCommits(t *testing.T) {
Action: "",
Tags: []string{},
ExtraInfo: "",
- Author: "Jesse Duffield",
+ AuthorName: "Jesse Duffield",
+ AuthorEmail: "jessedduffield@gmail.com",
UnixTimestamp: 1640739815,
Parents: []string{
"985fe482e806b172aea4",
diff --git a/pkg/commands/loaders/files.go b/pkg/commands/loaders/files.go
index f5becdb92..db37da935 100644
--- a/pkg/commands/loaders/files.go
+++ b/pkg/commands/loaders/files.go
@@ -7,7 +7,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
- "github.com/jesseduffield/lazygit/pkg/utils"
)
type FileLoaderConfig interface {
@@ -54,28 +53,15 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
self.Log.Warningf("warning when calling git status: %s", status.StatusString)
continue
}
- change := status.Change
- stagedChange := change[0:1]
- unstagedChange := change[1:2]
- untracked := utils.IncludesString([]string{"??", "A ", "AM"}, change)
- hasNoStagedChanges := utils.IncludesString([]string{" ", "U", "?"}, stagedChange)
- hasInlineMergeConflicts := utils.IncludesString([]string{"UU", "AA"}, change)
- hasMergeConflicts := hasInlineMergeConflicts || utils.IncludesString([]string{"DD", "AU", "UA", "UD", "DU"}, change)
file := &models.File{
- Name: status.Name,
- PreviousName: status.PreviousName,
- DisplayString: status.StatusString,
- HasStagedChanges: !hasNoStagedChanges,
- HasUnstagedChanges: unstagedChange != " ",
- Tracked: !untracked,
- Deleted: unstagedChange == "D" || stagedChange == "D",
- Added: unstagedChange == "A" || untracked,
- HasMergeConflicts: hasMergeConflicts,
- HasInlineMergeConflicts: hasInlineMergeConflicts,
- Type: self.getFileType(status.Name),
- ShortStatus: change,
+ Name: status.Name,
+ PreviousName: status.PreviousName,
+ DisplayString: status.StatusString,
+ Type: self.getFileType(status.Name),
}
+
+ models.SetStatusFields(file, status.Change)
files = append(files, file)
}
@@ -125,7 +111,7 @@ func (c *FileLoader) GitStatus(opts GitStatusOptions) ([]FileStatus, error) {
if strings.HasPrefix(status.Change, "R") {
// if a line starts with 'R' then the next line is the original file.
- status.PreviousName = strings.TrimSpace(splitLines[i+1])
+ status.PreviousName = splitLines[i+1]
status.StatusString = fmt.Sprintf("%s %s -> %s", status.Change, status.PreviousName, status.Name)
i++
}
diff --git a/pkg/commands/loaders/reflog_commits.go b/pkg/commands/loaders/reflog_commits.go
index dc1a4ac15..fe4e5e956 100644
--- a/pkg/commands/loaders/reflog_commits.go
+++ b/pkg/commands/loaders/reflog_commits.go
@@ -32,25 +32,32 @@ func (self *ReflogCommitLoader) GetReflogCommits(lastReflogCommit *models.Commit
filterPathArg = fmt.Sprintf(" --follow -- %s", self.cmd.Quote(filterPath))
}
- cmdObj := self.cmd.New(fmt.Sprintf(`git log -g --abbrev=20 --format="%%h %%ct %%gs"%s`, filterPathArg)).DontLog()
+ cmdObj := self.cmd.New(fmt.Sprintf(`git -c log.showSignature=false log -g --abbrev=40 --format="%s"%s`, "%h%x00%ct%x00%gs%x00%p", filterPathArg)).DontLog()
onlyObtainedNewReflogCommits := false
err := cmdObj.RunAndProcessLines(func(line string) (bool, error) {
- fields := strings.SplitN(line, " ", 3)
- if len(fields) <= 2 {
+ fields := strings.SplitN(line, "\x00", 4)
+ if len(fields) <= 3 {
return false, nil
}
unixTimestamp, _ := strconv.Atoi(fields[1])
+ parentHashes := fields[3]
+ parents := []string{}
+ if len(parentHashes) > 0 {
+ parents = strings.Split(parentHashes, " ")
+ }
+
commit := &models.Commit{
Sha: fields[0],
Name: fields[2],
UnixTimestamp: int64(unixTimestamp),
Status: "reflog",
+ Parents: parents,
}
// note that the unix timestamp here is the timestamp of the COMMIT, not the reflog entry itself,
- // so two consequetive reflog entries may have both the same SHA and therefore same timestamp.
+ // so two consecutive reflog entries may have both the same SHA and therefore same timestamp.
// We use the reflog message to disambiguate, and fingers crossed that we never see the same of those
// twice in a row. Reason being that it would mean we'd be erroneously exiting early.
if lastReflogCommit != nil && commit.Sha == lastReflogCommit.Sha && commit.UnixTimestamp == lastReflogCommit.UnixTimestamp && commit.Name == lastReflogCommit.Name {
diff --git a/pkg/commands/loaders/reflog_commits_test.go b/pkg/commands/loaders/reflog_commits_test.go
index 0e00ca3e5..8a82d27ac 100644
--- a/pkg/commands/loaders/reflog_commits_test.go
+++ b/pkg/commands/loaders/reflog_commits_test.go
@@ -2,6 +2,7 @@ package loaders
import (
"errors"
+ "strings"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
@@ -11,12 +12,12 @@ import (
"github.com/stretchr/testify/assert"
)
-const reflogOutput = `c3c4b66b64c97ffeecde 1643150483 checkout: moving from A to B
-c3c4b66b64c97ffeecde 1643150483 checkout: moving from B to A
-c3c4b66b64c97ffeecde 1643150483 checkout: moving from A to B
-c3c4b66b64c97ffeecde 1643150483 checkout: moving from master to A
-f4ddf2f0d4be4ccc7efa 1643149435 checkout: moving from A to master
-`
+var reflogOutput = strings.Replace(`c3c4b66b64c97ffeecde|1643150483|checkout: moving from A to B|51baa8c1
+c3c4b66b64c97ffeecde|1643150483|checkout: moving from B to A|51baa8c1
+c3c4b66b64c97ffeecde|1643150483|checkout: moving from A to B|51baa8c1
+c3c4b66b64c97ffeecde|1643150483|checkout: moving from master to A|51baa8c1
+f4ddf2f0d4be4ccc7efa|1643149435|checkout: moving from A to master|51baa8c1
+`, "|", "\x00", -1)
func TestGetReflogCommits(t *testing.T) {
type scenario struct {
@@ -33,7 +34,7 @@ func TestGetReflogCommits(t *testing.T) {
{
testName: "no reflog entries",
runner: oscommands.NewFakeRunner(t).
- Expect(`git log -g --abbrev=20 --format="%h %ct %gs"`, "", nil),
+ Expect(`git -c log.showSignature=false log -g --abbrev=40 --format="%h%x00%ct%x00%gs%x00%p"`, "", nil),
lastReflogCommit: nil,
expectedCommits: []*models.Commit{},
@@ -43,7 +44,7 @@ func TestGetReflogCommits(t *testing.T) {
{
testName: "some reflog entries",
runner: oscommands.NewFakeRunner(t).
- Expect(`git log -g --abbrev=20 --format="%h %ct %gs"`, reflogOutput, nil),
+ Expect(`git -c log.showSignature=false log -g --abbrev=40 --format="%h%x00%ct%x00%gs%x00%p"`, reflogOutput, nil),
lastReflogCommit: nil,
expectedCommits: []*models.Commit{
@@ -52,30 +53,35 @@ func TestGetReflogCommits(t *testing.T) {
Name: "checkout: moving from A to B",
Status: "reflog",
UnixTimestamp: 1643150483,
+ Parents: []string{"51baa8c1"},
},
{
Sha: "c3c4b66b64c97ffeecde",
Name: "checkout: moving from B to A",
Status: "reflog",
UnixTimestamp: 1643150483,
+ Parents: []string{"51baa8c1"},
},
{
Sha: "c3c4b66b64c97ffeecde",
Name: "checkout: moving from A to B",
Status: "reflog",
UnixTimestamp: 1643150483,
+ Parents: []string{"51baa8c1"},
},
{
Sha: "c3c4b66b64c97ffeecde",
Name: "checkout: moving from master to A",
Status: "reflog",
UnixTimestamp: 1643150483,
+ Parents: []string{"51baa8c1"},
},
{
Sha: "f4ddf2f0d4be4ccc7efa",
Name: "checkout: moving from A to master",
Status: "reflog",
UnixTimestamp: 1643149435,
+ Parents: []string{"51baa8c1"},
},
},
expectedOnlyObtainedNew: false,
@@ -84,13 +90,14 @@ func TestGetReflogCommits(t *testing.T) {
{
testName: "some reflog entries where last commit is given",
runner: oscommands.NewFakeRunner(t).
- Expect(`git log -g --abbrev=20 --format="%h %ct %gs"`, reflogOutput, nil),
+ Expect(`git -c log.showSignature=false log -g --abbrev=40 --format="%h%x00%ct%x00%gs%x00%p"`, reflogOutput, nil),
lastReflogCommit: &models.Commit{
Sha: "c3c4b66b64c97ffeecde",
Name: "checkout: moving from B to A",
Status: "reflog",
UnixTimestamp: 1643150483,
+ Parents: []string{"51baa8c1"},
},
expectedCommits: []*models.Commit{
{
@@ -98,6 +105,7 @@ func TestGetReflogCommits(t *testing.T) {
Name: "checkout: moving from A to B",
Status: "reflog",
UnixTimestamp: 1643150483,
+ Parents: []string{"51baa8c1"},
},
},
expectedOnlyObtainedNew: true,
@@ -106,13 +114,14 @@ func TestGetReflogCommits(t *testing.T) {
{
testName: "when passing filterPath",
runner: oscommands.NewFakeRunner(t).
- Expect(`git log -g --abbrev=20 --format="%h %ct %gs" --follow -- "path"`, reflogOutput, nil),
+ Expect(`git -c log.showSignature=false log -g --abbrev=40 --format="%h%x00%ct%x00%gs%x00%p" --follow -- "path"`, reflogOutput, nil),
lastReflogCommit: &models.Commit{
Sha: "c3c4b66b64c97ffeecde",
Name: "checkout: moving from B to A",
Status: "reflog",
UnixTimestamp: 1643150483,
+ Parents: []string{"51baa8c1"},
},
filterPath: "path",
expectedCommits: []*models.Commit{
@@ -121,6 +130,7 @@ func TestGetReflogCommits(t *testing.T) {
Name: "checkout: moving from A to B",
Status: "reflog",
UnixTimestamp: 1643150483,
+ Parents: []string{"51baa8c1"},
},
},
expectedOnlyObtainedNew: true,
@@ -129,7 +139,7 @@ func TestGetReflogCommits(t *testing.T) {
{
testName: "when command returns error",
runner: oscommands.NewFakeRunner(t).
- Expect(`git log -g --abbrev=20 --format="%h %ct %gs"`, "", errors.New("haha")),
+ Expect(`git -c log.showSignature=false log -g --abbrev=40 --format="%h%x00%ct%x00%gs%x00%p"`, "", errors.New("haha")),
lastReflogCommit: nil,
filterPath: "",
diff --git a/pkg/commands/loaders/remotes.go b/pkg/commands/loaders/remotes.go
index bd1fe0b6a..1323560f5 100644
--- a/pkg/commands/loaders/remotes.go
+++ b/pkg/commands/loaders/remotes.go
@@ -3,9 +3,9 @@ package loaders
import (
"fmt"
"regexp"
- "sort"
"strings"
+ "github.com/jesseduffield/generics/slices"
gogit "github.com/jesseduffield/go-git/v5"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
@@ -42,37 +42,35 @@ func (self *RemoteLoader) GetRemotes() ([]*models.Remote, error) {
}
// first step is to get our remotes from go-git
- remotes := make([]*models.Remote, len(goGitRemotes))
- for i, goGitRemote := range goGitRemotes {
+ remotes := slices.Map(goGitRemotes, func(goGitRemote *gogit.Remote) *models.Remote {
remoteName := goGitRemote.Config().Name
re := regexp.MustCompile(fmt.Sprintf(`(?m)^\s*%s\/([\S]+)`, remoteName))
matches := re.FindAllStringSubmatch(remoteBranchesStr, -1)
- branches := make([]*models.RemoteBranch, len(matches))
- for j, match := range matches {
- branches[j] = &models.RemoteBranch{
+ branches := slices.Map(matches, func(match []string) *models.RemoteBranch {
+ return &models.RemoteBranch{
Name: match[1],
RemoteName: remoteName,
}
- }
+ })
- remotes[i] = &models.Remote{
+ return &models.Remote{
Name: goGitRemote.Config().Name,
Urls: goGitRemote.Config().URLs,
Branches: branches,
}
- }
+ })
// now lets sort our remotes by name alphabetically
- sort.Slice(remotes, func(i, j int) bool {
+ slices.SortFunc(remotes, func(a, b *models.Remote) bool {
// we want origin at the top because we'll be most likely to want it
- if remotes[i].Name == "origin" {
+ if a.Name == "origin" {
return true
}
- if remotes[j].Name == "origin" {
+ if b.Name == "origin" {
return false
}
- return strings.ToLower(remotes[i].Name) < strings.ToLower(remotes[j].Name)
+ return strings.ToLower(a.Name) < strings.ToLower(b.Name)
})
return remotes, nil
diff --git a/pkg/commands/loaders/stash.go b/pkg/commands/loaders/stash.go
index 689bf30ce..66cfeaa3e 100644
--- a/pkg/commands/loaders/stash.go
+++ b/pkg/commands/loaders/stash.go
@@ -5,6 +5,7 @@ import (
"strconv"
"strings"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
@@ -65,11 +66,9 @@ outer:
func (self *StashLoader) getUnfilteredStashEntries() []*models.StashEntry {
rawString, _ := self.cmd.New("git stash list --pretty='%gs'").DontLog().RunWithOutput()
- stashEntries := []*models.StashEntry{}
- for i, line := range utils.SplitLines(rawString) {
- stashEntries = append(stashEntries, self.stashEntryFromLine(line, i))
- }
- return stashEntries
+ return slices.MapWithIndex(utils.SplitLines(rawString), func(line string, index int) *models.StashEntry {
+ return self.stashEntryFromLine(line, index)
+ })
}
func (c *StashLoader) stashEntryFromLine(line string, index int) *models.StashEntry {
diff --git a/pkg/commands/loaders/tags.go b/pkg/commands/loaders/tags.go
index 45b08a002..8e5063c34 100644
--- a/pkg/commands/loaders/tags.go
+++ b/pkg/commands/loaders/tags.go
@@ -1,8 +1,7 @@
package loaders
import (
- "strings"
-
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
@@ -27,25 +26,18 @@ func NewTagLoader(
func (self *TagLoader) GetTags() ([]*models.Tag, error) {
// get remote branches, sorted by creation date (descending)
// see: https://git-scm.com/docs/git-tag#Documentation/git-tag.txt---sortltkeygt
- remoteBranchesStr, err := self.cmd.New(`git tag --list --sort=-creatordate`).DontLog().RunWithOutput()
+ tagsOutput, err := self.cmd.New(`git tag --list --sort=-creatordate`).DontLog().RunWithOutput()
if err != nil {
return nil, err
}
- content := utils.TrimTrailingNewline(remoteBranchesStr)
- if content == "" {
- return nil, nil
- }
+ split := utils.SplitLines(tagsOutput)
- split := strings.Split(content, "\n")
-
- // first step is to get our remotes from go-git
- tags := make([]*models.Tag, len(split))
- for i, tagName := range split {
- tags[i] = &models.Tag{
+ tags := slices.Map(split, func(tagName string) *models.Tag {
+ return &models.Tag{
Name: tagName,
}
- }
+ })
return tags, nil
}
diff --git a/pkg/commands/loaders/tags_test.go b/pkg/commands/loaders/tags_test.go
new file mode 100644
index 000000000..5394fa3a8
--- /dev/null
+++ b/pkg/commands/loaders/tags_test.go
@@ -0,0 +1,68 @@
+package loaders
+
+import (
+ "testing"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/stretchr/testify/assert"
+)
+
+const tagsOutput = `v0.34
+v0.33
+v0.32.2
+v0.32.1
+v0.32
+testtag
+`
+
+func TestGetTags(t *testing.T) {
+ type scenario struct {
+ testName string
+ runner *oscommands.FakeCmdObjRunner
+ expectedTags []*models.Tag
+ expectedError error
+ }
+
+ scenarios := []scenario{
+ {
+ testName: "should return no tags if there are none",
+ runner: oscommands.NewFakeRunner(t).
+ Expect(`git tag --list --sort=-creatordate`, "", nil),
+ expectedTags: []*models.Tag{},
+ expectedError: nil,
+ },
+ {
+ testName: "should return tags if present",
+ runner: oscommands.NewFakeRunner(t).
+ Expect(`git tag --list --sort=-creatordate`, tagsOutput, nil),
+ expectedTags: []*models.Tag{
+ {Name: "v0.34"},
+ {Name: "v0.33"},
+ {Name: "v0.32.2"},
+ {Name: "v0.32.1"},
+ {Name: "v0.32"},
+ {Name: "testtag"},
+ },
+ expectedError: nil,
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario := scenario
+ t.Run(scenario.testName, func(t *testing.T) {
+ loader := &TagLoader{
+ Common: utils.NewDummyCommon(),
+ cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner),
+ }
+
+ tags, err := loader.GetTags()
+
+ assert.Equal(t, scenario.expectedTags, tags)
+ assert.Equal(t, scenario.expectedError, err)
+
+ scenario.runner.CheckForMissingCalls()
+ })
+ }
+}
diff --git a/pkg/commands/models/branch.go b/pkg/commands/models/branch.go
index 3cdf5ad6d..49bb801fa 100644
--- a/pkg/commands/models/branch.go
+++ b/pkg/commands/models/branch.go
@@ -5,11 +5,12 @@ package models
type Branch struct {
Name string
// the displayname is something like '(HEAD detached at 123asdf)', whereas in that case the name would be '123asdf'
- DisplayName string
- Recency string
- Pushables string
- Pullables string
- Head bool
+ DisplayName string
+ Recency string
+ Pushables string
+ Pullables string
+ UpstreamGone bool
+ Head bool
// if we have a named remote locally this will be the name of that remote e.g.
// 'origin' or 'tiwood'. If we don't have the remote locally it'll look like
// 'git@github.com:tiwood/lazygit.git'
@@ -17,10 +18,18 @@ type Branch struct {
UpstreamBranch string
}
+func (b *Branch) FullRefName() string {
+ return "refs/heads/" + b.Name
+}
+
func (b *Branch) RefName() string {
return b.Name
}
+func (b *Branch) ParentRefName() string {
+ return b.RefName() + "^"
+}
+
func (b *Branch) ID() string {
return b.RefName()
}
@@ -39,6 +48,10 @@ func (b *Branch) RemoteBranchStoredLocally() bool {
return b.IsTrackingRemote() && b.Pushables != "?" && b.Pullables != "?"
}
+func (b *Branch) RemoteBranchNotStoredLocally() bool {
+ return b.IsTrackingRemote() && b.Pushables == "?" && b.Pullables == "?"
+}
+
func (b *Branch) MatchesUpstream() bool {
return b.RemoteBranchStoredLocally() && b.Pushables == "0" && b.Pullables == "0"
}
diff --git a/pkg/commands/models/commit.go b/pkg/commands/models/commit.go
index 9f9184f2e..3502fab4f 100644
--- a/pkg/commands/models/commit.go
+++ b/pkg/commands/models/commit.go
@@ -6,6 +6,9 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
)
+// Special commit hash for empty tree object
+const EmptyTreeCommitHash = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
+
// Commit : A git commit
type Commit struct {
Sha string
@@ -14,7 +17,8 @@ type Commit struct {
Action string // one of "", "pick", "edit", "squash", "reword", "drop", "fixup"
Tags []string
ExtraInfo string // something like 'HEAD -> master, tag: v0.15.2'
- Author string
+ AuthorName string // something like 'Jesse Duffield'
+ AuthorEmail string // something like 'jessedduffield@gmail.com'
UnixTimestamp int64
// SHAs of parent commits (will be multiple if it's a merge commit)
@@ -25,10 +29,25 @@ func (c *Commit) ShortSha() string {
return utils.ShortSha(c.Sha)
}
+func (c *Commit) FullRefName() string {
+ return c.Sha
+}
+
func (c *Commit) RefName() string {
return c.Sha
}
+func (c *Commit) ParentRefName() string {
+ if c.IsFirstCommit() {
+ return EmptyTreeCommitHash
+ }
+ return c.RefName() + "^"
+}
+
+func (c *Commit) IsFirstCommit() bool {
+ return len(c.Parents) == 0
+}
+
func (c *Commit) ID() string {
return c.RefName()
}
diff --git a/pkg/commands/models/file.go b/pkg/commands/models/file.go
index c5e76949a..4589f91fa 100644
--- a/pkg/commands/models/file.go
+++ b/pkg/commands/models/file.go
@@ -2,6 +2,7 @@ package models
import (
"github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/samber/lo"
)
// File : A file from git status
@@ -27,6 +28,7 @@ type IFile interface {
GetHasStagedChanges() bool
GetIsTracked() bool
GetPath() string
+ GetPreviousPath() string
}
func (f *File) IsRename() bool {
@@ -85,3 +87,52 @@ func (f *File) GetPath() string {
// TODO: remove concept of name; just use path
return f.Name
}
+
+func (f *File) GetPreviousPath() string {
+ return f.PreviousName
+}
+
+type StatusFields struct {
+ HasStagedChanges bool
+ HasUnstagedChanges bool
+ Tracked bool
+ Deleted bool
+ Added bool
+ HasMergeConflicts bool
+ HasInlineMergeConflicts bool
+ ShortStatus string
+}
+
+func SetStatusFields(file *File, shortStatus string) {
+ derived := deriveStatusFields(shortStatus)
+
+ file.HasStagedChanges = derived.HasStagedChanges
+ file.HasUnstagedChanges = derived.HasUnstagedChanges
+ file.Tracked = derived.Tracked
+ file.Deleted = derived.Deleted
+ file.Added = derived.Added
+ file.HasMergeConflicts = derived.HasMergeConflicts
+ file.HasInlineMergeConflicts = derived.HasInlineMergeConflicts
+ file.ShortStatus = derived.ShortStatus
+}
+
+// shortStatus is something like '??' or 'A '
+func deriveStatusFields(shortStatus string) StatusFields {
+ stagedChange := shortStatus[0:1]
+ unstagedChange := shortStatus[1:2]
+ tracked := !lo.Contains([]string{"??", "A ", "AM"}, shortStatus)
+ hasStagedChanges := !lo.Contains([]string{" ", "U", "?"}, stagedChange)
+ hasInlineMergeConflicts := lo.Contains([]string{"UU", "AA"}, shortStatus)
+ hasMergeConflicts := hasInlineMergeConflicts || lo.Contains([]string{"DD", "AU", "UA", "UD", "DU"}, shortStatus)
+
+ return StatusFields{
+ HasStagedChanges: hasStagedChanges,
+ HasUnstagedChanges: unstagedChange != " ",
+ Tracked: tracked,
+ Deleted: unstagedChange == "D" || stagedChange == "D",
+ Added: unstagedChange == "A" || !tracked,
+ HasMergeConflicts: hasMergeConflicts,
+ HasInlineMergeConflicts: hasInlineMergeConflicts,
+ ShortStatus: shortStatus,
+ }
+}
diff --git a/pkg/commands/models/remote_branch.go b/pkg/commands/models/remote_branch.go
index bee004fdb..6a26f05f9 100644
--- a/pkg/commands/models/remote_branch.go
+++ b/pkg/commands/models/remote_branch.go
@@ -10,10 +10,18 @@ func (r *RemoteBranch) FullName() string {
return r.RemoteName + "/" + r.Name
}
+func (r *RemoteBranch) FullRefName() string {
+ return "refs/remotes/" + r.FullName()
+}
+
func (r *RemoteBranch) RefName() string {
return r.FullName()
}
+func (r *RemoteBranch) ParentRefName() string {
+ return r.RefName() + "^"
+}
+
func (r *RemoteBranch) ID() string {
return r.RefName()
}
diff --git a/pkg/commands/models/stash_entry.go b/pkg/commands/models/stash_entry.go
index efda6bc77..e70dfbf09 100644
--- a/pkg/commands/models/stash_entry.go
+++ b/pkg/commands/models/stash_entry.go
@@ -8,10 +8,18 @@ type StashEntry struct {
Name string
}
+func (s *StashEntry) FullRefName() string {
+ return s.RefName()
+}
+
func (s *StashEntry) RefName() string {
return fmt.Sprintf("stash@{%d}", s.Index)
}
+func (s *StashEntry) ParentRefName() string {
+ return s.RefName() + "^"
+}
+
func (s *StashEntry) ID() string {
return s.RefName()
}
diff --git a/pkg/commands/models/tag.go b/pkg/commands/models/tag.go
index 2fb024e66..25d8754f5 100644
--- a/pkg/commands/models/tag.go
+++ b/pkg/commands/models/tag.go
@@ -5,10 +5,18 @@ type Tag struct {
Name string
}
+func (t *Tag) FullRefName() string {
+ return "refs/tags/" + t.RefName()
+}
+
func (t *Tag) RefName() string {
return t.Name
}
+func (t *Tag) ParentRefName() string {
+ return t.RefName() + "^"
+}
+
func (t *Tag) ID() string {
return t.RefName()
}
diff --git a/pkg/commands/oscommands/cmd_obj.go b/pkg/commands/oscommands/cmd_obj.go
index 3e55359de..1a801c6fe 100644
--- a/pkg/commands/oscommands/cmd_obj.go
+++ b/pkg/commands/oscommands/cmd_obj.go
@@ -2,6 +2,8 @@ package oscommands
import (
"os/exec"
+
+ "github.com/sasha-s/go-deadlock"
)
// A command object is a general way to represent a command to be run on the
@@ -50,6 +52,9 @@ type ICmdObj interface {
PromptOnCredentialRequest() ICmdObj
FailOnCredentialRequest() ICmdObj
+ WithMutex(mutex *deadlock.Mutex) ICmdObj
+ Mutex() *deadlock.Mutex
+
GetCredentialStrategy() CredentialStrategy
}
@@ -70,6 +75,9 @@ type CmdObj struct {
// if set to true, it means we might be asked to enter a username/password by this command.
credentialStrategy CredentialStrategy
+
+ // can be set so that we don't run certain commands simultaneously
+ mutex *deadlock.Mutex
}
type CredentialStrategy int
@@ -132,6 +140,16 @@ func (self *CmdObj) IgnoreEmptyError() ICmdObj {
return self
}
+func (self *CmdObj) Mutex() *deadlock.Mutex {
+ return self.mutex
+}
+
+func (self *CmdObj) WithMutex(mutex *deadlock.Mutex) ICmdObj {
+ self.mutex = mutex
+
+ return self
+}
+
func (self *CmdObj) ShouldIgnoreEmptyError() bool {
return self.ignoreEmptyError
}
diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go
index e1a38d80f..8311f9eb7 100644
--- a/pkg/commands/oscommands/cmd_obj_runner.go
+++ b/pkg/commands/oscommands/cmd_obj_runner.go
@@ -34,6 +34,11 @@ type cmdObjRunner struct {
var _ ICmdObjRunner = &cmdObjRunner{}
func (self *cmdObjRunner) Run(cmdObj ICmdObj) error {
+ if cmdObj.Mutex() != nil {
+ cmdObj.Mutex().Lock()
+ defer cmdObj.Mutex().Unlock()
+ }
+
if cmdObj.GetCredentialStrategy() != NONE {
return self.runWithCredentialHandling(cmdObj)
}
@@ -42,17 +47,14 @@ func (self *cmdObjRunner) Run(cmdObj ICmdObj) error {
return self.runAndStream(cmdObj)
}
- _, err := self.RunWithOutput(cmdObj)
+ _, err := self.RunWithOutputAux(cmdObj)
return err
}
func (self *cmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
- if cmdObj.ShouldStreamOutput() {
- err := self.runAndStream(cmdObj)
- // for now we're not capturing output, just because it would take a little more
- // effort and there's currently no use case for it. Some commands call RunWithOutput
- // but ignore the output, hence why we've got this check here.
- return "", err
+ if cmdObj.Mutex() != nil {
+ cmdObj.Mutex().Lock()
+ defer cmdObj.Mutex().Unlock()
}
if cmdObj.GetCredentialStrategy() != NONE {
@@ -63,6 +65,18 @@ func (self *cmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
return "", err
}
+ if cmdObj.ShouldStreamOutput() {
+ err := self.runAndStream(cmdObj)
+ // for now we're not capturing output, just because it would take a little more
+ // effort and there's currently no use case for it. Some commands call RunWithOutput
+ // but ignore the output, hence why we've got this check here.
+ return "", err
+ }
+
+ return self.RunWithOutputAux(cmdObj)
+}
+
+func (self *cmdObjRunner) RunWithOutputAux(cmdObj ICmdObj) (string, error) {
self.log.WithField("command", cmdObj.ToString()).Debug("RunCommand")
if cmdObj.ShouldLog() {
@@ -77,6 +91,11 @@ func (self *cmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
}
func (self *cmdObjRunner) RunAndProcessLines(cmdObj ICmdObj, onLine func(line string) (bool, error)) error {
+ if cmdObj.Mutex() != nil {
+ cmdObj.Mutex().Lock()
+ defer cmdObj.Mutex().Unlock()
+ }
+
if cmdObj.GetCredentialStrategy() != NONE {
return errors.New("cannot call RunAndProcessLines with credential strategy. If you're seeing this then a contributor to Lazygit has accidentally called this method! Please raise an issue")
}
@@ -104,7 +123,7 @@ func (self *cmdObjRunner) RunAndProcessLines(cmdObj ICmdObj, onLine func(line st
return err
}
if stop {
- _ = cmd.Process.Kill()
+ _ = Kill(cmd)
break
}
}
@@ -188,12 +207,15 @@ func (self *cmdObjRunner) runAndStreamAux(
cmdObj ICmdObj,
onRun func(*cmdHandler, io.Writer),
) error {
+ // if we're streaming this we don't want any fancy terminal stuff
+ cmdObj.AddEnvVars("TERM=dumb")
+
cmdWriter := self.guiIO.newCmdWriterFn()
if cmdObj.ShouldLog() {
self.logCmdObj(cmdObj)
}
- self.log.WithField("command", cmdObj.ToString()).Info("RunCommand")
+ self.log.WithField("command", cmdObj.ToString()).Debug("RunCommand")
cmd := cmdObj.GetCmd()
var stderr bytes.Buffer
@@ -204,6 +226,9 @@ func (self *cmdObjRunner) runAndStreamAux(
return err
}
+ var stdout bytes.Buffer
+ handler.stdoutPipe = io.TeeReader(handler.stdoutPipe, &stdout)
+
defer func() {
if closeErr := handler.close(); closeErr != nil {
self.log.Error(closeErr)
@@ -215,10 +240,14 @@ func (self *cmdObjRunner) runAndStreamAux(
err = cmd.Wait()
if err != nil {
errStr := stderr.String()
- if cmdObj.ShouldIgnoreEmptyError() && errStr == "" {
+ if errStr != "" {
+ return errors.New(errStr)
+ }
+
+ if cmdObj.ShouldIgnoreEmptyError() {
return nil
}
- return errors.New(stderr.String())
+ return errors.New(stdout.String())
}
return nil
diff --git a/pkg/commands/oscommands/cmd_obj_runner_win.go b/pkg/commands/oscommands/cmd_obj_runner_win.go
index 9e3d1fd02..6893a2535 100644
--- a/pkg/commands/oscommands/cmd_obj_runner_win.go
+++ b/pkg/commands/oscommands/cmd_obj_runner_win.go
@@ -7,12 +7,13 @@ import (
"bytes"
"io"
"os/exec"
- "sync"
+
+ "github.com/sasha-s/go-deadlock"
)
type Buffer struct {
b bytes.Buffer
- m sync.Mutex
+ m deadlock.Mutex
}
func (b *Buffer) Read(p []byte) (n int, err error) {
@@ -20,6 +21,7 @@ func (b *Buffer) Read(p []byte) (n int, err error) {
defer b.m.Unlock()
return b.b.Read(p)
}
+
func (b *Buffer) Write(p []byte) (n int, err error) {
b.m.Lock()
defer b.m.Unlock()
diff --git a/pkg/commands/oscommands/copy.go b/pkg/commands/oscommands/copy.go
index 131e9bc6b..f68590280 100644
--- a/pkg/commands/oscommands/copy.go
+++ b/pkg/commands/oscommands/copy.go
@@ -72,7 +72,7 @@ func CopyFile(src, dst string) (err error) {
return
}
- return
+ return //nolint: nakedret
}
// CopyDir recursively copies a directory tree, attempting to preserve permissions.
@@ -133,5 +133,5 @@ func CopyDir(src string, dst string) (err error) {
}
}
- return
+ return //nolint: nakedret
}
diff --git a/pkg/commands/oscommands/dummies.go b/pkg/commands/oscommands/dummies.go
index 158e9a9c1..b5978e4b5 100644
--- a/pkg/commands/oscommands/dummies.go
+++ b/pkg/commands/oscommands/dummies.go
@@ -2,12 +2,13 @@ package oscommands
import (
"github.com/jesseduffield/lazygit/pkg/common"
+ "github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/utils"
)
// NewDummyOSCommand creates a new dummy OSCommand for testing
func NewDummyOSCommand() *OSCommand {
- osCmd := NewOSCommand(utils.NewDummyCommon(), dummyPlatform, NewNullGuiIO(utils.NewDummyLog()))
+ osCmd := NewOSCommand(utils.NewDummyCommon(), config.NewDummyAppConfig(), dummyPlatform, NewNullGuiIO(utils.NewDummyLog()))
return osCmd
}
@@ -56,7 +57,7 @@ var dummyPlatform = &Platform{
}
func NewDummyOSCommandWithRunner(runner *FakeCmdObjRunner) *OSCommand {
- osCommand := NewOSCommand(utils.NewDummyCommon(), dummyPlatform, NewNullGuiIO(utils.NewDummyLog()))
+ osCommand := NewOSCommand(utils.NewDummyCommon(), config.NewDummyAppConfig(), dummyPlatform, NewNullGuiIO(utils.NewDummyLog()))
osCommand.Cmd = NewDummyCmdObjBuilder(runner)
return osCommand
diff --git a/pkg/commands/oscommands/fake_cmd_obj_runner.go b/pkg/commands/oscommands/fake_cmd_obj_runner.go
index b542bfee3..d06861251 100644
--- a/pkg/commands/oscommands/fake_cmd_obj_runner.go
+++ b/pkg/commands/oscommands/fake_cmd_obj_runner.go
@@ -21,7 +21,7 @@ type FakeCmdObjRunner struct {
var _ ICmdObjRunner = &FakeCmdObjRunner{}
-func NewFakeRunner(t *testing.T) *FakeCmdObjRunner {
+func NewFakeRunner(t *testing.T) *FakeCmdObjRunner { //nolint:thelper
return &FakeCmdObjRunner{t: t}
}
diff --git a/pkg/commands/oscommands/os.go b/pkg/commands/oscommands/os.go
index 53f5bd6f6..2a7cc1328 100644
--- a/pkg/commands/oscommands/os.go
+++ b/pkg/commands/oscommands/os.go
@@ -12,7 +12,10 @@ import (
"github.com/go-errors/errors"
"github.com/atotto/clipboard"
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/kill"
"github.com/jesseduffield/lazygit/pkg/common"
+ "github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@@ -26,6 +29,8 @@ type OSCommand struct {
removeFileFn func(string) error
Cmd *CmdObjBuilder
+
+ tempDir string
}
// Platform stores the os state
@@ -38,13 +43,14 @@ type Platform struct {
}
// NewOSCommand os command runner
-func NewOSCommand(common *common.Common, platform *Platform, guiIO *guiIO) *OSCommand {
+func NewOSCommand(common *common.Common, config config.AppConfigurer, platform *Platform, guiIO *guiIO) *OSCommand {
c := &OSCommand{
Common: common,
Platform: platform,
getenvFn: os.Getenv,
removeFileFn: os.RemoveAll,
guiIO: guiIO,
+ tempDir: config.GetTempDir(),
}
runner := &cmdObjRunner{log: common.Log, guiIO: guiIO}
@@ -72,9 +78,14 @@ func FileType(path string) string {
}
func (c *OSCommand) OpenFile(filename string) error {
+ return c.OpenFileAtLine(filename, 1)
+}
+
+func (c *OSCommand) OpenFileAtLine(filename string, lineNumber int) error {
commandTemplate := c.UserConfig.OS.OpenCommand
templateValues := map[string]string{
"filename": c.Quote(filename),
+ "line": fmt.Sprintf("%d", lineNumber),
}
command := utils.ResolvePlaceholderString(commandTemplate, templateValues)
return c.Cmd.NewShell(command).Run()
@@ -98,40 +109,40 @@ func (c *OSCommand) Quote(message string) string {
// AppendLineToFile adds a new line in file
func (c *OSCommand) AppendLineToFile(filename, line string) error {
c.LogCommand(fmt.Sprintf("Appending '%s' to file '%s'", line, filename), false)
- f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
+ f, err := os.OpenFile(filename, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return utils.WrapError(err)
}
defer f.Close()
- _, err = f.WriteString("\n" + line)
+ info, err := os.Stat(filename)
+ if err != nil {
+ return utils.WrapError(err)
+ }
+
+ if info.Size() > 0 {
+ // read last char
+ buf := make([]byte, 1)
+ if _, err := f.ReadAt(buf, info.Size()-1); err != nil {
+ return utils.WrapError(err)
+ }
+
+ // if the last byte of the file is not a newline, add it
+ if []byte("\n")[0] != buf[0] {
+ _, err = f.WriteString("\n")
+ }
+ }
+
+ if err == nil {
+ _, err = f.WriteString(line + "\n")
+ }
+
if err != nil {
return utils.WrapError(err)
}
return nil
}
-// CreateTempFile writes a string to a new temp file and returns the file's name
-func (c *OSCommand) CreateTempFile(filename, content string) (string, error) {
- tmpfile, err := ioutil.TempFile("", filename)
- if err != nil {
- c.Log.Error(err)
- return "", utils.WrapError(err)
- }
- c.LogCommand(fmt.Sprintf("Creating temp file '%s'", tmpfile.Name()), false)
-
- if _, err := tmpfile.WriteString(content); err != nil {
- c.Log.Error(err)
- return "", utils.WrapError(err)
- }
- if err := tmpfile.Close(); err != nil {
- c.Log.Error(err)
- return "", utils.WrapError(err)
- }
-
- return tmpfile.Name(), nil
-}
-
// CreateFileWithContent creates a file with the given content
func (c *OSCommand) CreateFileWithContent(path string, content string) error {
c.LogCommand(fmt.Sprintf("Creating file '%s'", path), false)
@@ -140,7 +151,7 @@ func (c *OSCommand) CreateFileWithContent(path string, content string) error {
return err
}
- if err := ioutil.WriteFile(path, []byte(content), 0644); err != nil {
+ if err := ioutil.WriteFile(path, []byte(content), 0o644); err != nil {
c.Log.Error(err)
return utils.WrapError(err)
}
@@ -168,15 +179,11 @@ func (c *OSCommand) FileExists(path string) (bool, error) {
// PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C
func (c *OSCommand) PipeCommands(commandStrings ...string) error {
- cmds := make([]*exec.Cmd, len(commandStrings))
- logCmdStr := ""
- for i, str := range commandStrings {
- if i > 0 {
- logCmdStr += " | "
- }
- logCmdStr += str
- cmds[i] = c.Cmd.New(str).GetCmd()
- }
+ cmds := slices.Map(commandStrings, func(cmdString string) *exec.Cmd {
+ return c.Cmd.New(cmdString).GetCmd()
+ })
+
+ logCmdStr := strings.Join(commandStrings, " | ")
c.LogCommand(logCmdStr, true)
for i := 0; i < len(cmds)-1; i++ {
@@ -230,12 +237,14 @@ func (c *OSCommand) PipeCommands(commandStrings ...string) error {
return nil
}
+// Kill kills a process. If the process has Setpgid == true, then we have anticipated that it might spawn its own child processes, so we've given it a process group ID (PGID) equal to its process id (PID) and given its child processes will inherit the PGID, we can kill that group, rather than killing the process itself.
func Kill(cmd *exec.Cmd) error {
- if cmd.Process == nil {
- // somebody got to it before we were able to, poor bastard
- return nil
- }
- return cmd.Process.Kill()
+ return kill.Kill(cmd)
+}
+
+// PrepareForChildren sets Setpgid to true on the cmd, so that when we run it as a subprocess, we can kill its group rather than the process itself. This is because some commands, like `docker-compose logs` spawn multiple children processes, and killing the parent process isn't sufficient for killing those child processes. We set the group id here, and then in subprocess.go we check if the group id is set and if so, we kill the whole group rather than just the one process.
+func PrepareForChildren(cmd *exec.Cmd) {
+ kill.PrepareForChildren(cmd)
}
func (c *OSCommand) CopyToClipboard(str string) error {
@@ -255,8 +264,8 @@ func (c *OSCommand) Getenv(key string) string {
return c.getenvFn(key)
}
-func GetTempDir() string {
- return filepath.Join(os.TempDir(), "lazygit")
+func (c *OSCommand) GetTempDir() string {
+ return c.tempDir
}
// GetLazygitPath returns the path of the currently executed file
diff --git a/pkg/commands/oscommands/os_test.go b/pkg/commands/oscommands/os_test.go
index efda5a3a1..969224405 100644
--- a/pkg/commands/oscommands/os_test.go
+++ b/pkg/commands/oscommands/os_test.go
@@ -3,39 +3,12 @@ package oscommands
import (
"io/ioutil"
"os"
+ "path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
-func TestOSCommandRunWithOutput(t *testing.T) {
- type scenario struct {
- command string
- test func(string, error)
- }
-
- scenarios := []scenario{
- {
- "echo -n '123'",
- func(output string, err error) {
- assert.NoError(t, err)
- assert.EqualValues(t, "123", output)
- },
- },
- {
- "rmdir unexisting-folder",
- func(output string, err error) {
- assert.Regexp(t, "rmdir.*unexisting-folder.*", err.Error())
- },
- },
- }
-
- for _, s := range scenarios {
- c := NewDummyOSCommand()
- s.test(c.Cmd.New(s.command).RunWithOutput())
- }
-}
-
func TestOSCommandRun(t *testing.T) {
type scenario struct {
command string
@@ -141,7 +114,7 @@ func TestOSCommandFileType(t *testing.T) {
{
"testDirectory",
func() {
- if err := os.Mkdir("testDirectory", 0644); err != nil {
+ if err := os.Mkdir("testDirectory", 0o644); err != nil {
panic(err)
}
},
@@ -165,34 +138,60 @@ func TestOSCommandFileType(t *testing.T) {
}
}
-func TestOSCommandCreateTempFile(t *testing.T) {
+func TestOSCommandAppendLineToFile(t *testing.T) {
type scenario struct {
- testName string
- filename string
- content string
- test func(string, error)
+ path string
+ setup func(string)
+ test func(string)
}
scenarios := []scenario{
{
- "valid case",
- "filename",
- "content",
- func(path string, err error) {
- assert.NoError(t, err)
-
- content, err := ioutil.ReadFile(path)
- assert.NoError(t, err)
-
- assert.Equal(t, "content", string(content))
+ filepath.Join(os.TempDir(), "testFile"),
+ func(path string) {
+ if err := ioutil.WriteFile(path, []byte("hello"), 0o600); err != nil {
+ panic(err)
+ }
+ },
+ func(output string) {
+ assert.EqualValues(t, "hello\nworld\n", output)
+ },
+ },
+ {
+ filepath.Join(os.TempDir(), "emptyTestFile"),
+ func(path string) {
+ if err := ioutil.WriteFile(path, []byte(""), 0o600); err != nil {
+ panic(err)
+ }
+ },
+ func(output string) {
+ assert.EqualValues(t, "world\n", output)
+ },
+ },
+ {
+ filepath.Join(os.TempDir(), "testFileWithNewline"),
+ func(path string) {
+ if err := ioutil.WriteFile(path, []byte("hello\n"), 0o600); err != nil {
+ panic(err)
+ }
+ },
+ func(output string) {
+ assert.EqualValues(t, "hello\nworld\n", output)
},
},
}
for _, s := range scenarios {
- s := s
- t.Run(s.testName, func(t *testing.T) {
- s.test(NewDummyOSCommand().CreateTempFile(s.filename, s.content))
- })
+ s.setup(s.path)
+ osCommand := NewDummyOSCommand()
+ if err := osCommand.AppendLineToFile(s.path, "world"); err != nil {
+ panic(err)
+ }
+ f, err := ioutil.ReadFile(s.path)
+ if err != nil {
+ panic(err)
+ }
+ s.test(string(f))
+ _ = os.RemoveAll(s.path)
}
}
diff --git a/pkg/commands/oscommands/os_test_default.go b/pkg/commands/oscommands/os_test_default.go
index f4c1221ed..39a1226d2 100644
--- a/pkg/commands/oscommands/os_test_default.go
+++ b/pkg/commands/oscommands/os_test_default.go
@@ -10,6 +10,34 @@ import (
"github.com/stretchr/testify/assert"
)
+func TestOSCommandRunWithOutput(t *testing.T) {
+ type scenario struct {
+ command string
+ test func(string, error)
+ }
+
+ scenarios := []scenario{
+ {
+ "echo -n '123'",
+ func(output string, err error) {
+ assert.NoError(t, err)
+ assert.EqualValues(t, "123", output)
+ },
+ },
+ {
+ "rmdir unexisting-folder",
+ func(output string, err error) {
+ assert.Regexp(t, "rmdir.*unexisting-folder.*", err.Error())
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ c := NewDummyOSCommand()
+ s.test(c.Cmd.New(s.command).RunWithOutput())
+ }
+}
+
func TestOSCommandOpenFileDarwin(t *testing.T) {
type scenario struct {
filename string
diff --git a/pkg/commands/patch/hunk.go b/pkg/commands/patch/hunk.go
index bbb2d54ff..98d932126 100644
--- a/pkg/commands/patch/hunk.go
+++ b/pkg/commands/patch/hunk.go
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/samber/lo"
)
type PatchHunk struct {
@@ -54,7 +55,7 @@ func (hunk *PatchHunk) updatedLines(lineIndices []int, reverse bool) []string {
if line == "" {
break
}
- isLineSelected := utils.IncludesInt(lineIndices, lineIdx)
+ isLineSelected := lo.Contains(lineIndices, lineIdx)
firstChar, content := line[:1], line[1:]
transformedFirstChar := transformedFirstChar(firstChar, reverse, isLineSelected)
diff --git a/pkg/commands/patch/patch_manager.go b/pkg/commands/patch/patch_manager.go
index c8e16a7fd..91adfecb4 100644
--- a/pkg/commands/patch/patch_manager.go
+++ b/pkg/commands/patch/patch_manager.go
@@ -4,7 +4,9 @@ import (
"sort"
"strings"
- "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/jesseduffield/generics/maps"
+ "github.com/jesseduffield/generics/slices"
+ "github.com/samber/lo"
"github.com/sirupsen/logrus"
)
@@ -26,8 +28,10 @@ type fileInfo struct {
diff string
}
-type applyPatchFunc func(patch string, flags ...string) error
-type loadFileDiffFunc func(from string, to string, reverse bool, filename string, plain bool) (string, error)
+type (
+ applyPatchFunc func(patch string, flags ...string) error
+ loadFileDiffFunc func(from string, to string, reverse bool, filename string, plain bool) (string, error)
+)
// PatchManager 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
type PatchManager struct {
@@ -70,8 +74,9 @@ func (p *PatchManager) Start(from, to string, reverse bool, canRebase bool) {
func (p *PatchManager) addFileWhole(info *fileInfo) {
info.mode = WHOLE
lineCount := len(strings.Split(info.diff, "\n"))
- info.includedLineIndices = make([]int, lineCount)
// add every line index
+ // TODO: add tests and then use lo.Range to simplify
+ info.includedLineIndices = make([]int, lineCount)
for i := 0; i < lineCount; i++ {
info.includedLineIndices[i] = i
}
@@ -138,7 +143,7 @@ func (p *PatchManager) AddFileLineRange(filename string, firstLineIdx, lastLineI
return err
}
info.mode = PART
- info.includedLineIndices = utils.UnionInt(info.includedLineIndices, getIndicesForRange(firstLineIdx, lastLineIdx))
+ info.includedLineIndices = lo.Union(info.includedLineIndices, getIndicesForRange(firstLineIdx, lastLineIdx))
return nil
}
@@ -149,7 +154,7 @@ func (p *PatchManager) RemoveFileLineRange(filename string, firstLineIdx, lastLi
return err
}
info.mode = PART
- info.includedLineIndices = utils.DifferenceInt(info.includedLineIndices, getIndicesForRange(firstLineIdx, lastLineIdx))
+ info.includedLineIndices, _ = lo.Difference(info.includedLineIndices, getIndicesForRange(firstLineIdx, lastLineIdx))
if len(info.includedLineIndices) == 0 {
p.removeFile(info)
}
@@ -185,26 +190,20 @@ func (p *PatchManager) RenderPatchForFile(filename string, plain bool, reverse b
parser := NewPatchParser(p.Log, patch)
// not passing included lines because we don't want to see them in the secondary panel
- return parser.Render(-1, -1, nil)
+ return parser.Render(false, -1, -1, nil)
}
func (p *PatchManager) renderEachFilePatch(plain bool) []string {
// sort files by name then iterate through and render each patch
- filenames := make([]string, len(p.fileInfoMap))
- index := 0
- for filename := range p.fileInfoMap {
- filenames[index] = filename
- index++
- }
+ filenames := maps.Keys(p.fileInfoMap)
sort.Strings(filenames)
- output := []string{}
- for _, filename := range filenames {
- patch := p.RenderPatchForFile(filename, plain, false, true)
- if patch != "" {
- output = append(output, patch)
- }
- }
+ patches := slices.Map(filenames, func(filename string) string {
+ return p.RenderPatchForFile(filename, plain, false, true)
+ })
+ output := slices.Filter(patches, func(patch string) bool {
+ return patch != ""
+ })
return output
}
diff --git a/pkg/commands/patch/patch_modifier.go b/pkg/commands/patch/patch_modifier.go
index 2109ad1f0..2d060ec18 100644
--- a/pkg/commands/patch/patch_modifier.go
+++ b/pkg/commands/patch/patch_modifier.go
@@ -8,8 +8,10 @@ import (
"github.com/sirupsen/logrus"
)
-var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
-var patchHeaderRegexp = regexp.MustCompile(`(?ms)(^diff.*?)^@@`)
+var (
+ hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
+ patchHeaderRegexp = regexp.MustCompile(`(?ms)(^diff.*?)^@@`)
+)
func GetHeaderFromDiff(diff string) string {
match := patchHeaderRegexp.FindStringSubmatch(diff)
diff --git a/pkg/commands/patch/patch_parser.go b/pkg/commands/patch/patch_parser.go
index c2be120c9..90b2ea13e 100644
--- a/pkg/commands/patch/patch_parser.go
+++ b/pkg/commands/patch/patch_parser.go
@@ -4,9 +4,10 @@ import (
"regexp"
"strings"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/theme"
- "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/samber/lo"
"github.com/sirupsen/logrus"
)
@@ -182,29 +183,39 @@ func parsePatch(patch string) ([]int, []int, []*PatchLine) {
}
// Render returns the coloured string of the diff with any selected lines highlighted
-func (p *PatchParser) Render(firstLineIndex int, lastLineIndex int, incLineIndices []int) string {
- renderedLines := make([]string, len(p.PatchLines))
- for index, patchLine := range p.PatchLines {
- selected := index >= firstLineIndex && index <= lastLineIndex
- included := utils.IncludesInt(incLineIndices, index)
- renderedLines[index] = patchLine.render(selected, included)
- }
- result := strings.Join(renderedLines, "\n")
- if strings.TrimSpace(utils.Decolorise(result)) == "" {
+func (p *PatchParser) Render(isFocused bool, firstLineIndex int, lastLineIndex int, incLineIndices []int) string {
+ contentToDisplay := slices.Some(p.PatchLines, func(line *PatchLine) bool {
+ return line.Content != ""
+ })
+ if !contentToDisplay {
return ""
}
+
+ renderedLines := slices.MapWithIndex(p.PatchLines, func(patchLine *PatchLine, index int) string {
+ selected := isFocused && index >= firstLineIndex && index <= lastLineIndex
+ included := lo.Contains(incLineIndices, index)
+ return patchLine.render(selected, included)
+ })
+
+ result := strings.Join(renderedLines, "\n")
+
return result
}
-// PlainRenderLines returns the non-coloured string of diff part from firstLineIndex to
-// lastLineIndex
-func (p *PatchParser) PlainRenderLines(firstLineIndex, lastLineIndex int) string {
- linesToCopy := p.PatchLines[firstLineIndex : lastLineIndex+1]
+func (p *PatchParser) RenderPlain() string {
+ return renderLinesPlain(p.PatchLines)
+}
- renderedLines := make([]string, len(linesToCopy))
- for index, line := range linesToCopy {
- renderedLines[index] = line.Content
- }
+// RenderLinesPlain returns the non-coloured string of diff part from firstLineIndex to
+// lastLineIndex
+func (p *PatchParser) RenderLinesPlain(firstLineIndex, lastLineIndex int) string {
+ return renderLinesPlain(p.PatchLines[firstLineIndex : lastLineIndex+1])
+}
+
+func renderLinesPlain(lines []*PatchLine) string {
+ renderedLines := slices.Map(lines, func(line *PatchLine) string {
+ return line.Content
+ })
return strings.Join(renderedLines, "\n")
}
diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go
index 98620ad43..9806bcf58 100644
--- a/pkg/config/app_config.go
+++ b/pkg/config/app_config.go
@@ -1,6 +1,7 @@
package config
import (
+ "fmt"
"io/ioutil"
"os"
"path/filepath"
@@ -14,7 +15,6 @@ import (
type AppConfig struct {
Debug bool `long:"debug" env:"DEBUG" default:"false"`
Version string `long:"version" env:"VERSION" default:"unversioned"`
- Commit string `long:"commit" env:"COMMIT"`
BuildDate string `long:"build-date" env:"BUILD_DATE"`
Name string `long:"name" env:"NAME" default:"lazygit"`
BuildSource string `long:"build-source" env:"BUILD_SOURCE" default:""`
@@ -27,15 +27,11 @@ type AppConfig struct {
IsNewRepo bool
}
-// AppConfigurer interface allows individual app config structs to inherit Fields
-// from AppConfig and still be used by lazygit.
type AppConfigurer interface {
GetDebug() bool
// build info
GetVersion() string
- GetCommit() string
- GetBuildDate() string
GetName() string
GetBuildSource() string
@@ -43,13 +39,22 @@ type AppConfigurer interface {
GetUserConfigPaths() []string
GetUserConfigDir() string
ReloadUserConfig() error
+ GetTempDir() string
GetAppState() *AppState
SaveAppState() error
}
// NewAppConfig makes a new app config
-func NewAppConfig(name, version, commit, date string, buildSource string, debuggingFlag bool) (*AppConfig, error) {
+func NewAppConfig(
+ name string,
+ version,
+ commit,
+ date string,
+ buildSource string,
+ debuggingFlag bool,
+ tempDir string,
+) (*AppConfig, error) {
configDir, err := findOrCreateConfigDir()
if err != nil && !os.IsPermission(err) {
return nil, err
@@ -70,21 +75,14 @@ func NewAppConfig(name, version, commit, date string, buildSource string, debugg
return nil, err
}
- if os.Getenv("DEBUG") == "TRUE" {
- debuggingFlag = true
- }
-
- tempDir := filepath.Join(os.TempDir(), "lazygit")
-
appState, err := loadAppState()
if err != nil {
return nil, err
}
appConfig := &AppConfig{
- Name: "lazygit",
+ Name: name,
Version: version,
- Commit: commit,
BuildDate: date,
Debug: debuggingFlag,
BuildSource: buildSource,
@@ -123,7 +121,7 @@ func configDirForVendor(vendor string) string {
func findOrCreateConfigDir() (string, error) {
folder := ConfigDir()
- return folder, os.MkdirAll(folder, 0755)
+ return folder, os.MkdirAll(folder, 0o755)
}
func loadUserConfigWithDefaults(configFiles []string) (*UserConfig, error) {
@@ -160,7 +158,7 @@ func loadUserConfig(configFiles []string, base *UserConfig) (*UserConfig, error)
}
if err := yaml.Unmarshal(content, base); err != nil {
- return nil, err
+ return nil, fmt.Errorf("The config at `%s` couldn't be parsed, please inspect it before opening up an issue.\n%w", path, err)
}
}
@@ -175,14 +173,6 @@ func (c *AppConfig) GetVersion() string {
return c.Version
}
-func (c *AppConfig) GetCommit() string {
- return c.Commit
-}
-
-func (c *AppConfig) GetBuildDate() string {
- return c.BuildDate
-}
-
func (c *AppConfig) GetName() string {
return c.Name
}
@@ -221,6 +211,10 @@ func (c *AppConfig) ReloadUserConfig() error {
return nil
}
+func (c *AppConfig) GetTempDir() string {
+ return c.TempDir
+}
+
func configFilePath(filename string) (string, error) {
folder, err := findOrCreateConfigDir()
if err != nil {
@@ -232,7 +226,7 @@ func configFilePath(filename string) (string, error) {
var ConfigFilename = "config.yml"
-// ConfigFilename returns the filename of the deafult config file
+// ConfigFilename returns the filename of the default config file
func (c *AppConfig) ConfigFilename() string {
return filepath.Join(c.UserConfigDir, ConfigFilename)
}
@@ -249,7 +243,7 @@ func (c *AppConfig) SaveAppState() error {
return err
}
- err = ioutil.WriteFile(filepath, marshalledAppState, 0644)
+ err = ioutil.WriteFile(filepath, marshalledAppState, 0o644)
if err != nil && os.IsPermission(err) {
// apparently when people have read-only permissions they prefer us to fail silently
return nil
diff --git a/pkg/config/config_default_platform.go b/pkg/config/config_default_platform.go
index 32b1df473..6784f0ce2 100644
--- a/pkg/config/config_default_platform.go
+++ b/pkg/config/config_default_platform.go
@@ -7,8 +7,8 @@ package config
func GetPlatformDefaultConfig() OSConfig {
return OSConfig{
EditCommand: ``,
- EditCommandTemplate: `{{editor}} {{filename}}`,
- OpenCommand: "open {{filename}}",
+ EditCommandTemplate: "",
+ OpenCommand: "open -- {{filename}}",
OpenLinkCommand: "open {{link}}",
}
}
diff --git a/pkg/config/config_linux.go b/pkg/config/config_linux.go
index dd5708a53..8fdc0c473 100644
--- a/pkg/config/config_linux.go
+++ b/pkg/config/config_linux.go
@@ -1,10 +1,29 @@
package config
+import (
+ "io/ioutil"
+ "strings"
+)
+
+func isWSL() bool {
+ data, err := ioutil.ReadFile("/proc/sys/kernel/osrelease")
+ return err == nil && strings.Contains(string(data), "microsoft")
+}
+
// GetPlatformDefaultConfig gets the defaults for the platform
func GetPlatformDefaultConfig() OSConfig {
+ if isWSL() {
+ return OSConfig{
+ EditCommand: ``,
+ EditCommandTemplate: "",
+ OpenCommand: `powershell.exe start explorer.exe {{filename}} >/dev/null`,
+ OpenLinkCommand: `powershell.exe start {{link}} >/dev/null`,
+ }
+ }
+
return OSConfig{
EditCommand: ``,
- EditCommandTemplate: `{{editor}} {{filename}}`,
+ EditCommandTemplate: "",
OpenCommand: `xdg-open {{filename}} >/dev/null`,
OpenLinkCommand: `xdg-open {{link}} >/dev/null`,
}
diff --git a/pkg/config/config_windows.go b/pkg/config/config_windows.go
index 301eecec1..12ecb8dff 100644
--- a/pkg/config/config_windows.go
+++ b/pkg/config/config_windows.go
@@ -4,7 +4,7 @@ package config
func GetPlatformDefaultConfig() OSConfig {
return OSConfig{
EditCommand: ``,
- EditCommandTemplate: `{{editor}} {{filename}}`,
+ EditCommandTemplate: "",
OpenCommand: `start "" {{filename}}`,
OpenLinkCommand: `start "" {{link}}`,
}
diff --git a/pkg/config/dummies.go b/pkg/config/dummies.go
index bd973909a..08150c765 100644
--- a/pkg/config/dummies.go
+++ b/pkg/config/dummies.go
@@ -7,14 +7,11 @@ import (
// NewDummyAppConfig creates a new dummy AppConfig for testing
func NewDummyAppConfig() *AppConfig {
appConfig := &AppConfig{
- Name: "lazygit",
- Version: "unversioned",
- Commit: "",
- BuildDate: "",
- Debug: false,
- BuildSource: "",
- UserConfig: GetDefaultConfig(),
- AppState: &AppState{},
+ Name: "lazygit",
+ Version: "unversioned",
+ Debug: false,
+ UserConfig: GetDefaultConfig(),
+ AppState: &AppState{},
}
_ = yaml.Unmarshal([]byte{}, appConfig.AppState)
return appConfig
diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go
index 9efc73d90..05020a3fa 100644
--- a/pkg/config/user_config.go
+++ b/pkg/config/user_config.go
@@ -1,5 +1,9 @@
package config
+import (
+ "time"
+)
+
type UserConfig struct {
Gui GuiConfig `yaml:"gui"`
Git GitConfig `yaml:"git"`
@@ -11,11 +15,12 @@ type UserConfig struct {
QuitOnTopLevelReturn bool `yaml:"quitOnTopLevelReturn"`
Keybinding KeybindingConfig `yaml:"keybinding"`
// OS determines what defaults are set for opening files and links
- OS OSConfig `yaml:"os,omitempty"`
- DisableStartupPopups bool `yaml:"disableStartupPopups"`
- CustomCommands []CustomCommand `yaml:"customCommands"`
- Services map[string]string `yaml:"services"`
- NotARepository string `yaml:"notARepository"`
+ OS OSConfig `yaml:"os,omitempty"`
+ DisableStartupPopups bool `yaml:"disableStartupPopups"`
+ CustomCommands []CustomCommand `yaml:"customCommands"`
+ Services map[string]string `yaml:"services"`
+ NotARepository string `yaml:"notARepository"`
+ PromptToReturnFromSubprocess bool `yaml:"promptToReturnFromSubprocess"`
}
type RefresherConfig struct {
@@ -35,6 +40,7 @@ type GuiConfig struct {
ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"`
MainPanelSplitMode string `yaml:"mainPanelSplitMode"`
Language string `yaml:"language"`
+ TimeFormat string `yaml:"timeFormat"`
Theme ThemeConfig `yaml:"theme"`
CommitLength CommitLengthConfig `yaml:"commitLength"`
SkipNoStagedFilesWarning bool `yaml:"skipNoStagedFilesWarning"`
@@ -42,7 +48,10 @@ type GuiConfig struct {
ShowFileTree bool `yaml:"showFileTree"`
ShowRandomTip bool `yaml:"showRandomTip"`
ShowCommandLog bool `yaml:"showCommandLog"`
+ ShowBottomLine bool `yaml:"showBottomLine"`
+ ShowIcons bool `yaml:"showIcons"`
CommandLogSize int `yaml:"commandLogSize"`
+ SplitDiff string `yaml:"splitDiff"`
}
type ThemeConfig struct {
@@ -54,6 +63,7 @@ type ThemeConfig struct {
SelectedRangeBgColor []string `yaml:"selectedRangeBgColor"`
CherryPickedCommitBgColor []string `yaml:"cherryPickedCommitBgColor"`
CherryPickedCommitFgColor []string `yaml:"cherryPickedCommitFgColor"`
+ UnstagedChangesColor []string `yaml:"unstagedChangesColor"`
}
type CommitLengthConfig struct {
@@ -66,6 +76,7 @@ type GitConfig struct {
Merging MergingConfig `yaml:"merging"`
SkipHookPrefix string `yaml:"skipHookPrefix"`
AutoFetch bool `yaml:"autoFetch"`
+ AutoRefresh bool `yaml:"autoRefresh"`
BranchLogCmd string `yaml:"branchLogCmd"`
AllBranchesLogCmd string `yaml:"allBranchesLogCmd"`
OverrideGpg bool `yaml:"overrideGpg"`
@@ -94,8 +105,9 @@ type MergingConfig struct {
}
type LogConfig struct {
- Order string `yaml:"order"` // one of date-order, author-date-order, topo-order
- ShowGraph string `yaml:"showGraph"` // one of always, never, when-maximised
+ Order string `yaml:"order"` // one of date-order, author-date-order, topo-order
+ ShowGraph string `yaml:"showGraph"` // one of always, never, when-maximised
+ ShowWholeGraph bool `yaml:"showWholeGraph"`
}
type CommitPrefixConfig struct {
@@ -199,7 +211,7 @@ type KeybindingFilesConfig struct {
CommitChangesWithoutHook string `yaml:"commitChangesWithoutHook"`
AmendLastCommit string `yaml:"amendLastCommit"`
CommitChangesWithEditor string `yaml:"commitChangesWithEditor"`
- IgnoreFile string `yaml:"ignoreFile"`
+ IgnoreOrExcludeFile string `yaml:"IgnoreOrExcludeFile"`
RefreshFiles string `yaml:"refreshFiles"`
StashAllChanges string `yaml:"stashAllChanges"`
ViewStashOptions string `yaml:"viewStashOptions"`
@@ -228,28 +240,29 @@ type KeybindingBranchesConfig struct {
}
type KeybindingCommitsConfig struct {
- SquashDown string `yaml:"squashDown"`
- RenameCommit string `yaml:"renameCommit"`
- RenameCommitWithEditor string `yaml:"renameCommitWithEditor"`
- ViewResetOptions string `yaml:"viewResetOptions"`
- MarkCommitAsFixup string `yaml:"markCommitAsFixup"`
- CreateFixupCommit string `yaml:"createFixupCommit"`
- SquashAboveCommits string `yaml:"squashAboveCommits"`
- MoveDownCommit string `yaml:"moveDownCommit"`
- MoveUpCommit string `yaml:"moveUpCommit"`
- AmendToCommit string `yaml:"amendToCommit"`
- PickCommit string `yaml:"pickCommit"`
- RevertCommit string `yaml:"revertCommit"`
- CherryPickCopy string `yaml:"cherryPickCopy"`
- CherryPickCopyRange string `yaml:"cherryPickCopyRange"`
- PasteCommits string `yaml:"pasteCommits"`
- TagCommit string `yaml:"tagCommit"`
- CheckoutCommit string `yaml:"checkoutCommit"`
- ResetCherryPick string `yaml:"resetCherryPick"`
- CopyCommitMessageToClipboard string `yaml:"copyCommitMessageToClipboard"`
- OpenLogMenu string `yaml:"openLogMenu"`
- OpenInBrowser string `yaml:"openInBrowser"`
- ViewBisectOptions string `yaml:"viewBisectOptions"`
+ SquashDown string `yaml:"squashDown"`
+ RenameCommit string `yaml:"renameCommit"`
+ RenameCommitWithEditor string `yaml:"renameCommitWithEditor"`
+ ViewResetOptions string `yaml:"viewResetOptions"`
+ MarkCommitAsFixup string `yaml:"markCommitAsFixup"`
+ CreateFixupCommit string `yaml:"createFixupCommit"`
+ SquashAboveCommits string `yaml:"squashAboveCommits"`
+ MoveDownCommit string `yaml:"moveDownCommit"`
+ MoveUpCommit string `yaml:"moveUpCommit"`
+ AmendToCommit string `yaml:"amendToCommit"`
+ ResetCommitAuthor string `yaml:"resetCommitAuthor"`
+ PickCommit string `yaml:"pickCommit"`
+ RevertCommit string `yaml:"revertCommit"`
+ CherryPickCopy string `yaml:"cherryPickCopy"`
+ CherryPickCopyRange string `yaml:"cherryPickCopyRange"`
+ PasteCommits string `yaml:"pasteCommits"`
+ TagCommit string `yaml:"tagCommit"`
+ CheckoutCommit string `yaml:"checkoutCommit"`
+ ResetCherryPick string `yaml:"resetCherryPick"`
+ CopyCommitAttributeToClipboard string `yaml:"copyCommitAttributeToClipboard"`
+ OpenLogMenu string `yaml:"openLogMenu"`
+ OpenInBrowser string `yaml:"openInBrowser"`
+ ViewBisectOptions string `yaml:"viewBisectOptions"`
}
type KeybindingStashConfig struct {
@@ -265,6 +278,7 @@ type KeybindingMainConfig struct {
ToggleDragSelectAlt string `yaml:"toggleDragSelect-alt"`
ToggleSelectHunk string `yaml:"toggleSelectHunk"`
PickBothHunks string `yaml:"pickBothHunks"`
+ EditSelectHunk string `yaml:"editSelectHunk"`
}
type KeybindingSubmodulesConfig struct {
@@ -297,15 +311,19 @@ type CustomCommand struct {
LoadingText string `yaml:"loadingText"`
Description string `yaml:"description"`
Stream bool `yaml:"stream"`
+ ShowOutput bool `yaml:"showOutput"`
}
type CustomCommandPrompt struct {
- Type string `yaml:"type"` // one of 'input' and 'menu'
+ Type string `yaml:"type"` // one of 'input', 'menu', or 'confirm'
Title string `yaml:"title"`
- // this only apply to prompts
+ // this only apply to input prompts
InitialValue string `yaml:"initialValue"`
+ // this only applies to confirm prompts
+ Body string `yaml:"body"`
+
// this only applies to menus
Options []CustomCommandMenuOption
@@ -334,29 +352,35 @@ func GetDefaultConfig() *UserConfig {
ExpandFocusedSidePanel: false,
MainPanelSplitMode: "flexible",
Language: "auto",
+ TimeFormat: time.RFC822,
Theme: ThemeConfig{
LightTheme: false,
ActiveBorderColor: []string{"green", "bold"},
InactiveBorderColor: []string{"white"},
OptionsTextColor: []string{"blue"},
- SelectedLineBgColor: []string{"default"},
+ SelectedLineBgColor: []string{"blue"},
SelectedRangeBgColor: []string{"blue"},
- CherryPickedCommitBgColor: []string{"blue"},
- CherryPickedCommitFgColor: []string{"cyan"},
+ CherryPickedCommitBgColor: []string{"cyan"},
+ CherryPickedCommitFgColor: []string{"blue"},
+ UnstagedChangesColor: []string{"red"},
},
CommitLength: CommitLengthConfig{Show: true},
SkipNoStagedFilesWarning: false,
ShowListFooter: true,
ShowCommandLog: true,
+ ShowBottomLine: true,
ShowFileTree: true,
ShowRandomTip: true,
+ ShowIcons: false,
CommandLogSize: 8,
+ SplitDiff: "auto",
},
Git: GitConfig{
Paging: PagingConfig{
ColorArg: "always",
Pager: "",
- UseConfig: false},
+ UseConfig: false,
+ },
Commit: CommitConfig{
SignOff: false,
},
@@ -365,11 +389,13 @@ func GetDefaultConfig() *UserConfig {
Args: "",
},
Log: LogConfig{
- Order: "topo-order",
- ShowGraph: "when-maximised",
+ Order: "topo-order",
+ ShowGraph: "when-maximised",
+ ShowWholeGraph: false,
},
SkipHookPrefix: "WIP",
AutoFetch: true,
+ AutoRefresh: true,
BranchLogCmd: "git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --",
AllBranchesLogCmd: "git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium",
DisableForcePushing: false,
@@ -467,7 +493,7 @@ func GetDefaultConfig() *UserConfig {
CommitChangesWithoutHook: "w",
AmendLastCommit: "A",
CommitChangesWithEditor: "C",
- IgnoreFile: "i",
+ IgnoreOrExcludeFile: "i",
RefreshFiles: "r",
StashAllChanges: "s",
ViewStashOptions: "S",
@@ -494,28 +520,29 @@ func GetDefaultConfig() *UserConfig {
FetchRemote: "f",
},
Commits: KeybindingCommitsConfig{
- SquashDown: "s",
- RenameCommit: "r",
- RenameCommitWithEditor: "R",
- ViewResetOptions: "g",
- MarkCommitAsFixup: "f",
- CreateFixupCommit: "F",
- SquashAboveCommits: "S",
- MoveDownCommit: "",
- MoveUpCommit: "",
- AmendToCommit: "A",
- PickCommit: "p",
- RevertCommit: "t",
- CherryPickCopy: "c",
- CherryPickCopyRange: "C",
- PasteCommits: "v",
- TagCommit: "T",
- CheckoutCommit: "",
- ResetCherryPick: "",
- CopyCommitMessageToClipboard: "",
- OpenLogMenu: "",
- OpenInBrowser: "o",
- ViewBisectOptions: "b",
+ SquashDown: "s",
+ RenameCommit: "r",
+ RenameCommitWithEditor: "R",
+ ViewResetOptions: "g",
+ MarkCommitAsFixup: "f",
+ CreateFixupCommit: "F",
+ SquashAboveCommits: "S",
+ MoveDownCommit: "",
+ MoveUpCommit: "",
+ AmendToCommit: "A",
+ ResetCommitAuthor: "a",
+ PickCommit: "p",
+ RevertCommit: "t",
+ CherryPickCopy: "c",
+ CherryPickCopyRange: "C",
+ PasteCommits: "v",
+ TagCommit: "T",
+ CheckoutCommit: "",
+ ResetCherryPick: "",
+ CopyCommitAttributeToClipboard: "y",
+ OpenLogMenu: "",
+ OpenInBrowser: "o",
+ ViewBisectOptions: "b",
},
Stash: KeybindingStashConfig{
PopStash: "g",
@@ -528,6 +555,7 @@ func GetDefaultConfig() *UserConfig {
ToggleDragSelectAlt: "V",
ToggleSelectHunk: "a",
PickBothHunks: "b",
+ EditSelectHunk: "E",
},
Submodules: KeybindingSubmodulesConfig{
Init: "i",
@@ -535,10 +563,11 @@ func GetDefaultConfig() *UserConfig {
BulkMenu: "b",
},
},
- OS: GetPlatformDefaultConfig(),
- DisableStartupPopups: false,
- CustomCommands: []CustomCommand(nil),
- Services: map[string]string(nil),
- NotARepository: "prompt",
+ OS: GetPlatformDefaultConfig(),
+ DisableStartupPopups: false,
+ CustomCommands: []CustomCommand(nil),
+ Services: map[string]string(nil),
+ NotARepository: "prompt",
+ PromptToReturnFromSubprocess: true,
}
}
diff --git a/pkg/env/env.go b/pkg/env/env.go
index 9c0f4816f..8d7993a9a 100644
--- a/pkg/env/env.go
+++ b/pkg/env/env.go
@@ -1,6 +1,10 @@
package env
-import "os"
+import (
+ "os"
+)
+
+// This package encapsulates accessing/mutating the ENV of the program.
func GetGitDirEnv() string {
return os.Getenv("GIT_DIR")
diff --git a/pkg/gui/app_status_manager.go b/pkg/gui/app_status_manager.go
index e625fcad2..c2d72c5ac 100644
--- a/pkg/gui/app_status_manager.go
+++ b/pkg/gui/app_status_manager.go
@@ -1,35 +1,34 @@
package gui
import (
- "sync"
"time"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/sasha-s/go-deadlock"
)
+// statusManager's job is to handle rendering of loading states and toast notifications
+// that you see at the bottom left of the screen.
+type statusManager struct {
+ statuses []appStatus
+ nextId int
+ mutex deadlock.Mutex
+}
+
type appStatus struct {
message string
statusType string
id int
}
-type statusManager struct {
- statuses []appStatus
- nextId int
- mutex sync.Mutex
-}
-
func (m *statusManager) removeStatus(id int) {
m.mutex.Lock()
defer m.mutex.Unlock()
- newStatuses := []appStatus{}
- for _, status := range m.statuses {
- if status.id != id {
- newStatuses = append(newStatuses, status)
- }
- }
- m.statuses = newStatuses
+ m.statuses = slices.Filter(m.statuses, func(status appStatus) bool {
+ return status.id != id
+ })
}
func (m *statusManager) addWaitingStatus(message string) int {
@@ -83,7 +82,7 @@ func (m *statusManager) getStatusString() string {
return topStatus.message
}
-func (gui *Gui) raiseToast(message string) {
+func (gui *Gui) toast(message string) {
gui.statusManager.addToastStatus(message)
gui.renderAppStatus()
@@ -95,7 +94,7 @@ func (gui *Gui) renderAppStatus() {
defer ticker.Stop()
for range ticker.C {
appStatus := gui.statusManager.getStatusString()
- gui.OnUIThread(func() error {
+ gui.c.OnUIThread(func() error {
return gui.renderString(gui.Views.AppStatus, appStatus)
})
@@ -106,8 +105,8 @@ func (gui *Gui) renderAppStatus() {
})
}
-// WithWaitingStatus wraps a function and shows a waiting status while the function is still executing
-func (gui *Gui) WithWaitingStatus(message string, f func() error) error {
+// withWaitingStatus wraps a function and shows a waiting status while the function is still executing
+func (gui *Gui) withWaitingStatus(message string, f func() error) error {
go utils.Safe(func() {
id := gui.statusManager.addWaitingStatus(message)
@@ -118,8 +117,8 @@ func (gui *Gui) WithWaitingStatus(message string, f func() error) error {
gui.renderAppStatus()
if err := f(); err != nil {
- gui.OnUIThread(func() error {
- return gui.surfaceError(err)
+ gui.c.OnUIThread(func() error {
+ return gui.c.Error(err)
})
}
})
diff --git a/pkg/gui/arrangement.go b/pkg/gui/arrangement.go
index fa2e7f29d..89236ae5c 100644
--- a/pkg/gui/arrangement.go
+++ b/pkg/gui/arrangement.go
@@ -2,154 +2,17 @@ package gui
import (
"github.com/jesseduffield/lazygit/pkg/gui/boxlayout"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/mattn/go-runewidth"
)
+// In this file we use the boxlayout package, along with knowledge about the app's state,
+// to arrange the windows (i.e. panels) on the screen.
+
const INFO_SECTION_PADDING = " "
-func (gui *Gui) mainSectionChildren() []*boxlayout.Box {
- currentWindow := gui.currentWindow()
-
- // 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 !gui.isMainPanelSplit() || (gui.State.ScreenMode == SCREEN_FULL && currentWindow == "main") {
- return []*boxlayout.Box{
- {
- Window: "main",
- Weight: 1,
- },
- }
- }
-
- main := "main"
- secondary := "secondary"
- if gui.secondaryViewFocused() {
- // when you think you've focused the secondary view, we've actually just swapped them around in the layout
- main, secondary = secondary, main
- }
-
- return []*boxlayout.Box{
- {
- Window: main,
- Weight: 1,
- },
- {
- Window: secondary,
- Weight: 1,
- },
- }
-}
-
-func (gui *Gui) getMidSectionWeights() (int, int) {
- currentWindow := gui.currentWindow()
-
- // we originally specified this as a ratio i.e. .20 would correspond to a weight of 1 against 4
- sidePanelWidthRatio := gui.UserConfig.Gui.SidePanelWidth
- // we could make this better by creating ratios like 2:3 rather than always 1:something
- mainSectionWeight := int(1/sidePanelWidthRatio) - 1
- sideSectionWeight := 1
-
- if gui.splitMainPanelSideBySide() {
- mainSectionWeight = 5 // need to shrink side panel to make way for main panels if side-by-side
- }
-
- if currentWindow == "main" {
- if gui.State.ScreenMode == SCREEN_HALF || gui.State.ScreenMode == SCREEN_FULL {
- sideSectionWeight = 0
- }
- } else {
- if gui.State.ScreenMode == SCREEN_HALF {
- mainSectionWeight = 1
- } else if gui.State.ScreenMode == SCREEN_FULL {
- mainSectionWeight = 0
- }
- }
-
- return sideSectionWeight, mainSectionWeight
-}
-
-func (gui *Gui) infoSectionChildren(informationStr string, appStatus string) []*boxlayout.Box {
- if gui.State.Searching.isSearching {
- return []*boxlayout.Box{
- {
- Window: "searchPrefix",
- Size: len(SEARCH_PREFIX),
- },
- {
- Window: "search",
- Weight: 1,
- },
- }
- }
-
- result := []*boxlayout.Box{}
-
- if len(appStatus) > 0 {
- result = append(result,
- &boxlayout.Box{
- Window: "appStatus",
- Size: len(appStatus) + len(INFO_SECTION_PADDING),
- },
- )
- }
-
- result = append(result,
- []*boxlayout.Box{
- {
- Window: "options",
- Weight: 1,
- },
- {
- Window: "information",
- // unlike appStatus, informationStr has various colors so we need to decolorise before taking the length
- Size: len(INFO_SECTION_PADDING) + len(utils.Decolorise(informationStr)),
- },
- }...,
- )
-
- return result
-}
-
-func (gui *Gui) splitMainPanelSideBySide() bool {
- if !gui.isMainPanelSplit() {
- return false
- }
-
- mainPanelSplitMode := gui.UserConfig.Gui.MainPanelSplitMode
- width, height := gui.g.Size()
-
- switch mainPanelSplitMode {
- case "vertical":
- return false
- case "horizontal":
- return true
- default:
- if width < 200 && height > 30 { // 2 80 character width panels + 40 width for side panel
- return false
- } else {
- return true
- }
- }
-}
-
-func (gui *Gui) getExtrasWindowSize(screenHeight int) int {
- if !gui.ShowExtrasWindow {
- return 0
- }
-
- var baseSize int
- if gui.currentStaticContext().GetKey() == COMMAND_LOG_CONTEXT_KEY {
- baseSize = 1000 // my way of saying 'fill the available space'
- } else if screenHeight < 40 {
- baseSize = 1
- } else {
- baseSize = gui.UserConfig.Gui.CommandLogSize
- }
-
- frameSize := 2
- return baseSize + frameSize
-}
-
func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions {
width, height := gui.g.Size()
@@ -168,6 +31,12 @@ func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map
extrasWindowSize := gui.getExtrasWindowSize(height)
+ showInfoSection := gui.c.UserConfig.Gui.ShowBottomLine || (gui.State.Searching.isSearching || gui.isAnyModeActive())
+ infoSectionSize := 0
+ if showInfoSection {
+ infoSectionSize = 1
+ }
+
root := &boxlayout.Box{
Direction: boxlayout.ROW,
Children: []*boxlayout.Box{
@@ -199,13 +68,163 @@ func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map
},
{
Direction: boxlayout.COLUMN,
- Size: 1,
+ Size: infoSectionSize,
Children: gui.infoSectionChildren(informationStr, appStatus),
},
},
}
- return boxlayout.ArrangeWindows(root, 0, 0, width, height)
+ layerOneWindows := boxlayout.ArrangeWindows(root, 0, 0, width, height)
+ limitWindows := boxlayout.ArrangeWindows(&boxlayout.Box{Window: "limit"}, 0, 0, width, height)
+
+ return MergeMaps(layerOneWindows, limitWindows)
+}
+
+func MergeMaps[K comparable, V any](maps ...map[K]V) map[K]V {
+ result := map[K]V{}
+ for _, currMap := range maps {
+ for key, value := range currMap {
+ result[key] = value
+ }
+ }
+
+ return result
+}
+
+func (gui *Gui) mainSectionChildren() []*boxlayout.Box {
+ currentWindow := gui.currentWindow()
+
+ // 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 !gui.isMainPanelSplit() || (gui.State.ScreenMode == SCREEN_FULL && currentWindow == "main") {
+ return []*boxlayout.Box{
+ {
+ Window: "main",
+ Weight: 1,
+ },
+ }
+ }
+
+ return []*boxlayout.Box{
+ {
+ Window: "main",
+ Weight: 1,
+ },
+ {
+ Window: "secondary",
+ Weight: 1,
+ },
+ }
+}
+
+func (gui *Gui) getMidSectionWeights() (int, int) {
+ currentWindow := gui.currentWindow()
+
+ // we originally specified this as a ratio i.e. .20 would correspond to a weight of 1 against 4
+ sidePanelWidthRatio := gui.c.UserConfig.Gui.SidePanelWidth
+ // we could make this better by creating ratios like 2:3 rather than always 1:something
+ mainSectionWeight := int(1/sidePanelWidthRatio) - 1
+ sideSectionWeight := 1
+
+ if gui.splitMainPanelSideBySide() {
+ mainSectionWeight = 5 // need to shrink side panel to make way for main panels if side-by-side
+ }
+
+ if currentWindow == "main" {
+ if gui.State.ScreenMode == SCREEN_HALF || gui.State.ScreenMode == SCREEN_FULL {
+ sideSectionWeight = 0
+ }
+ } else {
+ if gui.State.ScreenMode == SCREEN_HALF {
+ mainSectionWeight = 1
+ } else if gui.State.ScreenMode == SCREEN_FULL {
+ mainSectionWeight = 0
+ }
+ }
+
+ return sideSectionWeight, mainSectionWeight
+}
+
+func (gui *Gui) infoSectionChildren(informationStr string, appStatus string) []*boxlayout.Box {
+ if gui.State.Searching.isSearching {
+ return []*boxlayout.Box{
+ {
+ Window: "searchPrefix",
+ Size: runewidth.StringWidth(SEARCH_PREFIX),
+ },
+ {
+ Window: "search",
+ Weight: 1,
+ },
+ }
+ }
+
+ result := []*boxlayout.Box{}
+
+ if len(appStatus) > 0 {
+ result = append(result,
+ &boxlayout.Box{
+ Window: "appStatus",
+ Size: runewidth.StringWidth(appStatus) + runewidth.StringWidth(INFO_SECTION_PADDING),
+ },
+ )
+ }
+
+ result = append(result,
+ []*boxlayout.Box{
+ {
+ Window: "options",
+ Weight: 1,
+ },
+ {
+ Window: "information",
+ // unlike appStatus, informationStr has various colors so we need to decolorise before taking the length
+ Size: runewidth.StringWidth(INFO_SECTION_PADDING) + runewidth.StringWidth(utils.Decolorise(informationStr)),
+ },
+ }...,
+ )
+
+ return result
+}
+
+func (gui *Gui) splitMainPanelSideBySide() bool {
+ if !gui.isMainPanelSplit() {
+ return false
+ }
+
+ mainPanelSplitMode := gui.c.UserConfig.Gui.MainPanelSplitMode
+ width, height := gui.g.Size()
+
+ switch mainPanelSplitMode {
+ case "vertical":
+ return false
+ case "horizontal":
+ return true
+ default:
+ if width < 200 && height > 30 { // 2 80 character width panels + 40 width for side panel
+ return false
+ } else {
+ return true
+ }
+ }
+}
+
+func (gui *Gui) getExtrasWindowSize(screenHeight int) int {
+ if !gui.ShowExtrasWindow {
+ return 0
+ }
+
+ var baseSize int
+ if gui.currentStaticContext().GetKey() == context.COMMAND_LOG_CONTEXT_KEY {
+ baseSize = 1000 // my way of saying 'fill the available space'
+ } else if screenHeight < 40 {
+ baseSize = 1
+ } else {
+ baseSize = gui.c.UserConfig.Gui.CommandLogSize
+ }
+
+ frameSize := 2
+ return baseSize + frameSize
}
// The stash window by default only contains one line so that it's not hogging
@@ -259,7 +278,7 @@ func (gui *Gui) sidePanelChildren(width int, height int) []*boxlayout.Box {
fullHeightBox("stash"),
}
} else if height >= 28 {
- accordionMode := gui.UserConfig.Gui.ExpandFocusedSidePanel
+ accordionMode := gui.c.UserConfig.Gui.ExpandFocusedSidePanel
accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box {
if accordionMode && defaultBox.Window == currentWindow {
return &boxlayout.Box{
@@ -311,6 +330,10 @@ func (gui *Gui) sidePanelChildren(width int, height int) []*boxlayout.Box {
}
}
+func (gui *Gui) getCyclableWindows() []string {
+ return []string{"status", "files", "branches", "commits", "stash"}
+}
+
func (gui *Gui) currentSideWindowName() string {
// there is always one and only one cyclable context in the context stack. We'll look from top to bottom
gui.State.ContextManager.RLock()
@@ -320,7 +343,7 @@ func (gui *Gui) currentSideWindowName() string {
reversedIdx := len(gui.State.ContextManager.ContextStack) - 1 - idx
context := gui.State.ContextManager.ContextStack[reversedIdx]
- if context.GetKind() == SIDE_CONTEXT {
+ if context.GetKind() == types.SIDE_CONTEXT {
return context.GetWindowName()
}
}
diff --git a/pkg/gui/basic_context.go b/pkg/gui/basic_context.go
deleted file mode 100644
index 1db80ee4a..000000000
--- a/pkg/gui/basic_context.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package gui
-
-type BasicContext struct {
- OnFocus func(opts ...OnFocusOpts) error
- OnFocusLost func() error
- OnRender func() error
- // this is for pushing some content to the main view
- OnRenderToMain func(opts ...OnFocusOpts) error
- Kind ContextKind
- Key ContextKey
- ViewName string
- WindowName string
- OnGetOptionsMap func() map[string]string
-
- ParentContext Context
- // we can't know on the calling end whether a Context is actually a nil value without reflection, so we're storing this flag here to tell us. There has got to be a better way around this
- hasParent bool
-}
-
-func (self *BasicContext) GetOptionsMap() map[string]string {
- if self.OnGetOptionsMap != nil {
- return self.OnGetOptionsMap()
- }
- return nil
-}
-
-func (self *BasicContext) SetParentContext(context Context) {
- self.ParentContext = context
- self.hasParent = true
-}
-
-func (self *BasicContext) GetParentContext() (Context, bool) {
- return self.ParentContext, self.hasParent
-}
-
-func (self *BasicContext) SetWindowName(windowName string) {
- self.WindowName = windowName
-}
-
-func (self *BasicContext) GetWindowName() string {
- windowName := self.WindowName
-
- if windowName != "" {
- return windowName
- }
-
- // TODO: actually set this for everything so we don't default to the view name
- return self.ViewName
-}
-
-func (self *BasicContext) HandleRender() error {
- if self.OnRender != nil {
- return self.OnRender()
- }
- return nil
-}
-
-func (self *BasicContext) GetViewName() string {
- return self.ViewName
-}
-
-func (self *BasicContext) HandleFocus(opts ...OnFocusOpts) error {
- if self.OnFocus != nil {
- if err := self.OnFocus(opts...); err != nil {
- return err
- }
- }
-
- if self.OnRenderToMain != nil {
- if err := self.OnRenderToMain(opts...); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func (self *BasicContext) HandleFocusLost() error {
- if self.OnFocusLost != nil {
- return self.OnFocusLost()
- }
- return nil
-}
-
-func (self *BasicContext) HandleRenderToMain() error {
- if self.OnRenderToMain != nil {
- return self.OnRenderToMain()
- }
-
- return nil
-}
-
-func (self *BasicContext) GetKind() ContextKind {
- return self.Kind
-}
-
-func (self *BasicContext) GetKey() ContextKey {
- return self.Key
-}
diff --git a/pkg/gui/bisect.go b/pkg/gui/bisect.go
deleted file mode 100644
index 5c46460ac..000000000
--- a/pkg/gui/bisect.go
+++ /dev/null
@@ -1,219 +0,0 @@
-package gui
-
-import (
- "fmt"
- "strings"
-
- "github.com/jesseduffield/lazygit/pkg/commands/git_commands"
- "github.com/jesseduffield/lazygit/pkg/commands/models"
-)
-
-func (gui *Gui) handleOpenBisectMenu() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- // no shame in getting this directly rather than using the cached value
- // given how cheap it is to obtain
- info := gui.Git.Bisect.GetInfo()
- commit := gui.getSelectedLocalCommit()
- if info.Started() {
- return gui.openMidBisectMenu(info, commit)
- } else {
- return gui.openStartBisectMenu(info, commit)
- }
-}
-
-func (gui *Gui) openMidBisectMenu(info *git_commands.BisectInfo, commit *models.Commit) error {
- // if there is not yet a 'current' bisect commit, or if we have
- // selected the current commit, we need to jump to the next 'current' commit
- // after we perform a bisect action. The reason we don't unconditionally jump
- // is that sometimes the user will want to go and mark a few commits as skipped
- // in a row and they wouldn't want to be jumped back to the current bisect
- // commit each time.
- // Originally we were allowing the user to, from the bisect menu, select whether
- // they were talking about the selected commit or the current bisect commit,
- // and that was a bit confusing (and required extra keypresses).
- selectCurrentAfter := info.GetCurrentSha() == "" || info.GetCurrentSha() == commit.Sha
- // we need to wait to reselect if our bisect commits aren't ancestors of our 'start'
- // ref, because we'll be reloading our commits in that case.
- waitToReselect := selectCurrentAfter && !gui.Git.Bisect.ReachableFromStart(info)
-
- menuItems := []*menuItem{
- {
- displayString: fmt.Sprintf(gui.Tr.Bisect.Mark, commit.ShortSha(), info.NewTerm()),
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.BisectMark)
- if err := gui.Git.Bisect.Mark(commit.Sha, info.NewTerm()); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.afterMark(selectCurrentAfter, waitToReselect)
- },
- },
- {
- displayString: fmt.Sprintf(gui.Tr.Bisect.Mark, commit.ShortSha(), info.OldTerm()),
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.BisectMark)
- if err := gui.Git.Bisect.Mark(commit.Sha, info.OldTerm()); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.afterMark(selectCurrentAfter, waitToReselect)
- },
- },
- {
- displayString: fmt.Sprintf(gui.Tr.Bisect.Skip, commit.ShortSha()),
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.BisectSkip)
- if err := gui.Git.Bisect.Skip(commit.Sha); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.afterMark(selectCurrentAfter, waitToReselect)
- },
- },
- {
- displayString: gui.Tr.Bisect.ResetOption,
- onPress: func() error {
- return gui.resetBisect()
- },
- },
- }
-
- return gui.createMenu(
- gui.Tr.Bisect.BisectMenuTitle,
- menuItems,
- createMenuOptions{showCancel: true},
- )
-}
-
-func (gui *Gui) openStartBisectMenu(info *git_commands.BisectInfo, commit *models.Commit) error {
- return gui.createMenu(
- gui.Tr.Bisect.BisectMenuTitle,
- []*menuItem{
- {
- displayString: fmt.Sprintf(gui.Tr.Bisect.MarkStart, commit.ShortSha(), info.NewTerm()),
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.StartBisect)
- if err := gui.Git.Bisect.Start(); err != nil {
- return gui.surfaceError(err)
- }
-
- if err := gui.Git.Bisect.Mark(commit.Sha, info.NewTerm()); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.postBisectCommandRefresh()
- },
- },
- {
- displayString: fmt.Sprintf(gui.Tr.Bisect.MarkStart, commit.ShortSha(), info.OldTerm()),
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.StartBisect)
- if err := gui.Git.Bisect.Start(); err != nil {
- return gui.surfaceError(err)
- }
-
- if err := gui.Git.Bisect.Mark(commit.Sha, info.OldTerm()); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.postBisectCommandRefresh()
- },
- },
- },
- createMenuOptions{showCancel: true},
- )
-}
-
-func (gui *Gui) resetBisect() error {
- return gui.ask(askOpts{
- title: gui.Tr.Bisect.ResetTitle,
- prompt: gui.Tr.Bisect.ResetPrompt,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.ResetBisect)
- if err := gui.Git.Bisect.Reset(); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.postBisectCommandRefresh()
- },
- })
-}
-
-func (gui *Gui) showBisectCompleteMessage(candidateShas []string) error {
- prompt := gui.Tr.Bisect.CompletePrompt
- if len(candidateShas) > 1 {
- prompt = gui.Tr.Bisect.CompletePromptIndeterminate
- }
-
- formattedCommits, err := gui.Git.Commit.GetCommitsOneline(candidateShas)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.Bisect.CompleteTitle,
- prompt: fmt.Sprintf(prompt, strings.TrimSpace(formattedCommits)),
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.ResetBisect)
- if err := gui.Git.Bisect.Reset(); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.postBisectCommandRefresh()
- },
- })
-}
-
-func (gui *Gui) afterMark(selectCurrent bool, waitToReselect bool) error {
- done, candidateShas, err := gui.Git.Bisect.IsDone()
- if err != nil {
- return gui.surfaceError(err)
- }
-
- if err := gui.afterBisectMarkRefresh(selectCurrent, waitToReselect); err != nil {
- return gui.surfaceError(err)
- }
-
- if done {
- return gui.showBisectCompleteMessage(candidateShas)
- }
-
- return nil
-}
-
-func (gui *Gui) postBisectCommandRefresh() error {
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{}})
-}
-
-func (gui *Gui) afterBisectMarkRefresh(selectCurrent bool, waitToReselect bool) error {
- selectFn := func() {
- if selectCurrent {
- gui.selectCurrentBisectCommit()
- }
- }
-
- if waitToReselect {
- return gui.refreshSidePanels(refreshOptions{mode: SYNC, scope: []RefreshableView{}, then: selectFn})
- } else {
- selectFn()
-
- return gui.postBisectCommandRefresh()
- }
-}
-
-func (gui *Gui) selectCurrentBisectCommit() {
- info := gui.Git.Bisect.GetInfo()
- if info.GetCurrentSha() != "" {
- // find index of commit with that sha, move cursor to that.
- for i, commit := range gui.State.Commits {
- if commit.Sha == info.GetCurrentSha() {
- gui.State.Contexts.BranchCommits.GetPanelState().SetSelectedLineIdx(i)
- _ = gui.State.Contexts.BranchCommits.HandleFocus()
- break
- }
- }
- }
-}
diff --git a/pkg/gui/boxlayout/boxlayout.go b/pkg/gui/boxlayout/boxlayout.go
index 36af2b2ab..4eb6f15e6 100644
--- a/pkg/gui/boxlayout/boxlayout.go
+++ b/pkg/gui/boxlayout/boxlayout.go
@@ -1,6 +1,10 @@
package boxlayout
-import "math"
+import (
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/samber/lo"
+)
type Dimensions struct {
X0 int
@@ -69,45 +73,12 @@ func ArrangeWindows(root *Box, x0, y0, width, height int) map[string]Dimensions
availableSize = height
}
- // work out size taken up by children
- reservedSize := 0
- totalWeight := 0
- for _, child := range children {
- // assuming either size or weight are non-zero
- reservedSize += child.Size
- totalWeight += child.Weight
- }
-
- remainingSize := availableSize - reservedSize
- if remainingSize < 0 {
- remainingSize = 0
- }
-
- unitSize := 0
- extraSize := 0
- if totalWeight > 0 {
- unitSize = remainingSize / totalWeight
- extraSize = remainingSize % totalWeight
- }
+ sizes := calcSizes(children, availableSize)
result := map[string]Dimensions{}
offset := 0
- for _, child := range children {
- var boxSize int
- if child.isStatic() {
- boxSize = child.Size
- // assuming that only one static child can have a size greater than the
- // available space. In that case we just crop the size to what's available
- if boxSize > availableSize {
- boxSize = availableSize
- }
- } else {
- // TODO: consider more evenly distributing the remainder
- boxSize = unitSize * child.Weight
- boxExtraSize := int(math.Min(float64(extraSize), float64(child.Weight)))
- boxSize += boxExtraSize
- extraSize -= boxExtraSize
- }
+ for i, child := range children {
+ boxSize := sizes[i]
var resultForChild map[string]Dimensions
if direction == COLUMN {
@@ -123,6 +94,95 @@ func ArrangeWindows(root *Box, x0, y0, width, height int) map[string]Dimensions
return result
}
+func calcSizes(boxes []*Box, availableSpace int) []int {
+ normalizedWeights := normalizeWeights(slices.Map(boxes, func(box *Box) int { return box.Weight }))
+
+ totalWeight := 0
+ reservedSpace := 0
+ for i, box := range boxes {
+ if box.isStatic() {
+ reservedSpace += box.Size
+ } else {
+ totalWeight += normalizedWeights[i]
+ }
+ }
+
+ dynamicSpace := utils.Max(0, availableSpace-reservedSpace)
+
+ unitSize := 0
+ extraSpace := 0
+ if totalWeight > 0 {
+ unitSize = dynamicSpace / totalWeight
+ extraSpace = dynamicSpace % totalWeight
+ }
+
+ result := make([]int, len(boxes))
+ for i, box := range boxes {
+ if box.isStatic() {
+ // assuming that only one static child can have a size greater than the
+ // available space. In that case we just crop the size to what's available
+ result[i] = utils.Min(availableSpace, box.Size)
+ } else {
+ result[i] = unitSize * normalizedWeights[i]
+ }
+ }
+
+ // distribute the remainder across dynamic boxes.
+ for extraSpace > 0 {
+ for i, weight := range normalizedWeights {
+ if weight > 0 {
+ result[i]++
+ extraSpace--
+ normalizedWeights[i]--
+
+ if extraSpace == 0 {
+ break
+ }
+ }
+ }
+ }
+
+ return result
+}
+
+// removes common multiple from weights e.g. if we get 2, 4, 4 we return 1, 2, 2.
+func normalizeWeights(weights []int) []int {
+ if len(weights) == 0 {
+ return []int{}
+ }
+
+ // to spare us some computation we'll exit early if any of our weights is 1
+ if slices.Some(weights, func(weight int) bool { return weight == 1 }) {
+ return weights
+ }
+
+ // map weights to factorSlices and find the lowest common factor
+ positiveWeights := slices.Filter(weights, func(weight int) bool { return weight > 0 })
+ factorSlices := slices.Map(positiveWeights, func(weight int) []int { return calcFactors(weight) })
+ commonFactors := factorSlices[0]
+ for _, factors := range factorSlices {
+ commonFactors = lo.Intersect(commonFactors, factors)
+ }
+
+ if len(commonFactors) == 0 {
+ return weights
+ }
+
+ newWeights := slices.Map(weights, func(weight int) int { return weight / commonFactors[0] })
+
+ return normalizeWeights(newWeights)
+}
+
+func calcFactors(n int) []int {
+ factors := []int{}
+ for i := 2; i <= n; i++ {
+ if n%i == 0 {
+ factors = append(factors, i)
+ }
+ }
+ return factors
+}
+
func (b *Box) isStatic() bool {
return b.Size > 0
}
diff --git a/pkg/gui/boxlayout/boxlayout_test.go b/pkg/gui/boxlayout/boxlayout_test.go
index 65e6101f7..c2c0bc9e4 100644
--- a/pkg/gui/boxlayout/boxlayout_test.go
+++ b/pkg/gui/boxlayout/boxlayout_test.go
@@ -19,24 +19,24 @@ func TestArrangeWindows(t *testing.T) {
scenarios := []scenario{
{
- "Empty box",
- &Box{},
- 0,
- 0,
- 10,
- 10,
- func(result map[string]Dimensions) {
+ testName: "Empty box",
+ root: &Box{},
+ x0: 0,
+ y0: 0,
+ width: 10,
+ height: 10,
+ test: func(result map[string]Dimensions) {
assert.EqualValues(t, result, map[string]Dimensions{})
},
},
{
- "Box with static and dynamic panel",
- &Box{Children: []*Box{{Size: 1, Window: "static"}, {Weight: 1, Window: "dynamic"}}},
- 0,
- 0,
- 10,
- 10,
- func(result map[string]Dimensions) {
+ testName: "Box with static and dynamic panel",
+ root: &Box{Children: []*Box{{Size: 1, Window: "static"}, {Weight: 1, Window: "dynamic"}}},
+ x0: 0,
+ y0: 0,
+ width: 10,
+ height: 10,
+ test: func(result map[string]Dimensions) {
assert.EqualValues(
t,
result,
@@ -48,13 +48,13 @@ func TestArrangeWindows(t *testing.T) {
},
},
{
- "Box with static and two dynamic panels",
- &Box{Children: []*Box{{Size: 1, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}},
- 0,
- 0,
- 10,
- 10,
- func(result map[string]Dimensions) {
+ testName: "Box with static and two dynamic panels",
+ root: &Box{Children: []*Box{{Size: 1, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}},
+ x0: 0,
+ y0: 0,
+ width: 10,
+ height: 10,
+ test: func(result map[string]Dimensions) {
assert.EqualValues(
t,
result,
@@ -67,13 +67,13 @@ func TestArrangeWindows(t *testing.T) {
},
},
{
- "Box with COLUMN direction",
- &Box{Direction: COLUMN, Children: []*Box{{Size: 1, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}},
- 0,
- 0,
- 10,
- 10,
- func(result map[string]Dimensions) {
+ testName: "Box with COLUMN direction",
+ root: &Box{Direction: COLUMN, Children: []*Box{{Size: 1, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}},
+ x0: 0,
+ y0: 0,
+ width: 10,
+ height: 10,
+ test: func(result map[string]Dimensions) {
assert.EqualValues(
t,
result,
@@ -86,19 +86,19 @@ func TestArrangeWindows(t *testing.T) {
},
},
{
- "Box with COLUMN direction only on wide boxes with narrow box",
- &Box{ConditionalDirection: func(width int, height int) Direction {
+ testName: "Box with COLUMN direction only on wide boxes with narrow box",
+ root: &Box{ConditionalDirection: func(width int, height int) Direction {
if width > 4 {
return COLUMN
} else {
return ROW
}
}, Children: []*Box{{Weight: 1, Window: "dynamic1"}, {Weight: 1, Window: "dynamic2"}}},
- 0,
- 0,
- 4,
- 4,
- func(result map[string]Dimensions) {
+ x0: 0,
+ y0: 0,
+ width: 4,
+ height: 4,
+ test: func(result map[string]Dimensions) {
assert.EqualValues(
t,
result,
@@ -110,19 +110,20 @@ func TestArrangeWindows(t *testing.T) {
},
},
{
- "Box with COLUMN direction only on wide boxes with wide box",
- &Box{ConditionalDirection: func(width int, height int) Direction {
+ testName: "Box with COLUMN direction only on wide boxes with wide box",
+ root: &Box{ConditionalDirection: func(width int, height int) Direction {
if width > 4 {
return COLUMN
} else {
return ROW
}
}, Children: []*Box{{Weight: 1, Window: "dynamic1"}, {Weight: 1, Window: "dynamic2"}}},
- 0,
- 0,
- 5,
- 5,
- func(result map[string]Dimensions) {
+ // 5 / 2 = 2 remainder 1. That remainder goes to the first box.
+ x0: 0,
+ y0: 0,
+ width: 5,
+ height: 5,
+ test: func(result map[string]Dimensions) {
assert.EqualValues(
t,
result,
@@ -134,19 +135,19 @@ func TestArrangeWindows(t *testing.T) {
},
},
{
- "Box with conditional children where box is wide",
- &Box{ConditionalChildren: func(width int, height int) []*Box {
+ testName: "Box with conditional children where box is wide",
+ root: &Box{ConditionalChildren: func(width int, height int) []*Box {
if width > 4 {
return []*Box{{Window: "wide", Weight: 1}}
} else {
return []*Box{{Window: "narrow", Weight: 1}}
}
}},
- 0,
- 0,
- 5,
- 5,
- func(result map[string]Dimensions) {
+ x0: 0,
+ y0: 0,
+ width: 5,
+ height: 5,
+ test: func(result map[string]Dimensions) {
assert.EqualValues(
t,
result,
@@ -157,19 +158,19 @@ func TestArrangeWindows(t *testing.T) {
},
},
{
- "Box with conditional children where box is narrow",
- &Box{ConditionalChildren: func(width int, height int) []*Box {
+ testName: "Box with conditional children where box is narrow",
+ root: &Box{ConditionalChildren: func(width int, height int) []*Box {
if width > 4 {
return []*Box{{Window: "wide", Weight: 1}}
} else {
return []*Box{{Window: "narrow", Weight: 1}}
}
}},
- 0,
- 0,
- 4,
- 4,
- func(result map[string]Dimensions) {
+ x0: 0,
+ y0: 0,
+ width: 4,
+ height: 4,
+ test: func(result map[string]Dimensions) {
assert.EqualValues(
t,
result,
@@ -180,13 +181,13 @@ func TestArrangeWindows(t *testing.T) {
},
},
{
- "Box with static child with size too large",
- &Box{Direction: COLUMN, Children: []*Box{{Size: 11, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}},
- 0,
- 0,
- 10,
- 10,
- func(result map[string]Dimensions) {
+ testName: "Box with static child with size too large",
+ root: &Box{Direction: COLUMN, Children: []*Box{{Size: 11, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}},
+ x0: 0,
+ y0: 0,
+ width: 10,
+ height: 10,
+ test: func(result map[string]Dimensions) {
assert.EqualValues(
t,
result,
@@ -200,6 +201,118 @@ func TestArrangeWindows(t *testing.T) {
)
},
},
+ {
+ // 10 total space minus 2 from the status box leaves us with 8.
+ // Total weight is 3, 8 / 3 = 2 with 2 remainder.
+ // We want to end up with 2, 3, 5 (one unit from remainder to each dynamic box)
+ testName: "Distributing remainder across weighted boxes",
+ root: &Box{Direction: COLUMN, Children: []*Box{{Size: 2, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}},
+ x0: 0,
+ y0: 0,
+ width: 10,
+ height: 10,
+ test: func(result map[string]Dimensions) {
+ assert.EqualValues(
+ t,
+ result,
+ map[string]Dimensions{
+ "static": {X0: 0, X1: 1, Y0: 0, Y1: 9}, // 2
+ "dynamic1": {X0: 2, X1: 4, Y0: 0, Y1: 9}, // 3
+ "dynamic2": {X0: 5, X1: 9, Y0: 0, Y1: 9}, // 5
+ },
+ )
+ },
+ },
+ {
+ // 9 total space.
+ // total weight is 5, 9 / 5 = 1 with 4 remainder
+ // we want to give 2 of that remainder to the first, 1 to the second, and 1 to the last.
+ // Reason being that we just give units to each box evenly and consider weight in subsequent passes.
+ testName: "Distributing remainder across weighted boxes 2",
+ root: &Box{Direction: COLUMN, Children: []*Box{{Weight: 2, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}, {Weight: 1, Window: "dynamic3"}}},
+ x0: 0,
+ y0: 0,
+ width: 9,
+ height: 10,
+ test: func(result map[string]Dimensions) {
+ assert.EqualValues(
+ t,
+ result,
+ map[string]Dimensions{
+ "dynamic1": {X0: 0, X1: 3, Y0: 0, Y1: 9}, // 4
+ "dynamic2": {X0: 4, X1: 6, Y0: 0, Y1: 9}, // 3
+ "dynamic3": {X0: 7, X1: 8, Y0: 0, Y1: 9}, // 2
+ },
+ )
+ },
+ },
+ {
+ // 9 total space.
+ // total weight is 5, 9 / 5 = 1 with 4 remainder
+ // we want to give 2 of that remainder to the first, 1 to the second, and 1 to the last.
+ // Reason being that we just give units to each box evenly and consider weight in subsequent passes.
+ testName: "Distributing remainder across weighted boxes with unnormalized weights",
+ root: &Box{Direction: COLUMN, Children: []*Box{{Weight: 4, Window: "dynamic1"}, {Weight: 4, Window: "dynamic2"}, {Weight: 2, Window: "dynamic3"}}},
+ x0: 0,
+ y0: 0,
+ width: 9,
+ height: 10,
+ test: func(result map[string]Dimensions) {
+ assert.EqualValues(
+ t,
+ result,
+ map[string]Dimensions{
+ "dynamic1": {X0: 0, X1: 3, Y0: 0, Y1: 9}, // 4
+ "dynamic2": {X0: 4, X1: 6, Y0: 0, Y1: 9}, // 3
+ "dynamic3": {X0: 7, X1: 8, Y0: 0, Y1: 9}, // 2
+ },
+ )
+ },
+ },
+ {
+ testName: "Another distribution test",
+ root: &Box{Direction: COLUMN, Children: []*Box{
+ {Weight: 3, Window: "dynamic1"},
+ {Weight: 1, Window: "dynamic2"},
+ {Weight: 1, Window: "dynamic3"},
+ }},
+ x0: 0,
+ y0: 0,
+ width: 9,
+ height: 10,
+ test: func(result map[string]Dimensions) {
+ assert.EqualValues(
+ t,
+ result,
+ map[string]Dimensions{
+ "dynamic1": {X0: 0, X1: 4, Y0: 0, Y1: 9}, // 5
+ "dynamic2": {X0: 5, X1: 6, Y0: 0, Y1: 9}, // 2
+ "dynamic3": {X0: 7, X1: 8, Y0: 0, Y1: 9}, // 2
+ },
+ )
+ },
+ },
+ {
+ testName: "Box with zero weight",
+ root: &Box{Direction: COLUMN, Children: []*Box{
+ {Weight: 1, Window: "dynamic1"},
+ {Weight: 0, Window: "dynamic2"},
+ }},
+ x0: 0,
+ y0: 0,
+ width: 10,
+ height: 10,
+ test: func(result map[string]Dimensions) {
+ assert.EqualValues(
+ t,
+ result,
+ map[string]Dimensions{
+ "dynamic1": {X0: 0, X1: 9, Y0: 0, Y1: 9},
+ "dynamic2": {X0: 10, X1: 9, Y0: 0, Y1: 9}, // when X0 > X1, we will hide the window
+ },
+ )
+ },
+ },
}
for _, s := range scenarios {
@@ -209,3 +322,59 @@ func TestArrangeWindows(t *testing.T) {
})
}
}
+
+func TestNormalizeWeights(t *testing.T) {
+ scenarios := []struct {
+ testName string
+ input []int
+ expected []int
+ }{
+ {
+ testName: "empty",
+ input: []int{},
+ expected: []int{},
+ },
+ {
+ testName: "one item of value 1",
+ input: []int{1},
+ expected: []int{1},
+ },
+ {
+ testName: "one item of value greater than 1",
+ input: []int{2},
+ expected: []int{1},
+ },
+ {
+ testName: "slice contains 1",
+ input: []int{2, 1},
+ expected: []int{2, 1},
+ },
+ {
+ testName: "slice contains 2 and 2",
+ input: []int{2, 2},
+ expected: []int{1, 1},
+ },
+ {
+ testName: "no common multiple",
+ input: []int{2, 3},
+ expected: []int{2, 3},
+ },
+ {
+ testName: "complex case",
+ input: []int{10, 10, 20},
+ expected: []int{1, 1, 2},
+ },
+ {
+ testName: "when a zero weight is included it is ignored",
+ input: []int{10, 10, 20, 0},
+ expected: []int{1, 1, 2, 0},
+ },
+ }
+
+ for _, s := range scenarios {
+ s := s
+ t.Run(s.testName, func(t *testing.T) {
+ assert.EqualValues(t, s.expected, normalizeWeights(s.input))
+ })
+ }
+}
diff --git a/pkg/gui/branches_panel.go b/pkg/gui/branches_panel.go
index 7e68fdb17..b9a25ea67 100644
--- a/pkg/gui/branches_panel.go
+++ b/pkg/gui/branches_panel.go
@@ -1,597 +1,23 @@
package gui
-import (
- "errors"
- "fmt"
- "strings"
-
- "github.com/jesseduffield/lazygit/pkg/commands/git_commands"
- "github.com/jesseduffield/lazygit/pkg/commands/models"
- "github.com/jesseduffield/lazygit/pkg/utils"
-)
-
-// list panel functions
-
-func (gui *Gui) getSelectedBranch() *models.Branch {
- if len(gui.State.Branches) == 0 {
- return nil
- }
-
- selectedLine := gui.State.Panels.Branches.SelectedLineIdx
- if selectedLine == -1 {
- return nil
- }
-
- return gui.State.Branches[selectedLine]
-}
+import "github.com/jesseduffield/lazygit/pkg/gui/types"
func (gui *Gui) branchesRenderToMain() error {
- var task updateTask
- branch := gui.getSelectedBranch()
+ var task types.UpdateTask
+ branch := gui.State.Contexts.Branches.GetSelected()
if branch == nil {
- task = NewRenderStringTask(gui.Tr.NoBranchesThisRepo)
+ task = types.NewRenderStringTask(gui.c.Tr.NoBranchesThisRepo)
} else {
- cmdObj := gui.Git.Branch.GetGraphCmdObj(branch.Name)
+ cmdObj := gui.git.Branch.GetGraphCmdObj(branch.FullRefName())
- task = NewRunPtyTask(cmdObj.GetCmd())
+ task = types.NewRunPtyTask(cmdObj.GetCmd())
}
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "Log",
- task: task,
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: gui.c.Tr.LogTitle,
+ Task: task,
},
})
}
-
-// gui.refreshStatus is called at the end of this because that's when we can
-// be sure there is a state.Branches array to pick the current branch from
-func (gui *Gui) refreshBranches() {
- reflogCommits := gui.State.FilteredReflogCommits
- if gui.State.Modes.Filtering.Active() {
- // in filter mode we filter our reflog commits to just those containing the path
- // however we need all the reflog entries to populate the recencies of our branches
- // which allows us to order them correctly. So if we're filtering we'll just
- // manually load all the reflog commits here
- var err error
- reflogCommits, _, err = gui.Git.Loaders.ReflogCommits.GetReflogCommits(nil, "")
- if err != nil {
- gui.Log.Error(err)
- }
- }
-
- branches, err := gui.Git.Loaders.Branches.Load(reflogCommits)
- if err != nil {
- _ = gui.surfaceError(err)
- }
-
- gui.State.Branches = branches
-
- if err := gui.postRefreshUpdate(gui.State.Contexts.Branches); err != nil {
- gui.Log.Error(err)
- }
-
- gui.refreshStatus()
-}
-
-func (gui *Gui) refreshGithubPullRequests() {
- err := gui.Git.Gh.BaseRepo()
- if err == nil {
- _ = gui.setGithubPullRequests()
- return
- }
-
- // when config not exits
- _ = gui.refreshRemotes()
- _ = gui.prompt(promptOpts{
- title: gui.Tr.SelectRemoteRepository,
- initialContent: "",
- findSuggestionsFunc: gui.getRemoteRepoSuggestionsFunc(),
- handleConfirm: func(repository string) error {
- return gui.WithWaitingStatus(gui.Tr.LcSelectingRemote, func() error {
- _, err := gui.Git.Gh.SetBaseRepo(repository)
- if err != nil {
- return err
- }
-
- err = gui.setGithubPullRequests()
- if err != nil {
- return err
- }
- _ = gui.postRefreshUpdate(gui.State.Contexts.Branches)
- return nil
- })
- },
- })
-}
-
-func (gui *Gui) setGithubPullRequests() error {
- prs, err := gui.Git.Gh.GithubMostRecentPRs()
-
- if err != nil {
- return gui.surfaceError(err)
- }
- gui.State.GithubState.RecentPRs = prs
- return nil
-}
-
-// specific functions
-
-func (gui *Gui) handleBranchPress() error {
- if gui.State.Panels.Branches.SelectedLineIdx == -1 {
- return nil
- }
- if gui.State.Panels.Branches.SelectedLineIdx == 0 {
- return gui.createErrorPanel(gui.Tr.AlreadyCheckedOutBranch)
- }
- branch := gui.getSelectedBranch()
- gui.logAction(gui.Tr.Actions.CheckoutBranch)
- return gui.handleCheckoutRef(branch.Name, handleCheckoutRefOptions{})
-}
-
-func (gui *Gui) handleCreateOrShowPullRequestPress() error {
- branch := gui.getSelectedBranch()
- pr, hasPr, err := gui.GetPr(branch)
- if err != nil {
- return err
- }
-
- if hasPr {
- return gui.OSCommand.OpenLink(pr.Url)
- }
- return gui.createPullRequest(branch.Name, "")
-}
-
-func (gui *Gui) handleCreateOrOpenPullRequestMenu() error {
- selectedBranch := gui.getSelectedBranch()
- if selectedBranch == nil {
- return nil
- }
- checkedOutBranch := gui.getCheckedOutBranch()
-
- return gui.createOrOpenPullRequestMenu(selectedBranch, checkedOutBranch)
-}
-
-func (gui *Gui) handleCopyPullRequestURLPress() error {
- hostingServiceMgr := gui.getHostingServiceMgr()
-
- branch := gui.getSelectedBranch()
-
- branchExistsOnRemote := gui.Git.Remote.CheckRemoteBranchExists(branch.Name)
-
- if !branchExistsOnRemote {
- return gui.surfaceError(errors.New(gui.Tr.NoBranchOnRemote))
- }
-
- url, err := hostingServiceMgr.GetPullRequestURL(branch.Name, "")
- if err != nil {
- return gui.surfaceError(err)
- }
- gui.logAction(gui.Tr.Actions.CopyPullRequestURL)
- if err := gui.OSCommand.CopyToClipboard(url); err != nil {
- return gui.surfaceError(err)
- }
-
- gui.raiseToast(gui.Tr.PullRequestURLCopiedToClipboard)
-
- return nil
-}
-
-func (gui *Gui) handleGitFetch() error {
- if err := gui.createLoaderPanel(gui.Tr.FetchWait); err != nil {
- return err
- }
-
- go utils.Safe(func() {
- err := gui.fetch()
- gui.handleCredentialsPopup(err)
- _ = gui.refreshSidePanels(refreshOptions{mode: ASYNC})
- })
- return nil
-}
-
-func (gui *Gui) handleForceCheckout() error {
- branch := gui.getSelectedBranch()
- message := gui.Tr.SureForceCheckout
- title := gui.Tr.ForceCheckoutBranch
-
- return gui.ask(askOpts{
- title: title,
- prompt: message,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.ForceCheckoutBranch)
- if err := gui.Git.Branch.Checkout(branch.Name, git_commands.CheckoutOptions{Force: true}); err != nil {
- _ = gui.surfaceError(err)
- }
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
- },
- })
-}
-
-type handleCheckoutRefOptions struct {
- WaitingStatus string
- EnvVars []string
- onRefNotFound func(ref string) error
-}
-
-func (gui *Gui) handleCheckoutRef(ref string, options handleCheckoutRefOptions) error {
- waitingStatus := options.WaitingStatus
- if waitingStatus == "" {
- waitingStatus = gui.Tr.CheckingOutStatus
- }
-
- cmdOptions := git_commands.CheckoutOptions{Force: false, EnvVars: options.EnvVars}
-
- onSuccess := func() {
- gui.State.Panels.Branches.SelectedLineIdx = 0
- gui.State.Panels.Commits.SelectedLineIdx = 0
- // loading a heap of commits is slow so we limit them whenever doing a reset
- gui.State.Panels.Commits.LimitCommits = true
- }
-
- return gui.WithWaitingStatus(waitingStatus, func() error {
- if err := gui.Git.Branch.Checkout(ref, cmdOptions); err != nil {
- // note, this will only work for english-language git commands. If we force git to use english, and the error isn't this one, then the user will receive an english command they may not understand. I'm not sure what the best solution to this is. Running the command once in english and a second time in the native language is one option
-
- if options.onRefNotFound != nil && strings.Contains(err.Error(), "did not match any file(s) known to git") {
- return options.onRefNotFound(ref)
- }
-
- if strings.Contains(err.Error(), "Please commit your changes or stash them before you switch branch") {
- // offer to autostash changes
- return gui.ask(askOpts{
-
- title: gui.Tr.AutoStashTitle,
- prompt: gui.Tr.AutoStashPrompt,
- handleConfirm: func() error {
- if err := gui.Git.Stash.Save(gui.Tr.StashPrefix + ref); err != nil {
- return gui.surfaceError(err)
- }
- if err := gui.Git.Branch.Checkout(ref, cmdOptions); err != nil {
- return gui.surfaceError(err)
- }
-
- onSuccess()
- if err := gui.Git.Stash.Pop(0); err != nil {
- if err := gui.refreshSidePanels(refreshOptions{mode: BLOCK_UI}); err != nil {
- return err
- }
- return gui.surfaceError(err)
- }
- return gui.refreshSidePanels(refreshOptions{mode: BLOCK_UI})
- },
- })
- }
-
- if err := gui.surfaceError(err); err != nil {
- return err
- }
- }
- onSuccess()
-
- return gui.refreshSidePanels(refreshOptions{mode: BLOCK_UI})
- })
-}
-
-func (gui *Gui) handleCheckoutByName() error {
- return gui.prompt(promptOpts{
- title: gui.Tr.BranchName + ":",
- findSuggestionsFunc: gui.getRefsSuggestionsFunc(),
- handleConfirm: func(response string) error {
- gui.logAction("Checkout branch")
- return gui.handleCheckoutRef(response, handleCheckoutRefOptions{
- onRefNotFound: func(ref string) error {
- return gui.ask(askOpts{
- title: gui.Tr.BranchNotFoundTitle,
- prompt: fmt.Sprintf("%s %s%s", gui.Tr.BranchNotFoundPrompt, ref, "?"),
- handleConfirm: func() error {
- return gui.createNewBranchWithName(ref)
- },
- })
- },
- })
- }},
- )
-}
-
-func (gui *Gui) getCheckedOutBranch() *models.Branch {
- if len(gui.State.Branches) == 0 {
- return nil
- }
-
- return gui.State.Branches[0]
-}
-
-func (gui *Gui) createNewBranchWithName(newBranchName string) error {
- branch := gui.getSelectedBranch()
- if branch == nil {
- return nil
- }
-
- if err := gui.Git.Branch.New(newBranchName, branch.Name); err != nil {
- return gui.surfaceError(err)
- }
-
- gui.State.Panels.Branches.SelectedLineIdx = 0
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
-}
-
-func (gui *Gui) handleDeleteBranch() error {
- return gui.deleteBranch(false)
-}
-
-func (gui *Gui) deleteBranch(force bool) error {
- selectedBranch := gui.getSelectedBranch()
- if selectedBranch == nil {
- return nil
- }
- checkedOutBranch := gui.getCheckedOutBranch()
- if checkedOutBranch.Name == selectedBranch.Name {
- return gui.createErrorPanel(gui.Tr.CantDeleteCheckOutBranch)
- }
- return gui.deleteNamedBranch(selectedBranch, force)
-}
-
-func (gui *Gui) deleteNamedBranch(selectedBranch *models.Branch, force bool) error {
- title := gui.Tr.DeleteBranch
- var templateStr string
- if force {
- templateStr = gui.Tr.ForceDeleteBranchMessage
- } else {
- templateStr = gui.Tr.DeleteBranchMessage
- }
- message := utils.ResolvePlaceholderString(
- templateStr,
- map[string]string{
- "selectedBranchName": selectedBranch.Name,
- },
- )
-
- return gui.ask(askOpts{
- title: title,
- prompt: message,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.DeleteBranch)
- if err := gui.Git.Branch.Delete(selectedBranch.Name, force); err != nil {
- errMessage := err.Error()
- if !force && strings.Contains(errMessage, "git branch -D ") {
- return gui.deleteNamedBranch(selectedBranch, true)
- }
- return gui.createErrorPanel(errMessage)
- }
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{BRANCHES}})
- },
- })
-}
-
-func (gui *Gui) mergeBranchIntoCheckedOutBranch(branchName string) error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- if gui.Git.Branch.IsHeadDetached() {
- return gui.createErrorPanel("Cannot merge branch in detached head state. You might have checked out a commit directly or a remote branch, in which case you should checkout the local branch you want to be on")
- }
- checkedOutBranchName := gui.getCheckedOutBranch().Name
- if checkedOutBranchName == branchName {
- return gui.createErrorPanel(gui.Tr.CantMergeBranchIntoItself)
- }
- prompt := utils.ResolvePlaceholderString(
- gui.Tr.ConfirmMerge,
- map[string]string{
- "checkedOutBranch": checkedOutBranchName,
- "selectedBranch": branchName,
- },
- )
-
- return gui.ask(askOpts{
- title: gui.Tr.MergingTitle,
- prompt: prompt,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.Merge)
- err := gui.Git.Branch.Merge(branchName, git_commands.MergeOpts{})
- return gui.handleGenericMergeCommandResult(err)
- },
- })
-}
-
-func (gui *Gui) handleMerge() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- selectedBranchName := gui.getSelectedBranch().Name
- return gui.mergeBranchIntoCheckedOutBranch(selectedBranchName)
-}
-
-func (gui *Gui) handleRebaseOntoLocalBranch() error {
- selectedBranchName := gui.getSelectedBranch().Name
- return gui.handleRebaseOntoBranch(selectedBranchName)
-}
-
-func (gui *Gui) handleRebaseOntoBranch(selectedBranchName string) error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- checkedOutBranch := gui.getCheckedOutBranch().Name
- if selectedBranchName == checkedOutBranch {
- return gui.createErrorPanel(gui.Tr.CantRebaseOntoSelf)
- }
- prompt := utils.ResolvePlaceholderString(
- gui.Tr.ConfirmRebase,
- map[string]string{
- "checkedOutBranch": checkedOutBranch,
- "selectedBranch": selectedBranchName,
- },
- )
-
- return gui.ask(askOpts{
- title: gui.Tr.RebasingTitle,
- prompt: prompt,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.RebaseBranch)
- err := gui.Git.Rebase.RebaseBranch(selectedBranchName)
- return gui.handleGenericMergeCommandResult(err)
- },
- })
-}
-
-func (gui *Gui) handleFastForward() error {
- branch := gui.getSelectedBranch()
- if branch == nil || !branch.IsRealBranch() {
- return nil
- }
-
- if !branch.IsTrackingRemote() {
- return gui.createErrorPanel(gui.Tr.FwdNoUpstream)
- }
- if !branch.RemoteBranchStoredLocally() {
- return gui.createErrorPanel(gui.Tr.FwdNoLocalUpstream)
- }
- if branch.HasCommitsToPush() {
- return gui.createErrorPanel(gui.Tr.FwdCommitsToPush)
- }
-
- action := gui.Tr.Actions.FastForwardBranch
-
- message := utils.ResolvePlaceholderString(
- gui.Tr.Fetching,
- map[string]string{
- "from": fmt.Sprintf("%s/%s", branch.UpstreamRemote, branch.UpstreamBranch),
- "to": branch.Name,
- },
- )
- go utils.Safe(func() {
- _ = gui.createLoaderPanel(message)
-
- if gui.State.Panels.Branches.SelectedLineIdx == 0 {
- _ = gui.pullWithLock(PullFilesOptions{action: action, FastForwardOnly: true})
- } else {
- gui.logAction(action)
- err := gui.Git.Sync.FastForward(branch.Name, branch.UpstreamRemote, branch.UpstreamBranch)
- gui.handleCredentialsPopup(err)
- _ = gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{BRANCHES}})
- }
- })
- return nil
-}
-
-func (gui *Gui) handleCreateResetToBranchMenu() error {
- branch := gui.getSelectedBranch()
- if branch == nil {
- return nil
- }
-
- return gui.createResetMenu(branch.Name)
-}
-
-func (gui *Gui) handleRenameBranch() error {
- branch := gui.getSelectedBranch()
- if branch == nil || !branch.IsRealBranch() {
- return nil
- }
-
- promptForNewName := func() error {
- return gui.prompt(promptOpts{
- title: gui.Tr.NewBranchNamePrompt + " " + branch.Name + ":",
- initialContent: branch.Name,
- handleConfirm: func(newBranchName string) error {
- gui.logAction(gui.Tr.Actions.RenameBranch)
- if err := gui.Git.Branch.Rename(branch.Name, newBranchName); err != nil {
- return gui.surfaceError(err)
- }
-
- // need to find where the branch is now so that we can re-select it. That means we need to refetch the branches synchronously and then find our branch
- gui.refreshBranches()
-
- // now that we've got our stuff again we need to find that branch and reselect it.
- for i, newBranch := range gui.State.Branches {
- if newBranch.Name == newBranchName {
- gui.State.Panels.Branches.SetSelectedLineIdx(i)
- if err := gui.State.Contexts.Branches.HandleRender(); err != nil {
- return err
- }
- }
- }
-
- return nil
- },
- })
- }
-
- // I could do an explicit check here for whether the branch is tracking a remote branch
- // but if we've selected it we'll already know that via Pullables and Pullables.
- // Bit of a hack but I'm lazy.
- if !branch.IsTrackingRemote() {
- return promptForNewName()
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.LcRenameBranch,
- prompt: gui.Tr.RenameBranchWarning,
- handleConfirm: promptForNewName,
- })
-}
-
-func (gui *Gui) currentBranch() *models.Branch {
- if len(gui.State.Branches) == 0 {
- return nil
- }
- return gui.State.Branches[0]
-}
-
-func (gui *Gui) handleNewBranchOffCurrentItem() error {
- context := gui.currentSideListContext()
-
- item, ok := context.GetSelectedItem()
- if !ok {
- return nil
- }
-
- message := utils.ResolvePlaceholderString(
- gui.Tr.NewBranchNameBranchOff,
- map[string]string{
- "branchName": item.Description(),
- },
- )
-
- prefilledName := ""
- if context.GetKey() == REMOTE_BRANCHES_CONTEXT_KEY {
- // will set to the remote's branch name without the remote name
- prefilledName = strings.SplitAfterN(item.ID(), "/", 2)[1]
- }
-
- return gui.prompt(promptOpts{
- title: message,
- initialContent: prefilledName,
- handleConfirm: func(response string) error {
- gui.logAction(gui.Tr.Actions.CreateBranch)
- if err := gui.Git.Branch.New(sanitizedBranchName(response), item.ID()); err != nil {
- return err
- }
-
- // if we're currently in the branch commits context then the selected commit
- // is about to go to the top of the list
- if context.GetKey() == BRANCH_COMMITS_CONTEXT_KEY {
- context.GetPanelState().SetSelectedLineIdx(0)
- }
-
- if context.GetKey() != gui.State.Contexts.Branches.GetKey() {
- if err := gui.pushContext(gui.State.Contexts.Branches); err != nil {
- return err
- }
- }
-
- gui.State.Panels.Branches.SelectedLineIdx = 0
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
- },
- })
-}
-
-// sanitizedBranchName will remove all spaces in favor of a dash "-" to meet
-// git's branch naming requirement.
-func sanitizedBranchName(input string) string {
- return strings.Replace(input, " ", "-", -1)
-}
diff --git a/pkg/gui/cherry_picking.go b/pkg/gui/cherry_picking.go
deleted file mode 100644
index b4b9439cd..000000000
--- a/pkg/gui/cherry_picking.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package gui
-
-import "github.com/jesseduffield/lazygit/pkg/commands/models"
-
-// you can only copy from one context at a time, because the order and position of commits matter
-
-func (gui *Gui) resetCherryPickingIfNecessary(context Context) error {
- oldContextKey := ContextKey(gui.State.Modes.CherryPicking.ContextKey)
-
- if oldContextKey != context.GetKey() {
- // need to reset the cherry picking mode
- gui.State.Modes.CherryPicking.ContextKey = string(context.GetKey())
- gui.State.Modes.CherryPicking.CherryPickedCommits = make([]*models.Commit, 0)
-
- return gui.rerenderContextViewIfPresent(oldContextKey)
- }
-
- return nil
-}
-
-func (gui *Gui) handleCopyCommit() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- // get currently selected commit, add the sha to state.
- context := gui.currentSideListContext()
- if context == nil {
- return nil
- }
-
- if err := gui.resetCherryPickingIfNecessary(context); err != nil {
- return err
- }
-
- item, ok := context.GetSelectedItem()
- if !ok {
- return nil
- }
- commit, ok := item.(*models.Commit)
- if !ok {
- return nil
- }
-
- // we will un-copy it if it's already copied
- for index, cherryPickedCommit := range gui.State.Modes.CherryPicking.CherryPickedCommits {
- if commit.Sha == cherryPickedCommit.Sha {
- gui.State.Modes.CherryPicking.CherryPickedCommits = append(gui.State.Modes.CherryPicking.CherryPickedCommits[0:index], gui.State.Modes.CherryPicking.CherryPickedCommits[index+1:]...)
- return context.HandleRender()
- }
- }
-
- gui.addCommitToCherryPickedCommits(context.GetPanelState().GetSelectedLineIdx())
- return context.HandleRender()
-}
-
-func (gui *Gui) cherryPickedCommitShaMap() map[string]bool {
- commitShaMap := map[string]bool{}
- for _, commit := range gui.State.Modes.CherryPicking.CherryPickedCommits {
- commitShaMap[commit.Sha] = true
- }
- return commitShaMap
-}
-
-func (gui *Gui) commitsListForContext() []*models.Commit {
- context := gui.currentSideListContext()
- if context == nil {
- return nil
- }
-
- // using a switch statement, but we should use polymorphism
- switch context.GetKey() {
- case BRANCH_COMMITS_CONTEXT_KEY:
- return gui.State.Commits
- case REFLOG_COMMITS_CONTEXT_KEY:
- return gui.State.FilteredReflogCommits
- case SUB_COMMITS_CONTEXT_KEY:
- return gui.State.SubCommits
- default:
- gui.Log.Errorf("no commit list for context %s", context.GetKey())
- return nil
- }
-}
-
-func (gui *Gui) addCommitToCherryPickedCommits(index int) {
- commitShaMap := gui.cherryPickedCommitShaMap()
- commitsList := gui.commitsListForContext()
- commitShaMap[commitsList[index].Sha] = true
-
- newCommits := []*models.Commit{}
- for _, commit := range commitsList {
- if commitShaMap[commit.Sha] {
- // duplicating just the things we need to put in the rebase TODO list
- newCommits = append(newCommits, &models.Commit{Name: commit.Name, Sha: commit.Sha})
- }
- }
-
- gui.State.Modes.CherryPicking.CherryPickedCommits = newCommits
-}
-
-func (gui *Gui) handleCopyCommitRange() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- // get currently selected commit, add the sha to state.
- context := gui.currentSideListContext()
- if context == nil {
- return nil
- }
-
- if err := gui.resetCherryPickingIfNecessary(context); err != nil {
- return err
- }
-
- commitShaMap := gui.cherryPickedCommitShaMap()
- commitsList := gui.commitsListForContext()
- selectedLineIdx := context.GetPanelState().GetSelectedLineIdx()
-
- if selectedLineIdx > len(commitsList)-1 {
- return nil
- }
-
- // find the last commit that is copied that's above our position
- // if there are none, startIndex = 0
- startIndex := 0
- for index, commit := range commitsList[0:selectedLineIdx] {
- if commitShaMap[commit.Sha] {
- startIndex = index
- }
- }
-
- for index := startIndex; index <= selectedLineIdx; index++ {
- gui.addCommitToCherryPickedCommits(index)
- }
-
- return context.HandleRender()
-}
-
-// HandlePasteCommits begins a cherry-pick rebase with the commits the user has copied
-func (gui *Gui) HandlePasteCommits() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.CherryPick,
- prompt: gui.Tr.SureCherryPick,
- handleConfirm: func() error {
- return gui.WithWaitingStatus(gui.Tr.CherryPickingStatus, func() error {
- gui.logAction(gui.Tr.Actions.CherryPick)
- err := gui.Git.Rebase.CherryPickCommits(gui.State.Modes.CherryPicking.CherryPickedCommits)
- return gui.handleGenericMergeCommandResult(err)
- })
- },
- })
-}
-
-func (gui *Gui) exitCherryPickingMode() error {
- contextKey := ContextKey(gui.State.Modes.CherryPicking.ContextKey)
-
- gui.State.Modes.CherryPicking.ContextKey = ""
- gui.State.Modes.CherryPicking.CherryPickedCommits = nil
-
- if contextKey == "" {
- gui.Log.Warn("context key blank when trying to exit cherry picking mode")
- return nil
- }
-
- return gui.rerenderContextViewIfPresent(contextKey)
-}
-
-func (gui *Gui) rerenderContextViewIfPresent(contextKey ContextKey) error {
- if contextKey == "" {
- return nil
- }
-
- context := gui.mustContextForContextKey(contextKey)
-
- viewName := context.GetViewName()
-
- view, err := gui.g.View(viewName)
- if err != nil {
- gui.Log.Error(err)
- return nil
- }
-
- if ContextKey(view.Context) == contextKey {
- if err := context.HandleRender(); err != nil {
- return err
- }
- }
-
- return nil
-}
diff --git a/pkg/gui/command_log_panel.go b/pkg/gui/command_log_panel.go
index aa46a4d18..dbee7febb 100644
--- a/pkg/gui/command_log_panel.go
+++ b/pkg/gui/command_log_panel.go
@@ -7,22 +7,23 @@ import (
"time"
"github.com/jesseduffield/lazygit/pkg/constants"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/theme"
)
// our UI command log looks like this:
-// Stage File
-// git add -- 'filename'
-// Unstage File
-// git reset HEAD 'filename'
+// Stage File:
+// git add -- 'filename'
+// Unstage File:
+// git reset HEAD 'filename'
//
// The 'Stage File' and 'Unstage File' lines are actions i.e they group up a set
// of command logs (typically there's only one command under an action but there may be more).
// So we call logAction to log the 'Stage File' part and then we call logCommand to log the command itself.
// We pass logCommand to our OSCommand struct so that it can handle logging commands
// for us.
-func (gui *Gui) logAction(action string) {
+func (gui *Gui) LogAction(action string) {
if gui.Views.Extras == nil {
return
}
@@ -32,7 +33,7 @@ func (gui *Gui) logAction(action string) {
fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action))
}
-func (gui *Gui) logCommand(cmdStr string, commandLine bool) {
+func (gui *Gui) LogCommand(cmdStr string, commandLine bool) {
if gui.Views.Extras == nil {
return
}
@@ -52,26 +53,26 @@ func (gui *Gui) logCommand(cmdStr string, commandLine bool) {
func (gui *Gui) printCommandLogHeader() {
introStr := fmt.Sprintf(
- gui.Tr.CommandLogHeader,
- gui.getKeyDisplay(gui.UserConfig.Keybinding.Universal.ExtrasMenu),
+ gui.c.Tr.CommandLogHeader,
+ keybindings.Label(gui.c.UserConfig.Keybinding.Universal.ExtrasMenu),
)
fmt.Fprintln(gui.Views.Extras, style.FgCyan.Sprint(introStr))
- if gui.UserConfig.Gui.ShowRandomTip {
+ if gui.c.UserConfig.Gui.ShowRandomTip {
fmt.Fprintf(
gui.Views.Extras,
"%s: %s",
- style.FgYellow.Sprint(gui.Tr.RandomTip),
+ style.FgYellow.Sprint(gui.c.Tr.RandomTip),
style.FgGreen.Sprint(gui.getRandomTip()),
)
}
}
func (gui *Gui) getRandomTip() string {
- config := gui.UserConfig.Keybinding
+ config := gui.c.UserConfig.Keybinding
formattedKey := func(key string) string {
- return gui.getKeyDisplay(key)
+ return keybindings.Label(key)
}
tips := []string{
diff --git a/pkg/gui/commit_files_panel.go b/pkg/gui/commit_files_panel.go
index 61f3b72b8..0849ab310 100644
--- a/pkg/gui/commit_files_panel.go
+++ b/pkg/gui/commit_files_panel.go
@@ -1,298 +1,51 @@
package gui
import (
- "github.com/jesseduffield/lazygit/pkg/commands/models"
- "github.com/jesseduffield/lazygit/pkg/commands/patch"
- "github.com/jesseduffield/lazygit/pkg/gui/filetree"
+ "github.com/jesseduffield/lazygit/pkg/gui/controllers"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
-func (gui *Gui) getSelectedCommitFileNode() *filetree.CommitFileNode {
- selectedLine := gui.State.Panels.CommitFiles.SelectedLineIdx
- if selectedLine == -1 || selectedLine > gui.State.CommitFileTreeViewModel.GetItemsLength()-1 {
- return nil
- }
-
- return gui.State.CommitFileTreeViewModel.GetItemAtIndex(selectedLine)
-}
-
-func (gui *Gui) getSelectedCommitFile() *models.CommitFile {
- node := gui.getSelectedCommitFileNode()
- if node == nil {
- return nil
- }
- return node.File
-}
-
-func (gui *Gui) getSelectedCommitFilePath() string {
- node := gui.getSelectedCommitFileNode()
- if node == nil {
- return ""
- }
- return node.GetPath()
-}
-
-func (gui *Gui) onCommitFileFocus() error {
- gui.escapeLineByLinePanel()
- return nil
-}
-
func (gui *Gui) commitFilesRenderToMain() error {
- node := gui.getSelectedCommitFileNode()
+ node := gui.State.Contexts.CommitFiles.GetSelected()
if node == nil {
return nil
}
- to := gui.State.CommitFileTreeViewModel.GetParent()
- from, reverse := gui.getFromAndReverseArgsForDiff(to)
+ ref := gui.State.Contexts.CommitFiles.GetRef()
+ to := ref.RefName()
+ from, reverse := gui.State.Modes.Diffing.GetFromAndReverseArgsForDiff(ref.ParentRefName())
- cmdObj := gui.Git.WorkingTree.ShowFileDiffCmdObj(from, to, reverse, node.GetPath(), false)
- task := NewRunPtyTask(cmdObj.GetCmd())
+ cmdObj := gui.git.WorkingTree.ShowFileDiffCmdObj(from, to, reverse, node.GetPath(), false)
+ task := types.NewRunPtyTask(cmdObj.GetCmd())
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "Patch",
- task: task,
+ pair := gui.c.MainViewPairs().Normal
+ if node.File != nil {
+ pair = gui.c.MainViewPairs().PatchBuilding
+ }
+
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: pair,
+ Main: &types.ViewUpdateOpts{
+ Title: gui.Tr.Patch,
+ Task: task,
},
- secondary: gui.secondaryPatchPanelUpdateOpts(),
+ Secondary: gui.secondaryPatchPanelUpdateOpts(),
})
}
-func (gui *Gui) handleCheckoutCommitFile() error {
- node := gui.getSelectedCommitFileNode()
- if node == nil {
- return nil
- }
+func (gui *Gui) SwitchToCommitFilesContext(opts controllers.SwitchToCommitFilesContextOpts) error {
+ gui.State.Contexts.CommitFiles.SetSelectedLineIdx(0)
+ gui.State.Contexts.CommitFiles.SetRef(opts.Ref)
+ gui.State.Contexts.CommitFiles.SetTitleRef(opts.Ref.Description())
+ gui.State.Contexts.CommitFiles.SetCanRebase(opts.CanRebase)
+ gui.State.Contexts.CommitFiles.SetParentContext(opts.Context)
+ gui.State.Contexts.CommitFiles.SetWindowName(opts.Context.GetWindowName())
- gui.logAction(gui.Tr.Actions.CheckoutFile)
- if err := gui.Git.WorkingTree.CheckoutFile(gui.State.CommitFileTreeViewModel.GetParent(), node.GetPath()); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
-}
-
-func (gui *Gui) handleDiscardOldFileChange() error {
- if ok, err := gui.validateNormalWorkingTreeState(); !ok {
+ if err := gui.c.Refresh(types.RefreshOptions{
+ Scope: []types.RefreshableView{types.COMMIT_FILES},
+ }); err != nil {
return err
}
- fileName := gui.getSelectedCommitFileName()
-
- return gui.ask(askOpts{
- title: gui.Tr.DiscardFileChangesTitle,
- prompt: gui.Tr.DiscardFileChangesPrompt,
- handleConfirm: func() error {
- return gui.WithWaitingStatus(gui.Tr.RebasingStatus, func() error {
- gui.logAction(gui.Tr.Actions.DiscardOldFileChange)
- if err := gui.Git.Rebase.DiscardOldFileChanges(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, fileName); err != nil {
- if err := gui.handleGenericMergeCommandResult(err); err != nil {
- return err
- }
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: BLOCK_UI})
- })
- },
- })
-}
-
-func (gui *Gui) refreshCommitFilesView() error {
- currentSideContext := gui.currentSideContext()
- if currentSideContext.GetKey() == COMMIT_FILES_CONTEXT_KEY || currentSideContext.GetKey() == BRANCH_COMMITS_CONTEXT_KEY {
- if err := gui.handleRefreshPatchBuildingPanel(-1); err != nil {
- return err
- }
- }
-
- to := gui.State.Panels.CommitFiles.refName
- from, reverse := gui.getFromAndReverseArgsForDiff(to)
-
- files, err := gui.Git.Loaders.CommitFiles.GetFilesInDiff(from, to, reverse)
- if err != nil {
- return gui.surfaceError(err)
- }
- gui.State.CommitFileTreeViewModel.SetParent(to)
- gui.State.CommitFileTreeViewModel.SetFiles(files)
-
- return gui.postRefreshUpdate(gui.State.Contexts.CommitFiles)
-}
-
-func (gui *Gui) handleOpenOldCommitFile() error {
- node := gui.getSelectedCommitFileNode()
- if node == nil {
- return nil
- }
-
- return gui.openFile(node.GetPath())
-}
-
-func (gui *Gui) handleEditCommitFile() error {
- node := gui.getSelectedCommitFileNode()
- if node == nil {
- return nil
- }
-
- if node.File == nil {
- return gui.createErrorPanel(gui.Tr.ErrCannotEditDirectory)
- }
-
- return gui.editFile(node.GetPath())
-}
-
-func (gui *Gui) handleToggleFileForPatch() error {
- node := gui.getSelectedCommitFileNode()
- if node == nil {
- return nil
- }
-
- toggleTheFile := func() error {
- if !gui.Git.Patch.PatchManager.Active() {
- if err := gui.startPatchManager(); err != nil {
- return err
- }
- }
-
- // if there is any file that hasn't been fully added we'll fully add everything,
- // otherwise we'll remove everything
- adding := node.AnyFile(func(file *models.CommitFile) bool {
- return gui.Git.Patch.PatchManager.GetFileStatus(file.Name, gui.State.CommitFileTreeViewModel.GetParent()) != patch.WHOLE
- })
-
- err := node.ForEachFile(func(file *models.CommitFile) error {
- if adding {
- return gui.Git.Patch.PatchManager.AddFileWhole(file.Name)
- } else {
- return gui.Git.Patch.PatchManager.RemoveFile(file.Name)
- }
- })
-
- if err != nil {
- return gui.surfaceError(err)
- }
-
- if gui.Git.Patch.PatchManager.IsEmpty() {
- gui.Git.Patch.PatchManager.Reset()
- }
-
- return gui.postRefreshUpdate(gui.State.Contexts.CommitFiles)
- }
-
- if gui.Git.Patch.PatchManager.Active() && gui.Git.Patch.PatchManager.To != gui.State.CommitFileTreeViewModel.GetParent() {
- return gui.ask(askOpts{
- title: gui.Tr.DiscardPatch,
- prompt: gui.Tr.DiscardPatchConfirm,
- handleConfirm: func() error {
- gui.Git.Patch.PatchManager.Reset()
- return toggleTheFile()
- },
- })
- }
-
- return toggleTheFile()
-}
-
-func (gui *Gui) startPatchManager() error {
- canRebase := gui.State.Panels.CommitFiles.canRebase
-
- to := gui.State.Panels.CommitFiles.refName
- from, reverse := gui.getFromAndReverseArgsForDiff(to)
-
- gui.Git.Patch.PatchManager.Start(from, to, reverse, canRebase)
- return nil
-}
-
-func (gui *Gui) handleEnterCommitFile() error {
- return gui.enterCommitFile(OnFocusOpts{ClickedViewName: "", ClickedViewLineIdx: -1})
-}
-
-func (gui *Gui) enterCommitFile(opts OnFocusOpts) error {
- node := gui.getSelectedCommitFileNode()
- if node == nil {
- return nil
- }
-
- if node.File == nil {
- return gui.handleToggleCommitFileDirCollapsed()
- }
-
- enterTheFile := func() error {
- if !gui.Git.Patch.PatchManager.Active() {
- if err := gui.startPatchManager(); err != nil {
- return err
- }
- }
-
- return gui.pushContext(gui.State.Contexts.PatchBuilding, opts)
- }
-
- if gui.Git.Patch.PatchManager.Active() && gui.Git.Patch.PatchManager.To != gui.State.CommitFileTreeViewModel.GetParent() {
- return gui.ask(askOpts{
- title: gui.Tr.DiscardPatch,
- prompt: gui.Tr.DiscardPatchConfirm,
- handleConfirm: func() error {
- gui.Git.Patch.PatchManager.Reset()
- return enterTheFile()
- },
- })
- }
-
- return enterTheFile()
-}
-
-func (gui *Gui) handleToggleCommitFileDirCollapsed() error {
- node := gui.getSelectedCommitFileNode()
- if node == nil {
- return nil
- }
-
- gui.State.CommitFileTreeViewModel.ToggleCollapsed(node.GetPath())
-
- if err := gui.postRefreshUpdate(gui.State.Contexts.CommitFiles); err != nil {
- gui.Log.Error(err)
- }
-
- return nil
-}
-
-func (gui *Gui) switchToCommitFilesContext(refName string, canRebase bool, context Context, windowName string) error {
- // sometimes the commitFiles view is already shown in another window, so we need to ensure that window
- // no longer considers the commitFiles view as its main view.
- gui.resetWindowForView(gui.Views.CommitFiles)
-
- gui.State.Panels.CommitFiles.SelectedLineIdx = 0
- gui.State.Panels.CommitFiles.refName = refName
- gui.State.Panels.CommitFiles.canRebase = canRebase
- gui.State.Contexts.CommitFiles.SetParentContext(context)
- gui.State.Contexts.CommitFiles.SetWindowName(windowName)
-
- if err := gui.refreshCommitFilesView(); err != nil {
- return err
- }
-
- return gui.pushContext(gui.State.Contexts.CommitFiles)
-}
-
-// NOTE: this is very similar to handleToggleFileTreeView, could be DRY'd with generics
-func (gui *Gui) handleToggleCommitFileTreeView() error {
- path := gui.getSelectedCommitFilePath()
-
- gui.State.CommitFileTreeViewModel.ToggleShowTree()
-
- // find that same node in the new format and move the cursor to it
- if path != "" {
- gui.State.CommitFileTreeViewModel.ExpandToPath(path)
- index, found := gui.State.CommitFileTreeViewModel.GetIndexForPath(path)
- if found {
- gui.State.Contexts.CommitFiles.GetPanelState().SetSelectedLineIdx(index)
- }
- }
-
- if err := gui.State.Contexts.CommitFiles.HandleRender(); err != nil {
- return err
- }
- if err := gui.State.Contexts.CommitFiles.HandleFocus(); err != nil {
- return err
- }
-
- return nil
+ return gui.c.PushContext(gui.State.Contexts.CommitFiles)
}
diff --git a/pkg/gui/commit_message_panel.go b/pkg/gui/commit_message_panel.go
index 5f5a8741f..4c8ddae2b 100644
--- a/pkg/gui/commit_message_panel.go
+++ b/pkg/gui/commit_message_panel.go
@@ -5,53 +5,33 @@ import (
"strings"
"github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/utils"
)
-func (gui *Gui) handleCommitConfirm() error {
- message := strings.TrimSpace(gui.Views.CommitMessage.TextArea.GetContent())
- gui.State.failedCommitMessage = message
- if message == "" {
- return gui.createErrorPanel(gui.Tr.CommitWithoutMessageErr)
- }
-
- cmdObj := gui.Git.Commit.CommitCmdObj(message)
- gui.logAction(gui.Tr.Actions.Commit)
-
- _ = gui.returnFromContext()
- return gui.withGpgHandling(cmdObj, gui.Tr.CommittingStatus, func() error {
- gui.Views.CommitMessage.ClearTextArea()
- gui.State.failedCommitMessage = ""
- return nil
- })
-}
-
-func (gui *Gui) handleCommitClose() error {
- return gui.returnFromContext()
-}
-
func (gui *Gui) handleCommitMessageFocused() error {
message := utils.ResolvePlaceholderString(
- gui.Tr.CommitMessageConfirm,
+ gui.c.Tr.CommitMessageConfirm,
map[string]string{
- "keyBindClose": gui.getKeyDisplay(gui.UserConfig.Keybinding.Universal.Return),
- "keyBindConfirm": gui.getKeyDisplay(gui.UserConfig.Keybinding.Universal.Confirm),
- "keyBindNewLine": gui.getKeyDisplay(gui.UserConfig.Keybinding.Universal.AppendNewline),
+ "keyBindClose": keybindings.Label(gui.c.UserConfig.Keybinding.Universal.Return),
+ "keyBindConfirm": keybindings.Label(gui.c.UserConfig.Keybinding.Universal.Confirm),
+ "keyBindNewLine": keybindings.Label(gui.c.UserConfig.Keybinding.Universal.AppendNewline),
},
)
+ gui.RenderCommitLength()
+
return gui.renderString(gui.Views.Options, message)
}
-func (gui *Gui) getBufferLength(view *gocui.View) string {
- return " " + strconv.Itoa(strings.Count(view.TextArea.GetContent(), "")-1) + " "
-}
-
-// RenderCommitLength is a function.
func (gui *Gui) RenderCommitLength() {
- if !gui.UserConfig.Gui.CommitLength.Show {
+ if !gui.c.UserConfig.Gui.CommitLength.Show {
return
}
- gui.Views.CommitMessage.Subtitle = gui.getBufferLength(gui.Views.CommitMessage)
+ gui.Views.CommitMessage.Subtitle = getBufferLength(gui.Views.CommitMessage)
+}
+
+func getBufferLength(view *gocui.View) string {
+ return " " + strconv.Itoa(strings.Count(view.TextArea.GetContent(), "")-1) + " "
}
diff --git a/pkg/gui/commits_panel.go b/pkg/gui/commits_panel.go
index 40848f97e..19434b9fb 100644
--- a/pkg/gui/commits_panel.go
+++ b/pkg/gui/commits_panel.go
@@ -1,11 +1,8 @@
package gui
import (
- "fmt"
- "sync"
-
- "github.com/jesseduffield/lazygit/pkg/commands/loaders"
"github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@@ -15,840 +12,68 @@ const COMMIT_THRESHOLD = 200
// list panel functions
func (gui *Gui) getSelectedLocalCommit() *models.Commit {
- selectedLine := gui.State.Panels.Commits.SelectedLineIdx
- if selectedLine == -1 || selectedLine > len(gui.State.Commits)-1 {
- return nil
- }
-
- return gui.State.Commits[selectedLine]
+ return gui.State.Contexts.LocalCommits.GetSelected()
}
func (gui *Gui) onCommitFocus() error {
- state := gui.State.Panels.Commits
- if state.SelectedLineIdx > COMMIT_THRESHOLD && state.LimitCommits {
- state.LimitCommits = false
+ context := gui.State.Contexts.LocalCommits
+ if context.GetSelectedLineIdx() > COMMIT_THRESHOLD && context.GetLimitCommits() {
+ context.SetLimitCommits(false)
go utils.Safe(func() {
if err := gui.refreshCommitsWithLimit(); err != nil {
- _ = gui.surfaceError(err)
+ _ = gui.c.Error(err)
}
})
}
- gui.escapeLineByLinePanel()
-
return nil
}
func (gui *Gui) branchCommitsRenderToMain() error {
- var task updateTask
- commit := gui.getSelectedLocalCommit()
+ var task types.UpdateTask
+ commit := gui.State.Contexts.LocalCommits.GetSelected()
if commit == nil {
- task = NewRenderStringTask(gui.Tr.NoCommitsThisBranch)
+ task = types.NewRenderStringTask(gui.c.Tr.NoCommitsThisBranch)
} else {
- cmdObj := gui.Git.Commit.ShowCmdObj(commit.Sha, gui.State.Modes.Filtering.GetPath())
- task = NewRunPtyTask(cmdObj.GetCmd())
+ cmdObj := gui.git.Commit.ShowCmdObj(commit.Sha, gui.State.Modes.Filtering.GetPath())
+ task = types.NewRunPtyTask(cmdObj.GetCmd())
}
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "Patch",
- task: task,
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: "Patch",
+ Task: task,
},
- secondary: gui.secondaryPatchPanelUpdateOpts(),
+ Secondary: gui.secondaryPatchPanelUpdateOpts(),
})
}
-// during startup, the bottleneck is fetching the reflog entries. We need these
-// on startup to sort the branches by recency. So we have two phases: INITIAL, and COMPLETE.
-// In the initial phase we don't get any reflog commits, but we asynchronously get them
-// and refresh the branches after that
-func (gui *Gui) refreshReflogCommitsConsideringStartup() {
- switch gui.State.StartupStage {
- case INITIAL:
- var wg sync.WaitGroup
- wg.Add(1)
+func (gui *Gui) secondaryPatchPanelUpdateOpts() *types.ViewUpdateOpts {
+ if gui.git.Patch.PatchManager.Active() {
+ patch := gui.git.Patch.PatchManager.RenderAggregatedPatchColored(false)
- go utils.Safe(func() {
- _ = gui.refreshReflogCommits()
- gui.refreshBranches()
- gui.State.StartupStage = COMPLETE
- wg.Done()
- })
- go utils.Safe(func() {
- // The github cli can be quite slow so we load the github PRs sparately
- if gui.Config.GetUserConfig().Git.EnableGhCommand {
- gui.refreshGithubPullRequests()
- }
-
- wg.Wait()
- _ = gui.postRefreshUpdate(gui.State.Contexts.Branches)
- })
- case COMPLETE:
- _ = gui.refreshReflogCommits()
- }
-}
-
-// whenever we change commits, we should update branches because the upstream/downstream
-// counts can change. Whenever we change branches we should probably also change commits
-// e.g. in the case of switching branches.
-func (gui *Gui) refreshCommits() {
- wg := sync.WaitGroup{}
- wg.Add(2)
-
- go utils.Safe(func() {
- gui.refreshReflogCommitsConsideringStartup()
-
- gui.refreshBranches()
- wg.Done()
- })
-
- go utils.Safe(func() {
- _ = gui.refreshCommitsWithLimit()
- context, ok := gui.State.Contexts.CommitFiles.GetParentContext()
- if ok && context.GetKey() == BRANCH_COMMITS_CONTEXT_KEY {
- // This makes sense when we've e.g. just amended a commit, meaning we get a new commit SHA at the same position.
- // However if we've just added a brand new commit, it pushes the list down by one and so we would end up
- // showing the contents of a different commit than the one we initially entered.
- // Ideally we would know when to refresh the commit files context and when not to,
- // or perhaps we could just pop that context off the stack whenever cycling windows.
- // For now the awkwardness remains.
- commit := gui.getSelectedLocalCommit()
- if commit != nil {
- gui.State.Panels.CommitFiles.refName = commit.RefName()
- _ = gui.refreshCommitFilesView()
- }
+ return &types.ViewUpdateOpts{
+ Task: types.NewRenderStringWithoutScrollTask(patch),
+ Title: gui.Tr.CustomPatch,
}
- wg.Done()
- })
-
- wg.Wait()
-}
-
-func (gui *Gui) refreshCommitsWithLimit() error {
- gui.Mutexes.BranchCommitsMutex.Lock()
- defer gui.Mutexes.BranchCommitsMutex.Unlock()
-
- commits, err := gui.Git.Loaders.Commits.GetCommits(
- loaders.GetCommitsOptions{
- Limit: gui.State.Panels.Commits.LimitCommits,
- FilterPath: gui.State.Modes.Filtering.GetPath(),
- IncludeRebaseCommits: true,
- RefName: gui.refForLog(),
- All: gui.State.ShowWholeGitGraph,
- },
- )
- if err != nil {
- return err
}
- gui.State.Commits = commits
- return gui.postRefreshUpdate(gui.State.Contexts.BranchCommits)
+ return nil
}
func (gui *Gui) refForLog() string {
- bisectInfo := gui.Git.Bisect.GetInfo()
- gui.State.BisectInfo = bisectInfo
+ bisectInfo := gui.git.Bisect.GetInfo()
+ gui.State.Model.BisectInfo = bisectInfo
if !bisectInfo.Started() {
return "HEAD"
}
// need to see if our bisect's current commit is reachable from our 'new' ref.
- if bisectInfo.Bisecting() && !gui.Git.Bisect.ReachableFromStart(bisectInfo) {
+ if bisectInfo.Bisecting() && !gui.git.Bisect.ReachableFromStart(bisectInfo) {
return bisectInfo.GetNewSha()
}
return bisectInfo.GetStartSha()
}
-
-func (gui *Gui) refreshRebaseCommits() error {
- gui.Mutexes.BranchCommitsMutex.Lock()
- defer gui.Mutexes.BranchCommitsMutex.Unlock()
-
- updatedCommits, err := gui.Git.Loaders.Commits.MergeRebasingCommits(gui.State.Commits)
- if err != nil {
- return err
- }
- gui.State.Commits = updatedCommits
-
- return gui.postRefreshUpdate(gui.State.Contexts.BranchCommits)
-}
-
-// specific functions
-
-func (gui *Gui) handleCommitSquashDown() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- if len(gui.State.Commits) <= 1 {
- return gui.createErrorPanel(gui.Tr.YouNoCommitsToSquash)
- }
-
- applied, err := gui.handleMidRebaseCommand("squash")
- if err != nil {
- return err
- }
- if applied {
- return nil
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.Squash,
- prompt: gui.Tr.SureSquashThisCommit,
- handleConfirm: func() error {
- return gui.WithWaitingStatus(gui.Tr.SquashingStatus, func() error {
- gui.logAction(gui.Tr.Actions.SquashCommitDown)
- err := gui.Git.Rebase.InteractiveRebase(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, "squash")
- return gui.handleGenericMergeCommandResult(err)
- })
- },
- })
-}
-
-func (gui *Gui) handleCommitFixup() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- if len(gui.State.Commits) <= 1 {
- return gui.createErrorPanel(gui.Tr.YouNoCommitsToSquash)
- }
-
- applied, err := gui.handleMidRebaseCommand("fixup")
- if err != nil {
- return err
- }
- if applied {
- return nil
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.Fixup,
- prompt: gui.Tr.SureFixupThisCommit,
- handleConfirm: func() error {
- return gui.WithWaitingStatus(gui.Tr.FixingStatus, func() error {
- gui.logAction(gui.Tr.Actions.FixupCommit)
- err := gui.Git.Rebase.InteractiveRebase(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, "fixup")
- return gui.handleGenericMergeCommandResult(err)
- })
- },
- })
-}
-
-func (gui *Gui) handleRewordCommit() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- applied, err := gui.handleMidRebaseCommand("reword")
- if err != nil {
- return err
- }
- if applied {
- return nil
- }
-
- commit := gui.getSelectedLocalCommit()
- if commit == nil {
- return nil
- }
-
- message, err := gui.Git.Commit.GetCommitMessage(commit.Sha)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- // TODO: use the commit message panel here
- return gui.prompt(promptOpts{
- title: gui.Tr.LcRewordCommit,
- initialContent: message,
- handleConfirm: func(response string) error {
- gui.logAction(gui.Tr.Actions.RewordCommit)
- if err := gui.Git.Rebase.RewordCommit(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, response); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
- },
- })
-}
-
-func (gui *Gui) handleRewordCommitEditor() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- applied, err := gui.handleMidRebaseCommand("reword")
- if err != nil {
- return err
- }
- if applied {
- return nil
- }
-
- gui.logAction(gui.Tr.Actions.RewordCommit)
- subProcess, err := gui.Git.Rebase.RewordCommitInEditor(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx)
- if err != nil {
- return gui.surfaceError(err)
- }
- if subProcess != nil {
- return gui.runSubprocessWithSuspenseAndRefresh(subProcess)
- }
-
- return nil
-}
-
-// handleMidRebaseCommand sees if the selected commit is in fact a rebasing
-// commit meaning you are trying to edit the todo file rather than actually
-// begin a rebase. It then updates the todo file with that action
-func (gui *Gui) handleMidRebaseCommand(action string) (bool, error) {
- selectedCommit := gui.State.Commits[gui.State.Panels.Commits.SelectedLineIdx]
- if selectedCommit.Status != "rebasing" {
- return false, nil
- }
-
- // for now we do not support setting 'reword' because it requires an editor
- // and that means we either unconditionally wait around for the subprocess to ask for
- // our input or we set a lazygit client as the EDITOR env variable and have it
- // request us to edit the commit message when prompted.
- if action == "reword" {
- return true, gui.createErrorPanel(gui.Tr.LcRewordNotSupported)
- }
-
- gui.logAction("Update rebase TODO")
- gui.logCommand(
- fmt.Sprintf("Updating rebase action of commit %s to '%s'", selectedCommit.ShortSha(), action),
- false,
- )
-
- if err := gui.Git.Rebase.EditRebaseTodo(gui.State.Panels.Commits.SelectedLineIdx, action); err != nil {
- return false, gui.surfaceError(err)
- }
-
- return true, gui.refreshRebaseCommits()
-}
-
-func (gui *Gui) handleCommitDelete() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- applied, err := gui.handleMidRebaseCommand("drop")
- if err != nil {
- return err
- }
- if applied {
- return nil
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.DeleteCommitTitle,
- prompt: gui.Tr.DeleteCommitPrompt,
- handleConfirm: func() error {
- return gui.WithWaitingStatus(gui.Tr.DeletingStatus, func() error {
- gui.logAction(gui.Tr.Actions.DropCommit)
- err := gui.Git.Rebase.InteractiveRebase(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, "drop")
- return gui.handleGenericMergeCommandResult(err)
- })
- },
- })
-}
-
-func (gui *Gui) handleCommitMoveDown() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- index := gui.State.Panels.Commits.SelectedLineIdx
- selectedCommit := gui.State.Commits[index]
- if selectedCommit.Status == "rebasing" {
- if gui.State.Commits[index+1].Status != "rebasing" {
- return nil
- }
-
- // logging directly here because MoveTodoDown doesn't have enough information
- // to provide a useful log
- gui.logAction(gui.Tr.Actions.MoveCommitDown)
- gui.logCommand(fmt.Sprintf("Moving commit %s down", selectedCommit.ShortSha()), false)
-
- if err := gui.Git.Rebase.MoveTodoDown(index); err != nil {
- return gui.surfaceError(err)
- }
- gui.State.Panels.Commits.SelectedLineIdx++
- return gui.refreshRebaseCommits()
- }
-
- return gui.WithWaitingStatus(gui.Tr.MovingStatus, func() error {
- gui.logAction(gui.Tr.Actions.MoveCommitDown)
- err := gui.Git.Rebase.MoveCommitDown(gui.State.Commits, index)
- if err == nil {
- gui.State.Panels.Commits.SelectedLineIdx++
- }
- return gui.handleGenericMergeCommandResult(err)
- })
-}
-
-func (gui *Gui) handleCommitMoveUp() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- index := gui.State.Panels.Commits.SelectedLineIdx
- if index == 0 {
- return nil
- }
-
- selectedCommit := gui.State.Commits[index]
- if selectedCommit.Status == "rebasing" {
- // logging directly here because MoveTodoDown doesn't have enough information
- // to provide a useful log
- gui.logAction(gui.Tr.Actions.MoveCommitUp)
- gui.logCommand(
- fmt.Sprintf("Moving commit %s up", selectedCommit.ShortSha()),
- false,
- )
-
- if err := gui.Git.Rebase.MoveTodoDown(index - 1); err != nil {
- return gui.surfaceError(err)
- }
- gui.State.Panels.Commits.SelectedLineIdx--
- return gui.refreshRebaseCommits()
- }
-
- return gui.WithWaitingStatus(gui.Tr.MovingStatus, func() error {
- gui.logAction(gui.Tr.Actions.MoveCommitUp)
- err := gui.Git.Rebase.MoveCommitDown(gui.State.Commits, index-1)
- if err == nil {
- gui.State.Panels.Commits.SelectedLineIdx--
- }
- return gui.handleGenericMergeCommandResult(err)
- })
-}
-
-func (gui *Gui) handleCommitEdit() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- applied, err := gui.handleMidRebaseCommand("edit")
- if err != nil {
- return err
- }
- if applied {
- return nil
- }
-
- return gui.WithWaitingStatus(gui.Tr.RebasingStatus, func() error {
- gui.logAction(gui.Tr.Actions.EditCommit)
- err = gui.Git.Rebase.InteractiveRebase(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, "edit")
- return gui.handleGenericMergeCommandResult(err)
- })
-}
-
-func (gui *Gui) handleCommitAmendTo() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.AmendCommitTitle,
- prompt: gui.Tr.AmendCommitPrompt,
- handleConfirm: func() error {
- return gui.WithWaitingStatus(gui.Tr.AmendingStatus, func() error {
- gui.logAction(gui.Tr.Actions.AmendCommit)
- err := gui.Git.Rebase.AmendTo(gui.State.Commits[gui.State.Panels.Commits.SelectedLineIdx].Sha)
- return gui.handleGenericMergeCommandResult(err)
- })
- },
- })
-}
-
-func (gui *Gui) handleCommitPick() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- applied, err := gui.handleMidRebaseCommand("pick")
- if err != nil {
- return err
- }
- if applied {
- return nil
- }
-
- // at this point we aren't actually rebasing so we will interpret this as an
- // attempt to pull. We might revoke this later after enabling configurable keybindings
- return gui.handlePullFiles()
-}
-
-func (gui *Gui) handleCommitRevert() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
- commit := gui.getSelectedLocalCommit()
- if commit.IsMerge() {
- return gui.createRevertMergeCommitMenu(commit)
- } else {
- return gui.ask(askOpts{
- title: gui.Tr.Actions.RevertCommit,
- prompt: utils.ResolvePlaceholderString(
- gui.Tr.ConfirmRevertCommit,
- map[string]string{
- "selectedCommit": commit.ShortSha(),
- }),
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.RevertCommit)
- if err := gui.Git.Commit.Revert(commit.Sha); err != nil {
- return gui.surfaceError(err)
- }
- return gui.afterRevertCommit()
- },
- })
- }
-}
-
-func (gui *Gui) createRevertMergeCommitMenu(commit *models.Commit) error {
- menuItems := make([]*menuItem, len(commit.Parents))
- for i, parentSha := range commit.Parents {
- i := i
- message, err := gui.Git.Commit.GetCommitMessageFirstLine(parentSha)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- menuItems[i] = &menuItem{
- displayString: fmt.Sprintf("%s: %s", utils.SafeTruncate(parentSha, 8), message),
- onPress: func() error {
- parentNumber := i + 1
- gui.logAction(gui.Tr.Actions.RevertCommit)
- if err := gui.Git.Commit.RevertMerge(commit.Sha, parentNumber); err != nil {
- return gui.surfaceError(err)
- }
- return gui.afterRevertCommit()
- },
- }
- }
-
- return gui.createMenu(gui.Tr.SelectParentCommitForMerge, menuItems, createMenuOptions{showCancel: true})
-}
-
-func (gui *Gui) afterRevertCommit() error {
- gui.State.Panels.Commits.SelectedLineIdx++
- return gui.refreshSidePanels(refreshOptions{mode: BLOCK_UI, scope: []RefreshableView{COMMITS, BRANCHES}})
-}
-
-func (gui *Gui) handleViewCommitFiles() error {
- commit := gui.getSelectedLocalCommit()
- if commit == nil {
- return nil
- }
-
- return gui.switchToCommitFilesContext(commit.Sha, true, gui.State.Contexts.BranchCommits, "commits")
-}
-
-func (gui *Gui) handleCreateFixupCommit() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- commit := gui.getSelectedLocalCommit()
- if commit == nil {
- return nil
- }
-
- prompt := utils.ResolvePlaceholderString(
- gui.Tr.SureCreateFixupCommit,
- map[string]string{
- "commit": commit.Sha,
- },
- )
-
- return gui.ask(askOpts{
- title: gui.Tr.CreateFixupCommit,
- prompt: prompt,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.CreateFixupCommit)
- if err := gui.Git.Commit.CreateFixupCommit(commit.Sha); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
- },
- })
-}
-
-func (gui *Gui) handleSquashAllAboveFixupCommits() error {
- if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
- return err
- }
-
- commit := gui.getSelectedLocalCommit()
- if commit == nil {
- return nil
- }
-
- prompt := utils.ResolvePlaceholderString(
- gui.Tr.SureSquashAboveCommits,
- map[string]string{
- "commit": commit.Sha,
- },
- )
-
- return gui.ask(askOpts{
- title: gui.Tr.SquashAboveCommits,
- prompt: prompt,
- handleConfirm: func() error {
- return gui.WithWaitingStatus(gui.Tr.SquashingStatus, func() error {
- gui.logAction(gui.Tr.Actions.SquashAllAboveFixupCommits)
- err := gui.Git.Rebase.SquashAllAboveFixupCommits(commit.Sha)
- return gui.handleGenericMergeCommandResult(err)
- })
- },
- })
-}
-
-func (gui *Gui) handleTagCommit() error {
- commit := gui.getSelectedLocalCommit()
- if commit == nil {
- return nil
- }
-
- return gui.createTagMenu(commit.Sha)
-}
-
-func (gui *Gui) createTagMenu(commitSha string) error {
- items := []*menuItem{
- {
- displayString: gui.Tr.LcLightweightTag,
- onPress: func() error {
- return gui.handleCreateLightweightTag(commitSha)
- },
- },
- {
- displayString: gui.Tr.LcAnnotatedTag,
- onPress: func() error {
- return gui.handleCreateAnnotatedTag(commitSha)
- },
- },
- }
-
- return gui.createMenu(gui.Tr.TagMenuTitle, items, createMenuOptions{showCancel: true})
-}
-
-func (gui *Gui) afterTagCreate() error {
- gui.State.Panels.Tags.SelectedLineIdx = 0 // Set to the top
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{COMMITS, TAGS}})
-}
-
-func (gui *Gui) handleCreateAnnotatedTag(commitSha string) error {
- return gui.prompt(promptOpts{
- title: gui.Tr.TagNameTitle,
- handleConfirm: func(tagName string) error {
- return gui.prompt(promptOpts{
- title: gui.Tr.TagMessageTitle,
- handleConfirm: func(msg string) error {
- gui.logAction(gui.Tr.Actions.CreateAnnotatedTag)
- if err := gui.Git.Tag.CreateAnnotated(tagName, commitSha, msg); err != nil {
- return gui.surfaceError(err)
- }
- return gui.afterTagCreate()
- },
- })
- },
- })
-}
-
-func (gui *Gui) handleCreateLightweightTag(commitSha string) error {
- return gui.prompt(promptOpts{
- title: gui.Tr.TagNameTitle,
- handleConfirm: func(tagName string) error {
- gui.logAction(gui.Tr.Actions.CreateLightweightTag)
- if err := gui.Git.Tag.CreateLightweight(tagName, commitSha); err != nil {
- return gui.surfaceError(err)
- }
- return gui.afterTagCreate()
- },
- })
-}
-
-func (gui *Gui) handleCheckoutCommit() error {
- commit := gui.getSelectedLocalCommit()
- if commit == nil {
- return nil
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.LcCheckoutCommit,
- prompt: gui.Tr.SureCheckoutThisCommit,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.CheckoutCommit)
- return gui.handleCheckoutRef(commit.Sha, handleCheckoutRefOptions{})
- },
- })
-}
-
-func (gui *Gui) handleCreateCommitResetMenu() error {
- commit := gui.getSelectedLocalCommit()
- if commit == nil {
- return gui.createErrorPanel(gui.Tr.NoCommitsThisBranch)
- }
-
- return gui.createResetMenu(commit.Sha)
-}
-
-func (gui *Gui) handleOpenSearchForCommitsPanel(string) error {
- // we usually lazyload these commits but now that we're searching we need to load them now
- if gui.State.Panels.Commits.LimitCommits {
- gui.State.Panels.Commits.LimitCommits = false
- if err := gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{COMMITS}}); err != nil {
- return err
- }
- }
-
- return gui.handleOpenSearch("commits")
-}
-
-func (gui *Gui) handleGotoBottomForCommitsPanel() error {
- // we usually lazyload these commits but now that we're searching we need to load them now
- if gui.State.Panels.Commits.LimitCommits {
- gui.State.Panels.Commits.LimitCommits = false
- if err := gui.refreshSidePanels(refreshOptions{mode: SYNC, scope: []RefreshableView{COMMITS}}); err != nil {
- return err
- }
- }
-
- for _, context := range gui.getListContexts() {
- if context.GetViewName() == "commits" {
- return context.handleGotoBottom()
- }
- }
-
- return nil
-}
-
-func (gui *Gui) handleCopySelectedCommitMessageToClipboard() error {
- commit := gui.getSelectedLocalCommit()
- if commit == nil {
- return nil
- }
-
- message, err := gui.Git.Commit.GetCommitMessage(commit.Sha)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- gui.logAction(gui.Tr.Actions.CopyCommitMessageToClipboard)
- if err := gui.OSCommand.CopyToClipboard(message); err != nil {
- return gui.surfaceError(err)
- }
-
- gui.raiseToast(gui.Tr.CommitMessageCopiedToClipboard)
-
- return nil
-}
-
-func (gui *Gui) handleOpenLogMenu() error {
- return gui.createMenu(gui.Tr.LogMenuTitle, []*menuItem{
- {
- displayString: gui.Tr.ToggleShowGitGraphAll,
- onPress: func() error {
- gui.State.ShowWholeGitGraph = !gui.State.ShowWholeGitGraph
-
- if gui.State.ShowWholeGitGraph {
- gui.State.Panels.Commits.LimitCommits = false
- }
-
- return gui.WithWaitingStatus(gui.Tr.LcLoadingCommits, func() error {
- return gui.refreshSidePanels(refreshOptions{mode: SYNC, scope: []RefreshableView{COMMITS}})
- })
- },
- },
- {
- displayString: gui.Tr.ShowGitGraph,
- opensMenu: true,
- onPress: func() error {
- onSelect := func(value string) {
- gui.UserConfig.Git.Log.ShowGraph = value
- gui.render()
- }
- return gui.createMenu(gui.Tr.LogMenuTitle, []*menuItem{
- {
- displayString: "always",
- onPress: func() error {
- onSelect("always")
- return nil
- },
- },
- {
- displayString: "never",
- onPress: func() error {
- onSelect("never")
- return nil
- },
- },
- {
- displayString: "when maximised",
- onPress: func() error {
- onSelect("when-maximised")
- return nil
- },
- },
- }, createMenuOptions{showCancel: true})
- },
- },
- {
- displayString: gui.Tr.SortCommits,
- opensMenu: true,
- onPress: func() error {
- onSelect := func(value string) error {
- gui.UserConfig.Git.Log.Order = value
- return gui.WithWaitingStatus(gui.Tr.LcLoadingCommits, func() error {
- return gui.refreshSidePanels(refreshOptions{mode: SYNC, scope: []RefreshableView{COMMITS}})
- })
- }
- return gui.createMenu(gui.Tr.LogMenuTitle, []*menuItem{
- {
- displayString: "topological (topo-order)",
- onPress: func() error {
- return onSelect("topo-order")
- },
- },
- {
- displayString: "date-order",
- onPress: func() error {
- return onSelect("date-order")
- },
- },
- {
- displayString: "author-date-order",
- onPress: func() error {
- return onSelect("author-date-order")
- },
- },
- }, createMenuOptions{showCancel: true})
- },
- },
- }, createMenuOptions{showCancel: true})
-}
-
-func (gui *Gui) handleOpenCommitInBrowser() error {
- commit := gui.getSelectedLocalCommit()
- if commit == nil {
- return nil
- }
-
- hostingServiceMgr := gui.getHostingServiceMgr()
-
- url, err := hostingServiceMgr.GetCommitURL(commit.Sha)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- gui.logAction(gui.Tr.Actions.OpenCommitInBrowser)
- if err := gui.OSCommand.OpenLink(url); err != nil {
- return gui.surfaceError(err)
- }
-
- return nil
-}
diff --git a/pkg/gui/confirmation_panel.go b/pkg/gui/confirmation_panel.go
index b092b1d10..1ad724ad5 100644
--- a/pkg/gui/confirmation_panel.go
+++ b/pkg/gui/confirmation_panel.go
@@ -5,62 +5,26 @@ import (
"strings"
"github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/mattn/go-runewidth"
)
-type createPopupPanelOpts struct {
- hasLoader bool
- editable bool
- title string
- prompt string
- handleConfirm func() error
- handleConfirmPrompt func(string) error
- handleClose func() error
+// This file is for the rendering of confirmation panels along with setting and handling associated
+// keybindings.
- // when handlersManageFocus is true, do not return from the confirmation context automatically. It's expected that the handlers will manage focus, whether that means switching to another context, or manually returning the context.
- handlersManageFocus bool
-
- findSuggestionsFunc func(string) []*types.Suggestion
-}
-
-type askOpts struct {
- title string
- prompt string
- handleConfirm func() error
- handleClose func() error
- handlersManageFocus bool
-}
-
-type promptOpts struct {
- title string
- initialContent string
- findSuggestionsFunc func(string) []*types.Suggestion
- handleConfirm func(string) error
-}
-
-func (gui *Gui) ask(opts askOpts) error {
- return gui.PopupHandler.Ask(opts)
-}
-
-func (gui *Gui) prompt(opts promptOpts) error {
- return gui.PopupHandler.Prompt(opts)
-}
-
-func (gui *Gui) createLoaderPanel(prompt string) error {
- return gui.PopupHandler.Loader(prompt)
-}
-
-func (gui *Gui) wrappedConfirmationFunction(handlersManageFocus bool, function func() error) func() error {
+func (gui *Gui) wrappedConfirmationFunction(function func() error) func() error {
return func() error {
- if err := gui.closeConfirmationPrompt(handlersManageFocus); err != nil {
+ if err := gui.c.PopContext(); err != nil {
return err
}
if function != nil {
if err := function(); err != nil {
- return gui.surfaceError(err)
+ return gui.c.Error(err)
}
}
@@ -68,15 +32,15 @@ func (gui *Gui) wrappedConfirmationFunction(handlersManageFocus bool, function f
}
}
-func (gui *Gui) wrappedPromptConfirmationFunction(handlersManageFocus bool, function func(string) error, getResponse func() string) func() error {
+func (gui *Gui) wrappedPromptConfirmationFunction(function func(string) error, getResponse func() string) func() error {
return func() error {
- if err := gui.closeConfirmationPrompt(handlersManageFocus); err != nil {
+ if err := gui.c.PopContext(); err != nil {
return err
}
if function != nil {
if err := function(getResponse()); err != nil {
- return gui.surfaceError(err)
+ return gui.c.Error(err)
}
}
@@ -84,23 +48,15 @@ func (gui *Gui) wrappedPromptConfirmationFunction(handlersManageFocus bool, func
}
}
-func (gui *Gui) closeConfirmationPrompt(handlersManageFocus bool) error {
- // we've already closed it so we can just return
- if !gui.Views.Confirmation.Visible {
- return nil
- }
+func (gui *Gui) deactivateConfirmationPrompt() {
+ gui.Mutexes.PopupMutex.Lock()
+ gui.State.CurrentPopupOpts = nil
+ gui.Mutexes.PopupMutex.Unlock()
- if !handlersManageFocus {
- if err := gui.returnFromContext(); err != nil {
- return err
- }
- }
-
- gui.clearConfirmationViewKeyBindings()
gui.Views.Confirmation.Visible = false
gui.Views.Suggestions.Visible = false
- return nil
+ gui.clearConfirmationViewKeyBindings()
}
func (gui *Gui) getMessageHeight(wrap bool, message string, width int) int {
@@ -109,7 +65,7 @@ func (gui *Gui) getMessageHeight(wrap bool, message string, width int) int {
// if we need to wrap, calculate height to fit content within view's width
if wrap {
for _, line := range lines {
- lineCount += len(line)/width + 1
+ lineCount += runewidth.StringWidth(line)/width + 1
}
} else {
lineCount = len(lines)
@@ -118,7 +74,28 @@ func (gui *Gui) getMessageHeight(wrap bool, message string, width int) int {
}
func (gui *Gui) getConfirmationPanelDimensions(wrap bool, prompt string) (int, int, int, int) {
+ panelWidth := gui.getConfirmationPanelWidth()
+ panelHeight := gui.getMessageHeight(wrap, prompt, panelWidth)
+ return gui.getConfirmationPanelDimensionsAux(panelWidth, panelHeight)
+}
+
+func (gui *Gui) getConfirmationPanelDimensionsForContentHeight(panelWidth, contentHeight int) (int, int, int, int) {
+ return gui.getConfirmationPanelDimensionsAux(panelWidth, contentHeight)
+}
+
+func (gui *Gui) getConfirmationPanelDimensionsAux(panelWidth int, panelHeight int) (int, int, int, int) {
width, height := gui.g.Size()
+ if panelHeight > height*3/4 {
+ panelHeight = height * 3 / 4
+ }
+ return width/2 - panelWidth/2,
+ height/2 - panelHeight/2 - panelHeight%2 - 1,
+ width/2 + panelWidth/2,
+ height/2 + panelHeight/2
+}
+
+func (gui *Gui) getConfirmationPanelWidth() int {
+ width, _ := gui.g.Size()
// we want a minimum width up to a point, then we do it based on ratio.
panelWidth := 4 * width / 7
minWidth := 80
@@ -129,81 +106,82 @@ func (gui *Gui) getConfirmationPanelDimensions(wrap bool, prompt string) (int, i
panelWidth = minWidth
}
}
- panelHeight := gui.getMessageHeight(wrap, prompt, panelWidth)
- if panelHeight > height*3/4 {
- panelHeight = height * 3 / 4
- }
- return width/2 - panelWidth/2,
- height/2 - panelHeight/2 - panelHeight%2 - 1,
- width/2 + panelWidth/2,
- height/2 + panelHeight/2
+
+ return panelWidth
}
func (gui *Gui) prepareConfirmationPanel(
- title,
- prompt string,
- hasLoader bool,
- findSuggestionsFunc func(string) []*types.Suggestion,
- editable bool,
+ opts types.ConfirmOpts,
) error {
- x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(true, prompt)
- // calling SetView on an existing view returns the same view, so I'm not bothering
- // to reassign to gui.Views.Confirmation
- _, err := gui.g.SetView("confirmation", x0, y0, x1, y1, 0)
- if err != nil {
- return err
- }
- gui.Views.Confirmation.HasLoader = hasLoader
- if hasLoader {
+ gui.Views.Confirmation.HasLoader = opts.HasLoader
+ if opts.HasLoader {
gui.g.StartTicking()
}
- gui.Views.Confirmation.Title = title
+ gui.Views.Confirmation.Title = opts.Title
// for now we do not support wrapping in our editor
- gui.Views.Confirmation.Wrap = !editable
+ gui.Views.Confirmation.Wrap = !opts.Editable
gui.Views.Confirmation.FgColor = theme.GocuiDefaultTextColor
+ gui.Views.Confirmation.Mask = runeForMask(opts.Mask)
- gui.findSuggestions = findSuggestionsFunc
- if findSuggestionsFunc != nil {
- suggestionsViewHeight := 11
- suggestionsView, err := gui.g.SetView("suggestions", x0, y1+1, x1, y1+suggestionsViewHeight, 0)
- if err != nil {
- return err
- }
+ gui.findSuggestions = opts.FindSuggestionsFunc
+ if opts.FindSuggestionsFunc != nil {
+ suggestionsView := gui.Views.Suggestions
suggestionsView.Wrap = false
suggestionsView.FgColor = theme.GocuiDefaultTextColor
- gui.setSuggestions(findSuggestionsFunc(""))
+ gui.setSuggestions(opts.FindSuggestionsFunc(""))
suggestionsView.Visible = true
- suggestionsView.Title = fmt.Sprintf(gui.Tr.SuggestionsTitle, gui.UserConfig.Keybinding.Universal.TogglePanel)
+ suggestionsView.Title = fmt.Sprintf(gui.c.Tr.SuggestionsTitle, gui.c.UserConfig.Keybinding.Universal.TogglePanel)
}
return nil
}
-func (gui *Gui) createPopupPanel(opts createPopupPanelOpts) error {
+func runeForMask(mask bool) rune {
+ if mask {
+ return '*'
+ }
+ return 0
+}
+
+func (gui *Gui) createPopupPanel(opts types.CreatePopupPanelOpts) error {
+ gui.Mutexes.PopupMutex.Lock()
+ defer gui.Mutexes.PopupMutex.Unlock()
+
+ // we don't allow interruptions of non-loader popups in case we get stuck somehow
+ // e.g. a credentials popup never gets its required user input so a process hangs
+ // forever.
+ // The proper solution is to have a queue of popup options
+ if gui.State.CurrentPopupOpts != nil && !gui.State.CurrentPopupOpts.HasLoader {
+ gui.Log.Error("ignoring create popup panel because a popup panel is already open")
+ return nil
+ }
+
// remove any previous keybindings
gui.clearConfirmationViewKeyBindings()
err := gui.prepareConfirmationPanel(
- opts.title,
- opts.prompt,
- opts.hasLoader,
- opts.findSuggestionsFunc,
- opts.editable,
- )
+ types.ConfirmOpts{
+ Title: opts.Title,
+ Prompt: opts.Prompt,
+ HasLoader: opts.HasLoader,
+ FindSuggestionsFunc: opts.FindSuggestionsFunc,
+ Editable: opts.Editable,
+ Mask: opts.Mask,
+ })
if err != nil {
return err
}
confirmationView := gui.Views.Confirmation
- confirmationView.Editable = opts.editable
+ confirmationView.Editable = opts.Editable
confirmationView.Editor = gocui.EditorFunc(gui.defaultEditor)
- if opts.editable {
+ if opts.Editable {
textArea := confirmationView.TextArea
textArea.Clear()
- textArea.TypeString(opts.prompt)
+ textArea.TypeString(opts.Prompt)
confirmationView.RenderTextArea()
} else {
- if err := gui.renderString(confirmationView, opts.prompt); err != nil {
+ if err := gui.renderString(confirmationView, style.AttrBold.Sprint(opts.Prompt)); err != nil {
return err
}
}
@@ -212,12 +190,14 @@ func (gui *Gui) createPopupPanel(opts createPopupPanelOpts) error {
return err
}
- return gui.pushContext(gui.State.Contexts.Confirmation)
+ gui.State.CurrentPopupOpts = &opts
+
+ return gui.c.PushContext(gui.State.Contexts.Confirmation)
}
-func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error {
+func (gui *Gui) setKeyBindings(opts types.CreatePopupPanelOpts) error {
actions := utils.ResolvePlaceholderString(
- gui.Tr.CloseConfirm,
+ gui.c.Tr.CloseConfirm,
map[string]string{
"keyBindClose": "esc",
"keyBindConfirm": "enter",
@@ -226,45 +206,38 @@ func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error {
_ = gui.renderString(gui.Views.Options, actions)
var onConfirm func() error
- if opts.handleConfirmPrompt != nil {
- onConfirm = gui.wrappedPromptConfirmationFunction(opts.handlersManageFocus, opts.handleConfirmPrompt, func() string { return gui.Views.Confirmation.TextArea.GetContent() })
+ if opts.HandleConfirmPrompt != nil {
+ onConfirm = gui.wrappedPromptConfirmationFunction(opts.HandleConfirmPrompt, func() string { return gui.Views.Confirmation.TextArea.GetContent() })
} else {
- onConfirm = gui.wrappedConfirmationFunction(opts.handlersManageFocus, opts.handleConfirm)
+ onConfirm = gui.wrappedConfirmationFunction(opts.HandleConfirm)
}
- type confirmationKeybinding struct {
- viewName string
- key interface{}
- handler func() error
- }
-
- keybindingConfig := gui.UserConfig.Keybinding
+ keybindingConfig := gui.c.UserConfig.Keybinding
onSuggestionConfirm := gui.wrappedPromptConfirmationFunction(
- opts.handlersManageFocus,
- opts.handleConfirmPrompt,
+ opts.HandleConfirmPrompt,
gui.getSelectedSuggestionValue,
)
- confirmationKeybindings := []confirmationKeybinding{
+ bindings := []*types.Binding{
{
- viewName: "confirmation",
- key: gui.getKey(keybindingConfig.Universal.Confirm),
- handler: onConfirm,
+ ViewName: "confirmation",
+ Key: keybindings.GetKey(keybindingConfig.Universal.Confirm),
+ Handler: onConfirm,
},
{
- viewName: "confirmation",
- key: gui.getKey(keybindingConfig.Universal.ConfirmAlt1),
- handler: onConfirm,
+ ViewName: "confirmation",
+ Key: keybindings.GetKey(keybindingConfig.Universal.ConfirmAlt1),
+ Handler: onConfirm,
},
{
- viewName: "confirmation",
- key: gui.getKey(keybindingConfig.Universal.Return),
- handler: gui.wrappedConfirmationFunction(opts.handlersManageFocus, opts.handleClose),
+ ViewName: "confirmation",
+ Key: keybindings.GetKey(keybindingConfig.Universal.Return),
+ Handler: gui.wrappedConfirmationFunction(opts.HandleClose),
},
{
- viewName: "confirmation",
- key: gui.getKey(keybindingConfig.Universal.TogglePanel),
- handler: func() error {
+ ViewName: "confirmation",
+ Key: keybindings.GetKey(keybindingConfig.Universal.TogglePanel),
+ Handler: func() error {
if len(gui.State.Suggestions) > 0 {
return gui.replaceContext(gui.State.Contexts.Suggestions)
}
@@ -272,29 +245,29 @@ func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error {
},
},
{
- viewName: "suggestions",
- key: gui.getKey(keybindingConfig.Universal.Confirm),
- handler: onSuggestionConfirm,
+ ViewName: "suggestions",
+ Key: keybindings.GetKey(keybindingConfig.Universal.Confirm),
+ Handler: onSuggestionConfirm,
},
{
- viewName: "suggestions",
- key: gui.getKey(keybindingConfig.Universal.ConfirmAlt1),
- handler: onSuggestionConfirm,
+ ViewName: "suggestions",
+ Key: keybindings.GetKey(keybindingConfig.Universal.ConfirmAlt1),
+ Handler: onSuggestionConfirm,
},
{
- viewName: "suggestions",
- key: gui.getKey(keybindingConfig.Universal.Return),
- handler: gui.wrappedConfirmationFunction(opts.handlersManageFocus, opts.handleClose),
+ ViewName: "suggestions",
+ Key: keybindings.GetKey(keybindingConfig.Universal.Return),
+ Handler: gui.wrappedConfirmationFunction(opts.HandleClose),
},
{
- viewName: "suggestions",
- key: gui.getKey(keybindingConfig.Universal.TogglePanel),
- handler: func() error { return gui.replaceContext(gui.State.Contexts.Confirmation) },
+ ViewName: "suggestions",
+ Key: keybindings.GetKey(keybindingConfig.Universal.TogglePanel),
+ Handler: func() error { return gui.replaceContext(gui.State.Contexts.Confirmation) },
},
}
- for _, binding := range confirmationKeybindings {
- if err := gui.g.SetKeybinding(binding.viewName, nil, binding.key, gocui.ModNone, gui.wrappedHandler(binding.handler)); err != nil {
+ for _, binding := range bindings {
+ if err := gui.SetKeybinding(binding); err != nil {
return err
}
}
@@ -303,33 +276,32 @@ func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error {
}
func (gui *Gui) clearConfirmationViewKeyBindings() {
- keybindingConfig := gui.UserConfig.Keybinding
- _ = gui.g.DeleteKeybinding("confirmation", gui.getKey(keybindingConfig.Universal.Confirm), gocui.ModNone)
- _ = gui.g.DeleteKeybinding("confirmation", gui.getKey(keybindingConfig.Universal.ConfirmAlt1), gocui.ModNone)
- _ = gui.g.DeleteKeybinding("confirmation", gui.getKey(keybindingConfig.Universal.Return), gocui.ModNone)
- _ = gui.g.DeleteKeybinding("suggestions", gui.getKey(keybindingConfig.Universal.Confirm), gocui.ModNone)
- _ = gui.g.DeleteKeybinding("suggestions", gui.getKey(keybindingConfig.Universal.ConfirmAlt1), gocui.ModNone)
- _ = gui.g.DeleteKeybinding("suggestions", gui.getKey(keybindingConfig.Universal.Return), gocui.ModNone)
+ keybindingConfig := gui.c.UserConfig.Keybinding
+ _ = gui.g.DeleteKeybinding("confirmation", keybindings.GetKey(keybindingConfig.Universal.Confirm), gocui.ModNone)
+ _ = gui.g.DeleteKeybinding("confirmation", keybindings.GetKey(keybindingConfig.Universal.ConfirmAlt1), gocui.ModNone)
+ _ = gui.g.DeleteKeybinding("confirmation", keybindings.GetKey(keybindingConfig.Universal.Return), gocui.ModNone)
+ _ = gui.g.DeleteKeybinding("suggestions", keybindings.GetKey(keybindingConfig.Universal.Confirm), gocui.ModNone)
+ _ = gui.g.DeleteKeybinding("suggestions", keybindings.GetKey(keybindingConfig.Universal.ConfirmAlt1), gocui.ModNone)
+ _ = gui.g.DeleteKeybinding("suggestions", keybindings.GetKey(keybindingConfig.Universal.Return), gocui.ModNone)
}
-func (gui *Gui) wrappedHandler(f func() error) func(g *gocui.Gui, v *gocui.View) error {
- return func(g *gocui.Gui, v *gocui.View) error {
- return f()
- }
+func (gui *Gui) refreshSuggestions() {
+ gui.suggestionsAsyncHandler.Do(func() func() {
+ suggestions := gui.findSuggestions(gui.c.GetPromptInput())
+ return func() { gui.setSuggestions(suggestions) }
+ })
}
-func (gui *Gui) createErrorPanel(message string) error {
- return gui.PopupHandler.Error(message)
-}
-
-func (gui *Gui) surfaceError(err error) error {
- if err == nil {
- return nil
- }
-
- if err == gocui.ErrQuit {
- return err
- }
-
- return gui.createErrorPanel(err.Error())
+func (gui *Gui) handleAskFocused() error {
+ keybindingConfig := gui.c.UserConfig.Keybinding
+
+ message := utils.ResolvePlaceholderString(
+ gui.c.Tr.CloseConfirm,
+ map[string]string{
+ "keyBindClose": keybindings.Label(keybindingConfig.Universal.Return),
+ "keyBindConfirm": keybindings.Label(keybindingConfig.Universal.Confirm),
+ },
+ )
+
+ return gui.renderString(gui.Views.Options, message)
}
diff --git a/pkg/gui/context.go b/pkg/gui/context.go
index 7aa9a1046..e4719ab13 100644
--- a/pkg/gui/context.go
+++ b/pkg/gui/context.go
@@ -1,132 +1,114 @@
package gui
import (
- "errors"
- "fmt"
+ "sort"
+ "strings"
+ "github.com/jesseduffield/generics/maps"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
-type ContextKind int
-
-const (
- SIDE_CONTEXT ContextKind = iota
- MAIN_CONTEXT
- TEMPORARY_POPUP
- PERSISTENT_POPUP
- EXTRAS_CONTEXT
-)
-
-type OnFocusOpts struct {
- ClickedViewName string
- ClickedViewLineIdx int
-}
-
-type Context interface {
- HandleFocus(opts ...OnFocusOpts) error
- HandleFocusLost() error
- HandleRender() error
- HandleRenderToMain() error
- GetKind() ContextKind
- GetViewName() string
- GetWindowName() string
- SetWindowName(string)
- GetKey() ContextKey
- SetParentContext(Context)
-
- // we return a bool here to tell us whether or not the returned value just wraps a nil
- GetParentContext() (Context, bool)
- GetOptionsMap() map[string]string
-}
+// This file is for the management of contexts. There is a context stack such that
+// for example you might start off in the commits context and then open a menu, putting
+// you in the menu context. When contexts are activated/deactivated certain things need
+// to happen like showing/hiding views and rendering content.
func (gui *Gui) popupViewNames() []string {
- result := []string{}
- for _, context := range gui.allContexts() {
- if context.GetKind() == PERSISTENT_POPUP || context.GetKind() == TEMPORARY_POPUP {
- result = append(result, context.GetViewName())
- }
- }
+ popups := slices.Filter(gui.State.Contexts.Flatten(), func(c types.Context) bool {
+ return c.GetKind() == types.PERSISTENT_POPUP || c.GetKind() == types.TEMPORARY_POPUP
+ })
- return result
-}
-
-func (gui *Gui) currentContextKeyIgnoringPopups() ContextKey {
- gui.State.ContextManager.RLock()
- defer gui.State.ContextManager.RUnlock()
-
- stack := gui.State.ContextManager.ContextStack
-
- for i := range stack {
- reversedIndex := len(stack) - 1 - i
- context := stack[reversedIndex]
- kind := stack[reversedIndex].GetKind()
- if kind != TEMPORARY_POPUP && kind != PERSISTENT_POPUP {
- return context.GetKey()
- }
- }
-
- return ""
+ return slices.Map(popups, func(c types.Context) string {
+ return c.GetViewName()
+ })
}
// use replaceContext when you don't want to return to the original context upon
// hitting escape: you want to go that context's parent instead.
-func (gui *Gui) replaceContext(c Context) error {
+func (gui *Gui) replaceContext(c types.Context) error {
+ if !c.IsFocusable() {
+ return nil
+ }
+
gui.State.ContextManager.Lock()
- defer gui.State.ContextManager.Unlock()
if len(gui.State.ContextManager.ContextStack) == 0 {
- gui.State.ContextManager.ContextStack = []Context{c}
+ gui.State.ContextManager.ContextStack = []types.Context{c}
} else {
// replace the last item with the given item
gui.State.ContextManager.ContextStack = append(gui.State.ContextManager.ContextStack[0:len(gui.State.ContextManager.ContextStack)-1], c)
}
- return gui.activateContext(c)
+ defer gui.State.ContextManager.Unlock()
+
+ return gui.activateContext(c, types.OnFocusOpts{})
}
-func (gui *Gui) pushContext(c Context, opts ...OnFocusOpts) error {
- // using triple dot but you should only ever pass one of these opt structs
- if len(opts) > 1 {
- return errors.New("cannot pass multiple opts to pushContext")
+func (gui *Gui) pushContext(c types.Context, opts types.OnFocusOpts) error {
+ if !c.IsFocusable() {
+ return nil
}
+ contextsToDeactivate := gui.pushToContextStack(c)
+
+ for _, contextToDeactivate := range contextsToDeactivate {
+ if err := gui.deactivateContext(contextToDeactivate, types.OnFocusLostOpts{NewContextKey: c.GetKey()}); err != nil {
+ return err
+ }
+ }
+
+ return gui.activateContext(c, opts)
+}
+
+// Adjusts the context stack based on the context that's being pushed and returns contexts to deactivate
+func (gui *Gui) pushToContextStack(c types.Context) []types.Context {
+ contextsToDeactivate := []types.Context{}
+
gui.State.ContextManager.Lock()
+ defer gui.State.ContextManager.Unlock()
- // push onto stack
- // if we are switching to a side context, remove all other contexts in the stack
- if c.GetKind() == SIDE_CONTEXT {
+ if len(gui.State.ContextManager.ContextStack) == 0 {
+ gui.State.ContextManager.ContextStack = append(gui.State.ContextManager.ContextStack, c)
+ } else if c.GetKind() == types.SIDE_CONTEXT {
+ // if we are switching to a side context, remove all other contexts in the stack
+ contextsToDeactivate = gui.State.ContextManager.ContextStack
+ gui.State.ContextManager.ContextStack = []types.Context{c}
+ } else if c.GetKind() == types.MAIN_CONTEXT {
+ // if we're switching to a main context, remove all other main contexts in the stack
for _, stackContext := range gui.State.ContextManager.ContextStack {
- if stackContext.GetKey() != c.GetKey() {
- if err := gui.deactivateContext(stackContext); err != nil {
- gui.State.ContextManager.Unlock()
- return err
- }
+ if stackContext.GetKind() == types.MAIN_CONTEXT {
+ contextsToDeactivate = append(contextsToDeactivate, stackContext)
}
}
- gui.State.ContextManager.ContextStack = []Context{c}
- } else if len(gui.State.ContextManager.ContextStack) == 0 || gui.currentContextWithoutLock().GetKey() != c.GetKey() {
- // Do not append if the one at the end is the same context (e.g. opening a menu from a menu)
- // In that case we'll just close the menu entirely when the user hits escape.
+ gui.State.ContextManager.ContextStack = []types.Context{c}
+ } else {
+ topContext := gui.currentContextWithoutLock()
- // TODO: think about other exceptional cases
- gui.State.ContextManager.ContextStack = append(gui.State.ContextManager.ContextStack, c)
+ // if we're pushing the same context on, we do nothing.
+ if topContext.GetKey() != c.GetKey() {
+ // if top one is a temporary popup, we remove it. Ideally you'd be able to
+ // escape back to previous temporary popups, but because we're currently reusing
+ // views for this, you might not be able to get back to where you previously were.
+ // The exception is when going to the search context e.g. for searching a menu.
+ if (topContext.GetKind() == types.TEMPORARY_POPUP && c.GetKey() != context.SEARCH_CONTEXT_KEY) ||
+ // we only ever want one main context on the stack at a time.
+ (topContext.GetKind() == types.MAIN_CONTEXT && c.GetKind() == types.MAIN_CONTEXT) {
+
+ contextsToDeactivate = append(contextsToDeactivate, topContext)
+ _, gui.State.ContextManager.ContextStack = slices.Pop(gui.State.ContextManager.ContextStack)
+ }
+
+ gui.State.ContextManager.ContextStack = append(gui.State.ContextManager.ContextStack, c)
+ }
}
- gui.State.ContextManager.Unlock()
-
- return gui.activateContext(c, opts...)
+ return contextsToDeactivate
}
-// asynchronous code idea: functions return an error via a channel, when done
-
-// pushContextWithView is to be used when you don't know which context you
-// want to switch to: you only know the view that you want to switch to. It will
-// look up the context currently active for that view and switch to that context
-func (gui *Gui) pushContextWithView(viewName string) error {
- return gui.pushContext(gui.State.ViewContextMap[viewName])
-}
-
-func (gui *Gui) returnFromContext() error {
+func (gui *Gui) popContext() error {
gui.State.ContextManager.Lock()
if len(gui.State.ContextManager.ContextStack) == 1 {
@@ -135,23 +117,21 @@ func (gui *Gui) returnFromContext() error {
return nil
}
- n := len(gui.State.ContextManager.ContextStack) - 1
+ var currentContext types.Context
+ currentContext, gui.State.ContextManager.ContextStack = slices.Pop(gui.State.ContextManager.ContextStack)
- currentContext := gui.State.ContextManager.ContextStack[n]
- newContext := gui.State.ContextManager.ContextStack[n-1]
-
- gui.State.ContextManager.ContextStack = gui.State.ContextManager.ContextStack[:n]
+ newContext := gui.State.ContextManager.ContextStack[len(gui.State.ContextManager.ContextStack)-1]
gui.State.ContextManager.Unlock()
- if err := gui.deactivateContext(currentContext); err != nil {
+ if err := gui.deactivateContext(currentContext, types.OnFocusLostOpts{NewContextKey: newContext.GetKey()}); err != nil {
return err
}
- return gui.activateContext(newContext)
+ return gui.activateContext(newContext, types.OnFocusOpts{})
}
-func (gui *Gui) deactivateContext(c Context) error {
+func (gui *Gui) deactivateContext(c types.Context, opts types.OnFocusLostOpts) error {
view, _ := gui.g.View(c.GetViewName())
if view != nil && view.IsSearching() {
@@ -161,11 +141,13 @@ func (gui *Gui) deactivateContext(c Context) error {
}
// if we are the kind of context that is sent to back upon deactivation, we should do that
- if view != nil && (c.GetKind() == TEMPORARY_POPUP || c.GetKind() == PERSISTENT_POPUP || c.GetKey() == COMMIT_FILES_CONTEXT_KEY) {
+ if view != nil &&
+ (c.GetKind() == types.TEMPORARY_POPUP ||
+ c.GetKind() == types.PERSISTENT_POPUP) {
view.Visible = false
}
- if err := c.HandleFocusLost(); err != nil {
+ if err := c.HandleFocusLost(opts); err != nil {
return err
}
@@ -175,22 +157,13 @@ func (gui *Gui) deactivateContext(c Context) error {
// postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed
// if the context's view is set to another context we do nothing.
// if the context's view is the current view we trigger a focus; re-selecting the current item.
-func (gui *Gui) postRefreshUpdate(c Context) error {
- v, err := gui.g.View(c.GetViewName())
- if err != nil {
- return nil
- }
-
- if ContextKey(v.Context) != c.GetKey() {
- return nil
- }
-
+func (gui *Gui) postRefreshUpdate(c types.Context) error {
if err := c.HandleRender(); err != nil {
return err
}
if gui.currentViewName() == c.GetViewName() {
- if err := c.HandleFocus(); err != nil {
+ if err := c.HandleFocus(types.OnFocusOpts{}); err != nil {
return err
}
}
@@ -198,39 +171,26 @@ func (gui *Gui) postRefreshUpdate(c Context) error {
return nil
}
-func (gui *Gui) activateContext(c Context, opts ...OnFocusOpts) error {
+func (gui *Gui) activateContext(c types.Context, opts types.OnFocusOpts) error {
viewName := c.GetViewName()
v, err := gui.g.View(viewName)
if err != nil {
return err
}
- originalViewContextKey := ContextKey(v.Context)
- // ensure that any other window for which this view was active is now set to the default for that window.
- gui.setViewAsActiveForWindow(v)
-
- if viewName == "main" {
- gui.changeMainViewsContext(c.GetKey())
- } else {
- gui.changeMainViewsContext(MAIN_NORMAL_CONTEXT_KEY)
- }
-
- gui.setViewTabForContext(c)
+ gui.setWindowContext(c)
+ gui.moveToTopOfWindow(c)
if _, err := gui.g.SetCurrentView(viewName); err != nil {
return err
}
- v.Visible = true
-
- // if the new context's view was previously displaying another context, render the new context
- if originalViewContextKey != c.GetKey() {
- if err := c.HandleRender(); err != nil {
- return err
- }
+ desiredTitle := c.Title()
+ if desiredTitle != "" {
+ v.Title = desiredTitle
}
- v.Context = string(c.GetKey())
+ v.Visible = true
gui.g.Cursor = v.Editable
@@ -241,16 +201,25 @@ func (gui *Gui) activateContext(c Context, opts ...OnFocusOpts) error {
}
gui.renderOptionsMap(optionsMap)
- if err := c.HandleFocus(opts...); err != nil {
+ if err := c.HandleFocus(opts); err != nil {
return err
}
- // TODO: consider removing this and instead depending on the .Context field of views
- gui.State.ViewContextMap[c.GetViewName()] = c
-
return nil
}
+func (gui *Gui) optionsMapToString(optionsMap map[string]string) string {
+ options := maps.MapToSlice(optionsMap, func(key string, description string) string {
+ return key + ": " + description
+ })
+ sort.Strings(options)
+ return strings.Join(options, ", ")
+}
+
+func (gui *Gui) renderOptionsMap(optionsMap map[string]string) {
+ _ = gui.renderString(gui.Views.Options, gui.optionsMapToString(optionsMap))
+}
+
// // currently unused
// func (gui *Gui) renderContextStack() string {
// result := ""
@@ -260,14 +229,14 @@ func (gui *Gui) activateContext(c Context, opts ...OnFocusOpts) error {
// return result
// }
-func (gui *Gui) currentContext() Context {
+func (gui *Gui) currentContext() types.Context {
gui.State.ContextManager.RLock()
defer gui.State.ContextManager.RUnlock()
return gui.currentContextWithoutLock()
}
-func (gui *Gui) currentContextWithoutLock() Context {
+func (gui *Gui) currentContextWithoutLock() types.Context {
if len(gui.State.ContextManager.ContextStack) == 0 {
return gui.defaultSideContext()
}
@@ -277,16 +246,16 @@ func (gui *Gui) currentContextWithoutLock() Context {
// the status panel is not yet a list context (and may never be), so this method is not
// quite the same as currentSideContext()
-func (gui *Gui) currentSideListContext() IListContext {
+func (gui *Gui) currentSideListContext() types.IListContext {
context := gui.currentSideContext()
- listContext, ok := context.(IListContext)
+ listContext, ok := context.(types.IListContext)
if !ok {
return nil
}
return listContext
}
-func (gui *Gui) currentSideContext() Context {
+func (gui *Gui) currentSideContext() types.Context {
gui.State.ContextManager.RLock()
defer gui.State.ContextManager.RUnlock()
@@ -297,11 +266,11 @@ func (gui *Gui) currentSideContext() Context {
return gui.defaultSideContext()
}
- // find the first context in the stack with the type of SIDE_CONTEXT
+ // find the first context in the stack with the type of types.SIDE_CONTEXT
for i := range stack {
context := stack[len(stack)-1-i]
- if context.GetKind() == SIDE_CONTEXT {
+ if context.GetKind() == types.SIDE_CONTEXT {
return context
}
}
@@ -310,10 +279,14 @@ func (gui *Gui) currentSideContext() Context {
}
// static as opposed to popup
-func (gui *Gui) currentStaticContext() Context {
+func (gui *Gui) currentStaticContext() types.Context {
gui.State.ContextManager.RLock()
defer gui.State.ContextManager.RUnlock()
+ return gui.currentStaticContextWithoutLock()
+}
+
+func (gui *Gui) currentStaticContextWithoutLock() types.Context {
stack := gui.State.ContextManager.ContextStack
if len(stack) == 0 {
@@ -324,7 +297,7 @@ func (gui *Gui) currentStaticContext() Context {
for i := range stack {
context := stack[len(stack)-1-i]
- if context.GetKind() != TEMPORARY_POPUP && context.GetKind() != PERSISTENT_POPUP {
+ if context.GetKind() != types.TEMPORARY_POPUP && context.GetKind() != types.PERSISTENT_POPUP {
return context
}
}
@@ -332,40 +305,22 @@ func (gui *Gui) currentStaticContext() Context {
return gui.defaultSideContext()
}
-func (gui *Gui) defaultSideContext() Context {
+func (gui *Gui) defaultSideContext() types.Context {
if gui.State.Modes.Filtering.Active() {
- return gui.State.Contexts.BranchCommits
+ return gui.State.Contexts.LocalCommits
} else {
return gui.State.Contexts.Files
}
}
-// remove the need to do this: always use a mapping
-func (gui *Gui) setInitialViewContexts() {
- // arguably we should only have our ViewContextMap and we should do away with
- // contexts on views, or vice versa
- for viewName, context := range gui.State.ViewContextMap {
- // see if the view exists. If it does, set the context on it
- view, err := gui.g.View(viewName)
- if err != nil {
- continue
- }
-
- view.Context = string(context.GetKey())
- }
-}
-
// getFocusLayout returns a manager function for when view gain and lose focus
func (gui *Gui) getFocusLayout() func(g *gocui.Gui) error {
var previousView *gocui.View
return func(g *gocui.Gui) error {
newView := gui.g.CurrentView()
- if err := gui.onViewFocusChange(); err != nil {
- return err
- }
// for now we don't consider losing focus to a popup panel as actually losing focus
if newView != previousView && !gui.isPopupPanel(newView.Name()) {
- if err := gui.onViewFocusLost(previousView, newView); err != nil {
+ if err := gui.onViewFocusLost(previousView); err != nil {
return err
}
@@ -375,120 +330,30 @@ func (gui *Gui) getFocusLayout() func(g *gocui.Gui) error {
}
}
-func (gui *Gui) onViewFocusChange() error {
- gui.g.Mutexes.ViewsMutex.Lock()
- defer gui.g.Mutexes.ViewsMutex.Unlock()
-
- currentView := gui.g.CurrentView()
- for _, view := range gui.g.Views() {
- view.Highlight = view.Name() != "main" && view.Name() != "extras" && view == currentView
- }
- return nil
-}
-
-func (gui *Gui) onViewFocusLost(oldView *gocui.View, newView *gocui.View) error {
+func (gui *Gui) onViewFocusLost(oldView *gocui.View) error {
if oldView == nil {
return nil
}
- _ = oldView.SetOriginX(0)
+ oldView.Highlight = false
- if oldView == gui.Views.CommitFiles && newView != gui.Views.Main && newView != gui.Views.Secondary && newView != gui.Views.Search {
- gui.resetWindowForView(gui.Views.CommitFiles)
- if err := gui.deactivateContext(gui.State.Contexts.CommitFiles); err != nil {
- return err
- }
- }
+ _ = oldView.SetOriginX(0)
return nil
}
-// changeContext is a helper function for when we want to change a 'main' context
-// which currently just means a context that affects both the main and secondary views
-// other views can have their context changed directly but this function helps
-// keep the main and secondary views in sync
-func (gui *Gui) changeMainViewsContext(contextKey ContextKey) {
- if gui.State.MainContext == contextKey {
- return
- }
-
- switch contextKey {
- case MAIN_NORMAL_CONTEXT_KEY, MAIN_PATCH_BUILDING_CONTEXT_KEY, MAIN_STAGING_CONTEXT_KEY, MAIN_MERGING_CONTEXT_KEY:
- gui.Views.Main.Context = string(contextKey)
- gui.Views.Secondary.Context = string(contextKey)
- default:
- panic(fmt.Sprintf("unknown context for main: %s", contextKey))
- }
-
- gui.State.MainContext = contextKey
-}
-
-func (gui *Gui) viewTabNames(viewName string) []string {
- tabContexts := gui.State.ViewTabContextMap[viewName]
-
- if len(tabContexts) == 0 {
- return nil
- }
-
- result := make([]string, len(tabContexts))
- for i, tabContext := range tabContexts {
- result[i] = tabContext.tab
- }
-
- return result
-}
-
-func (gui *Gui) setViewTabForContext(c Context) {
- // search for the context in our map and if we find it, set the tab for the corresponding view
- tabContexts, ok := gui.State.ViewTabContextMap[c.GetViewName()]
- if !ok {
- return
- }
-
- for tabIndex, tabContext := range tabContexts {
- for _, context := range tabContext.contexts {
- if context.GetKey() == c.GetKey() {
- // get the view, set the tab
- v, err := gui.g.View(c.GetViewName())
- if err != nil {
- gui.Log.Error(err)
- return
- }
- v.TabIndex = tabIndex
- return
- }
- }
- }
-}
-
-type tabContext struct {
- tab string
- contexts []Context
-}
-
-func (gui *Gui) mustContextForContextKey(contextKey ContextKey) Context {
- context, ok := gui.contextForContextKey(contextKey)
-
- if !ok {
- panic(fmt.Sprintf("context not found for key %s", contextKey))
- }
-
- return context
-}
-
-func (gui *Gui) contextForContextKey(contextKey ContextKey) (Context, bool) {
- for _, context := range gui.allContexts() {
- if context.GetKey() == contextKey {
- return context, true
- }
- }
-
- return nil, false
+func (gui *Gui) TransientContexts() []types.Context {
+ return slices.Filter(gui.State.Contexts.Flatten(), func(context types.Context) bool {
+ return context.IsTransient()
+ })
}
func (gui *Gui) rerenderView(view *gocui.View) error {
- contextKey := ContextKey(view.Context)
- context := gui.mustContextForContextKey(contextKey)
+ context, ok := gui.contextForView(view.Name())
+ if !ok {
+ gui.Log.Errorf("no context found for view %s", view.Name())
+ return nil
+ }
return context.HandleRender()
}
@@ -499,13 +364,7 @@ func (gui *Gui) getSideContextSelectedItemId() string {
return ""
}
- item, ok := currentSideContext.GetSelectedItem()
-
- if ok {
- return item.ID()
- }
-
- return ""
+ return currentSideContext.GetSelectedItemId()
}
// currently unused
diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go
new file mode 100644
index 000000000..58b8d25ab
--- /dev/null
+++ b/pkg/gui/context/base_context.go
@@ -0,0 +1,154 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type BaseContext struct {
+ kind types.ContextKind
+ key types.ContextKey
+ view *gocui.View
+ viewTrait types.IViewTrait
+ windowName string
+ onGetOptionsMap func() map[string]string
+
+ keybindingsFns []types.KeybindingsFn
+ mouseKeybindingsFns []types.MouseKeybindingsFn
+ onClickFn func() error
+
+ focusable bool
+ transient bool
+ hasControlledBounds bool
+
+ *ParentContextMgr
+}
+
+var _ types.IBaseContext = &BaseContext{}
+
+type NewBaseContextOpts struct {
+ Kind types.ContextKind
+ Key types.ContextKey
+ View *gocui.View
+ WindowName string
+ Focusable bool
+ Transient bool
+ HasUncontrolledBounds bool // negating for the sake of making false the default
+
+ OnGetOptionsMap func() map[string]string
+}
+
+func NewBaseContext(opts NewBaseContextOpts) *BaseContext {
+ viewTrait := NewViewTrait(opts.View)
+
+ hasControlledBounds := !opts.HasUncontrolledBounds
+
+ return &BaseContext{
+ kind: opts.Kind,
+ key: opts.Key,
+ view: opts.View,
+ windowName: opts.WindowName,
+ onGetOptionsMap: opts.OnGetOptionsMap,
+ focusable: opts.Focusable,
+ transient: opts.Transient,
+ hasControlledBounds: hasControlledBounds,
+ ParentContextMgr: &ParentContextMgr{},
+ viewTrait: viewTrait,
+ }
+}
+
+func (self *BaseContext) GetOptionsMap() map[string]string {
+ if self.onGetOptionsMap != nil {
+ return self.onGetOptionsMap()
+ }
+ return nil
+}
+
+func (self *BaseContext) SetWindowName(windowName string) {
+ self.windowName = windowName
+}
+
+func (self *BaseContext) GetWindowName() string {
+ return self.windowName
+}
+
+func (self *BaseContext) GetViewName() string {
+ // for the sake of the global context which has no view
+ if self.view == nil {
+ return ""
+ }
+
+ return self.view.Name()
+}
+
+func (self *BaseContext) GetView() *gocui.View {
+ return self.view
+}
+
+func (self *BaseContext) GetViewTrait() types.IViewTrait {
+ return self.viewTrait
+}
+
+func (self *BaseContext) GetKind() types.ContextKind {
+ return self.kind
+}
+
+func (self *BaseContext) GetKey() types.ContextKey {
+ return self.key
+}
+
+func (self *BaseContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{}
+ for i := range self.keybindingsFns {
+ // the first binding in the bindings array takes precedence but we want the
+ // last keybindingsFn to take precedence to we add them in reverse
+ bindings = append(bindings, self.keybindingsFns[len(self.keybindingsFns)-1-i](opts)...)
+ }
+
+ return bindings
+}
+
+func (self *BaseContext) AddKeybindingsFn(fn types.KeybindingsFn) {
+ self.keybindingsFns = append(self.keybindingsFns, fn)
+}
+
+func (self *BaseContext) AddMouseKeybindingsFn(fn types.MouseKeybindingsFn) {
+ self.mouseKeybindingsFns = append(self.mouseKeybindingsFns, fn)
+}
+
+func (self *BaseContext) AddOnClickFn(fn func() error) {
+ if fn != nil {
+ self.onClickFn = fn
+ }
+}
+
+func (self *BaseContext) GetOnClick() func() error {
+ return self.onClickFn
+}
+
+func (self *BaseContext) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
+ bindings := []*gocui.ViewMouseBinding{}
+ for i := range self.mouseKeybindingsFns {
+ // the first binding in the bindings array takes precedence but we want the
+ // last keybindingsFn to take precedence to we add them in reverse
+ bindings = append(bindings, self.mouseKeybindingsFns[len(self.mouseKeybindingsFns)-1-i](opts)...)
+ }
+
+ return bindings
+}
+
+func (self *BaseContext) IsFocusable() bool {
+ return self.focusable
+}
+
+func (self *BaseContext) IsTransient() bool {
+ return self.transient
+}
+
+func (self *BaseContext) HasControlledBounds() bool {
+ return self.hasControlledBounds
+}
+
+func (self *BaseContext) Title() string {
+ return ""
+}
diff --git a/pkg/gui/context/basic_view_model.go b/pkg/gui/context/basic_view_model.go
new file mode 100644
index 000000000..a53be4d91
--- /dev/null
+++ b/pkg/gui/context/basic_view_model.go
@@ -0,0 +1,34 @@
+package context
+
+import "github.com/jesseduffield/lazygit/pkg/gui/context/traits"
+
+type BasicViewModel[T any] struct {
+ *traits.ListCursor
+ getModel func() []T
+}
+
+func NewBasicViewModel[T any](getModel func() []T) *BasicViewModel[T] {
+ self := &BasicViewModel[T]{
+ getModel: getModel,
+ }
+
+ self.ListCursor = traits.NewListCursor(self)
+
+ return self
+}
+
+func (self *BasicViewModel[T]) Len() int {
+ return len(self.getModel())
+}
+
+func (self *BasicViewModel[T]) GetSelected() T {
+ if self.Len() == 0 {
+ return Zero[T]()
+ }
+
+ return self.getModel()[self.GetSelectedLineIdx()]
+}
+
+func Zero[T any]() T {
+ return *new(T)
+}
diff --git a/pkg/gui/context/branches_context.go b/pkg/gui/context/branches_context.go
new file mode 100644
index 000000000..a3e404fdb
--- /dev/null
+++ b/pkg/gui/context/branches_context.go
@@ -0,0 +1,65 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type BranchesContext struct {
+ *BasicViewModel[*models.Branch]
+ *ListContextTrait
+}
+
+var _ types.IListContext = (*BranchesContext)(nil)
+
+func NewBranchesContext(
+ getModel func() []*models.Branch,
+ view *gocui.View,
+ getDisplayStrings func(startIdx int, length int) [][]string,
+
+ onFocus func(types.OnFocusOpts) error,
+ onRenderToMain func() error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+
+ c *types.HelperCommon,
+) *BranchesContext {
+ viewModel := NewBasicViewModel(getModel)
+
+ return &BranchesContext{
+ BasicViewModel: viewModel,
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "branches",
+ Key: LOCAL_BRANCHES_CONTEXT_KEY,
+ Kind: types.SIDE_CONTEXT,
+ Focusable: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ OnRenderToMain: onRenderToMain,
+ }),
+ list: viewModel,
+ getDisplayStrings: getDisplayStrings,
+ c: c,
+ },
+ }
+}
+
+func (self *BranchesContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.ID()
+}
+
+func (self *BranchesContext) GetSelectedRef() types.Ref {
+ branch := self.GetSelected()
+ if branch == nil {
+ return nil
+ }
+ return branch
+}
diff --git a/pkg/gui/context/commit_files_context.go b/pkg/gui/context/commit_files_context.go
new file mode 100644
index 000000000..4a28ac4c5
--- /dev/null
+++ b/pkg/gui/context/commit_files_context.go
@@ -0,0 +1,63 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/filetree"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type CommitFilesContext struct {
+ *filetree.CommitFileTreeViewModel
+ *ListContextTrait
+ *DynamicTitleBuilder
+}
+
+var _ types.IListContext = (*CommitFilesContext)(nil)
+
+func NewCommitFilesContext(
+ getModel func() []*models.CommitFile,
+ view *gocui.View,
+ getDisplayStrings func(startIdx int, length int) [][]string,
+
+ onFocus func(types.OnFocusOpts) error,
+ onRenderToMain func() error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+
+ c *types.HelperCommon,
+) *CommitFilesContext {
+ viewModel := filetree.NewCommitFileTreeViewModel(getModel, c.Log, c.UserConfig.Gui.ShowFileTree)
+
+ return &CommitFilesContext{
+ CommitFileTreeViewModel: viewModel,
+ DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.CommitFilesDynamicTitle),
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(
+ NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "commits",
+ Key: COMMIT_FILES_CONTEXT_KEY,
+ Kind: types.SIDE_CONTEXT,
+ Focusable: true,
+ Transient: true,
+ }),
+ ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ OnRenderToMain: onRenderToMain,
+ }),
+ list: viewModel,
+ getDisplayStrings: getDisplayStrings,
+ c: c,
+ },
+ }
+}
+
+func (self *CommitFilesContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.ID()
+}
diff --git a/pkg/gui/context/context.go b/pkg/gui/context/context.go
new file mode 100644
index 000000000..131eecf0a
--- /dev/null
+++ b/pkg/gui/context/context.go
@@ -0,0 +1,152 @@
+package context
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+const (
+ GLOBAL_CONTEXT_KEY types.ContextKey = "global"
+ STATUS_CONTEXT_KEY types.ContextKey = "status"
+ FILES_CONTEXT_KEY types.ContextKey = "files"
+ LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches"
+ REMOTES_CONTEXT_KEY types.ContextKey = "remotes"
+ 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"
+
+ // these shouldn't really be needed for anything but I'm giving them unique keys nonetheless
+ OPTIONS_CONTEXT_KEY types.ContextKey = "options"
+ APP_STATUS_CONTEXT_KEY types.ContextKey = "appStatus"
+ SEARCH_PREFIX_CONTEXT_KEY types.ContextKey = "searchPrefix"
+ INFORMATION_CONTEXT_KEY types.ContextKey = "information"
+ LIMIT_CONTEXT_KEY types.ContextKey = "limit"
+
+ MENU_CONTEXT_KEY types.ContextKey = "menu"
+ CONFIRMATION_CONTEXT_KEY types.ContextKey = "confirmation"
+ SEARCH_CONTEXT_KEY types.ContextKey = "search"
+ COMMIT_MESSAGE_CONTEXT_KEY types.ContextKey = "commitMessage"
+ SUBMODULES_CONTEXT_KEY types.ContextKey = "submodules"
+ SUGGESTIONS_CONTEXT_KEY types.ContextKey = "suggestions"
+ COMMAND_LOG_CONTEXT_KEY types.ContextKey = "cmdLog"
+)
+
+var AllContextKeys = []types.ContextKey{
+ GLOBAL_CONTEXT_KEY,
+ STATUS_CONTEXT_KEY,
+ FILES_CONTEXT_KEY,
+ LOCAL_BRANCHES_CONTEXT_KEY,
+ REMOTES_CONTEXT_KEY,
+ REMOTE_BRANCHES_CONTEXT_KEY,
+ TAGS_CONTEXT_KEY,
+ LOCAL_COMMITS_CONTEXT_KEY,
+ REFLOG_COMMITS_CONTEXT_KEY,
+ SUB_COMMITS_CONTEXT_KEY,
+ COMMIT_FILES_CONTEXT_KEY,
+ 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,
+ CONFIRMATION_CONTEXT_KEY,
+ SEARCH_CONTEXT_KEY,
+ COMMIT_MESSAGE_CONTEXT_KEY,
+ SUBMODULES_CONTEXT_KEY,
+ SUGGESTIONS_CONTEXT_KEY,
+ COMMAND_LOG_CONTEXT_KEY,
+}
+
+type ContextTree struct {
+ Global types.Context
+ Status types.Context
+ Files *WorkingTreeContext
+ Menu *MenuContext
+ Branches *BranchesContext
+ Tags *TagsContext
+ LocalCommits *LocalCommitsContext
+ CommitFiles *CommitFilesContext
+ Remotes *RemotesContext
+ Submodules *SubmodulesContext
+ RemoteBranches *RemoteBranchesContext
+ ReflogCommits *ReflogCommitsContext
+ SubCommits *SubCommitsContext
+ Stash *StashContext
+ Suggestions *SuggestionsContext
+ Normal types.Context
+ NormalSecondary types.Context
+ Staging *PatchExplorerContext
+ StagingSecondary *PatchExplorerContext
+ CustomPatchBuilder *PatchExplorerContext
+ CustomPatchBuilderSecondary types.Context
+ MergeConflicts *MergeConflictsContext
+ Confirmation types.Context
+ CommitMessage types.Context
+ CommandLog types.Context
+
+ // display contexts
+ AppStatus types.Context
+ Options types.Context
+ SearchPrefix types.Context
+ Search types.Context
+ Information types.Context
+ Limit types.Context
+}
+
+// the order of this decides which context is initially at the top of its window
+func (self *ContextTree) Flatten() []types.Context {
+ return []types.Context{
+ self.Global,
+ self.Status,
+ self.Submodules,
+ self.Files,
+ self.SubCommits,
+ self.Remotes,
+ self.RemoteBranches,
+ self.Tags,
+ self.Branches,
+ self.CommitFiles,
+ self.ReflogCommits,
+ self.LocalCommits,
+ self.Stash,
+ self.Menu,
+ self.Confirmation,
+ self.CommitMessage,
+
+ self.MergeConflicts,
+ self.StagingSecondary,
+ self.Staging,
+ self.CustomPatchBuilderSecondary,
+ self.CustomPatchBuilder,
+ self.NormalSecondary,
+ self.Normal,
+
+ self.Suggestions,
+ self.CommandLog,
+ self.AppStatus,
+ self.Options,
+ self.SearchPrefix,
+ self.Search,
+ self.Information,
+ self.Limit,
+ }
+}
+
+type TabView struct {
+ Tab string
+ ViewName string
+}
diff --git a/pkg/gui/context/dynamic_title_builder.go b/pkg/gui/context/dynamic_title_builder.go
new file mode 100644
index 000000000..ee4facad2
--- /dev/null
+++ b/pkg/gui/context/dynamic_title_builder.go
@@ -0,0 +1,23 @@
+package context
+
+import "fmt"
+
+type DynamicTitleBuilder struct {
+ formatStr string // e.g. 'remote branches for %s'
+
+ titleRef string // e.g. 'origin'
+}
+
+func NewDynamicTitleBuilder(formatStr string) *DynamicTitleBuilder {
+ return &DynamicTitleBuilder{
+ formatStr: formatStr,
+ }
+}
+
+func (self *DynamicTitleBuilder) SetTitleRef(titleRef string) {
+ self.titleRef = titleRef
+}
+
+func (self *DynamicTitleBuilder) Title() string {
+ return fmt.Sprintf(self.formatStr, self.titleRef)
+}
diff --git a/pkg/gui/context/list_context_trait.go b/pkg/gui/context/list_context_trait.go
new file mode 100644
index 000000000..df5bbc0af
--- /dev/null
+++ b/pkg/gui/context/list_context_trait.go
@@ -0,0 +1,64 @@
+package context
+
+import (
+ "fmt"
+
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type ListContextTrait struct {
+ types.Context
+
+ c *types.HelperCommon
+ list types.IList
+ getDisplayStrings func(startIdx int, length int) [][]string
+}
+
+func (self *ListContextTrait) GetList() types.IList {
+ return self.list
+}
+
+func (self *ListContextTrait) FocusLine() {
+ // we need a way of knowing whether we've rendered to the view yet.
+ self.GetViewTrait().FocusPoint(self.list.GetSelectedLineIdx())
+ self.setFooter()
+}
+
+func (self *ListContextTrait) setFooter() {
+ self.GetViewTrait().SetFooter(formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len()))
+}
+
+func formatListFooter(selectedLineIdx int, length int) string {
+ return fmt.Sprintf("%d of %d", selectedLineIdx+1, length)
+}
+
+func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) error {
+ self.FocusLine()
+
+ self.GetViewTrait().SetHighlight(self.list.Len() > 0)
+
+ return self.Context.HandleFocus(opts)
+}
+
+func (self *ListContextTrait) HandleFocusLost(opts types.OnFocusLostOpts) error {
+ self.GetViewTrait().SetOriginX(0)
+
+ return self.Context.HandleFocusLost(opts)
+}
+
+// OnFocus assumes that the content of the context has already been rendered to the view. OnRender is the function which actually renders the content to the view
+func (self *ListContextTrait) HandleRender() error {
+ self.list.RefreshSelectedIdx()
+ content := utils.RenderDisplayStrings(self.getDisplayStrings(0, self.list.Len()))
+ self.GetViewTrait().SetContent(content)
+ self.c.Render()
+ self.setFooter()
+
+ return nil
+}
+
+func (self *ListContextTrait) OnSearchSelect(selectedLineIdx int) error {
+ self.GetList().SetSelectedLineIdx(selectedLineIdx)
+ return self.HandleFocus(types.OnFocusOpts{})
+}
diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go
new file mode 100644
index 000000000..462a85d59
--- /dev/null
+++ b/pkg/gui/context/local_commits_context.go
@@ -0,0 +1,112 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type LocalCommitsContext struct {
+ *LocalCommitsViewModel
+ *ViewportListContextTrait
+}
+
+var _ types.IListContext = (*LocalCommitsContext)(nil)
+
+func NewLocalCommitsContext(
+ getModel func() []*models.Commit,
+ view *gocui.View,
+ getDisplayStrings func(startIdx int, length int) [][]string,
+
+ onFocus func(types.OnFocusOpts) error,
+ onRenderToMain func() error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+
+ c *types.HelperCommon,
+) *LocalCommitsContext {
+ viewModel := NewLocalCommitsViewModel(getModel, c)
+
+ return &LocalCommitsContext{
+ LocalCommitsViewModel: viewModel,
+ ViewportListContextTrait: &ViewportListContextTrait{
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "commits",
+ Key: LOCAL_COMMITS_CONTEXT_KEY,
+ Kind: types.SIDE_CONTEXT,
+ Focusable: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ OnRenderToMain: onRenderToMain,
+ }),
+ list: viewModel,
+ getDisplayStrings: getDisplayStrings,
+ c: c,
+ },
+ },
+ }
+}
+
+func (self *LocalCommitsContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.ID()
+}
+
+type LocalCommitsViewModel struct {
+ *BasicViewModel[*models.Commit]
+
+ // If this is true we limit the amount of commits we load, for the sake of keeping things fast.
+ // If the user attempts to scroll past the end of the list, we will load more commits.
+ limitCommits bool
+
+ // If this is true we'll use git log --all when fetching the commits.
+ showWholeGitGraph bool
+}
+
+func NewLocalCommitsViewModel(getModel func() []*models.Commit, c *types.HelperCommon) *LocalCommitsViewModel {
+ self := &LocalCommitsViewModel{
+ BasicViewModel: NewBasicViewModel(getModel),
+ limitCommits: true,
+ showWholeGitGraph: c.UserConfig.Git.Log.ShowWholeGraph,
+ }
+
+ return self
+}
+
+func (self *LocalCommitsContext) CanRebase() bool {
+ return true
+}
+
+func (self *LocalCommitsContext) GetSelectedRef() types.Ref {
+ commit := self.GetSelected()
+ if commit == nil {
+ return nil
+ }
+ return commit
+}
+
+func (self *LocalCommitsViewModel) SetLimitCommits(value bool) {
+ self.limitCommits = value
+}
+
+func (self *LocalCommitsViewModel) GetLimitCommits() bool {
+ return self.limitCommits
+}
+
+func (self *LocalCommitsViewModel) SetShowWholeGitGraph(value bool) {
+ self.showWholeGitGraph = value
+}
+
+func (self *LocalCommitsViewModel) GetShowWholeGitGraph() bool {
+ return self.showWholeGitGraph
+}
+
+func (self *LocalCommitsViewModel) GetCommits() []*models.Commit {
+ return self.getModel()
+}
diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go
new file mode 100644
index 000000000..780c35660
--- /dev/null
+++ b/pkg/gui/context/menu_context.go
@@ -0,0 +1,128 @@
+package context
+
+import (
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type MenuContext struct {
+ *MenuViewModel
+ *ListContextTrait
+}
+
+var _ types.IListContext = (*MenuContext)(nil)
+
+func NewMenuContext(
+ view *gocui.View,
+
+ c *types.HelperCommon,
+ getOptionsMap func() map[string]string,
+ renderToDescriptionView func(string),
+) *MenuContext {
+ viewModel := NewMenuViewModel()
+
+ onFocus := func(types.OnFocusOpts) error {
+ selectedMenuItem := viewModel.GetSelected()
+ renderToDescriptionView(selectedMenuItem.Tooltip)
+ return nil
+ }
+
+ return &MenuContext{
+ MenuViewModel: viewModel,
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "menu",
+ Key: "menu",
+ Kind: types.TEMPORARY_POPUP,
+ OnGetOptionsMap: getOptionsMap,
+ Focusable: true,
+ HasUncontrolledBounds: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ }),
+ getDisplayStrings: viewModel.GetDisplayStrings,
+ list: viewModel,
+ c: c,
+ },
+ }
+}
+
+// TODO: remove this thing.
+func (self *MenuContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.Label
+}
+
+type MenuViewModel struct {
+ menuItems []*types.MenuItem
+ *BasicViewModel[*types.MenuItem]
+}
+
+func NewMenuViewModel() *MenuViewModel {
+ self := &MenuViewModel{
+ menuItems: nil,
+ }
+
+ self.BasicViewModel = NewBasicViewModel(func() []*types.MenuItem { return self.menuItems })
+
+ return self
+}
+
+func (self *MenuViewModel) SetMenuItems(items []*types.MenuItem) {
+ self.menuItems = items
+}
+
+// TODO: move into presentation package
+func (self *MenuViewModel) GetDisplayStrings(_startIdx int, _length int) [][]string {
+ showKeys := slices.Some(self.menuItems, func(item *types.MenuItem) bool {
+ return item.Key != nil
+ })
+
+ return slices.Map(self.menuItems, func(item *types.MenuItem) []string {
+ displayStrings := item.LabelColumns
+ if showKeys {
+ displayStrings = slices.Prepend(displayStrings, style.FgCyan.Sprint(keybindings.LabelFromKey(item.Key)))
+ }
+ return displayStrings
+ })
+}
+
+func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ basicBindings := self.ListContextTrait.GetKeybindings(opts)
+ menuItemsWithKeys := slices.Filter(self.menuItems, func(item *types.MenuItem) bool {
+ return item.Key != nil
+ })
+
+ menuItemBindings := slices.Map(menuItemsWithKeys, func(item *types.MenuItem) *types.Binding {
+ return &types.Binding{
+ Key: item.Key,
+ Handler: func() error { return self.OnMenuPress(item) },
+ }
+ })
+
+ // appending because that means the menu item bindings have lower precedence.
+ // So if a basic binding is to escape from the menu, we want that to still be
+ // what happens when you press escape. This matters when we're showing the menu
+ // for all keybindings of say the files context.
+ return append(basicBindings, menuItemBindings...)
+}
+
+func (self *MenuContext) OnMenuPress(selectedItem *types.MenuItem) error {
+ if err := self.c.PopContext(); err != nil {
+ return err
+ }
+
+ if err := selectedItem.OnPress(); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/pkg/gui/context/merge_conflicts_context.go b/pkg/gui/context/merge_conflicts_context.go
new file mode 100644
index 000000000..4d02d452e
--- /dev/null
+++ b/pkg/gui/context/merge_conflicts_context.go
@@ -0,0 +1,117 @@
+package context
+
+import (
+ "math"
+
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/sasha-s/go-deadlock"
+)
+
+type MergeConflictsContext struct {
+ types.Context
+ viewModel *ConflictsViewModel
+ c *types.HelperCommon
+ mutex *deadlock.Mutex
+}
+
+type ConflictsViewModel struct {
+ state *mergeconflicts.State
+
+ // userVerticalScrolling tells us if the user has started scrolling through the file themselves
+ // in which case we won't auto-scroll to a conflict.
+ userVerticalScrolling bool
+}
+
+func NewMergeConflictsContext(
+ view *gocui.View,
+
+ opts ContextCallbackOpts,
+
+ c *types.HelperCommon,
+ getOptionsMap func() map[string]string,
+) *MergeConflictsContext {
+ viewModel := &ConflictsViewModel{
+ state: mergeconflicts.NewState(),
+ userVerticalScrolling: false,
+ }
+
+ return &MergeConflictsContext{
+ viewModel: viewModel,
+ mutex: &deadlock.Mutex{},
+ Context: NewSimpleContext(
+ NewBaseContext(NewBaseContextOpts{
+ Kind: types.MAIN_CONTEXT,
+ View: view,
+ WindowName: "main",
+ Key: MERGE_CONFLICTS_CONTEXT_KEY,
+ OnGetOptionsMap: getOptionsMap,
+ Focusable: true,
+ }),
+ opts,
+ ),
+ c: c,
+ }
+}
+
+func (self *MergeConflictsContext) GetState() *mergeconflicts.State {
+ return self.viewModel.state
+}
+
+func (self *MergeConflictsContext) SetState(state *mergeconflicts.State) {
+ self.viewModel.state = state
+}
+
+func (self *MergeConflictsContext) GetMutex() *deadlock.Mutex {
+ return self.mutex
+}
+
+func (self *MergeConflictsContext) SetUserScrolling(isScrolling bool) {
+ self.viewModel.userVerticalScrolling = isScrolling
+}
+
+func (self *MergeConflictsContext) IsUserScrolling() bool {
+ return self.viewModel.userVerticalScrolling
+}
+
+func (self *MergeConflictsContext) RenderAndFocus(isFocused bool) error {
+ self.setContent(isFocused)
+ self.focusSelection()
+
+ self.c.Render()
+
+ return nil
+}
+
+func (self *MergeConflictsContext) Render(isFocused bool) error {
+ self.setContent(isFocused)
+
+ self.c.Render()
+
+ return nil
+}
+
+func (self *MergeConflictsContext) GetContentToRender(isFocused bool) string {
+ if self.GetState() == nil {
+ return ""
+ }
+
+ return mergeconflicts.ColoredConflictFile(self.GetState(), isFocused)
+}
+
+func (self *MergeConflictsContext) setContent(isFocused bool) {
+ self.GetView().SetContent(self.GetContentToRender(isFocused))
+}
+
+func (self *MergeConflictsContext) focusSelection() {
+ if !self.IsUserScrolling() {
+ _ = self.GetView().SetOrigin(self.GetView().OriginX(), self.GetOriginY())
+ }
+}
+
+func (self *MergeConflictsContext) GetOriginY() int {
+ view := self.GetView()
+ conflictMiddle := self.GetState().GetConflictMiddle()
+ return int(math.Max(0, float64(conflictMiddle-(view.Height()/2))))
+}
diff --git a/pkg/gui/context/parent_context_mgr.go b/pkg/gui/context/parent_context_mgr.go
new file mode 100644
index 000000000..50747a3a0
--- /dev/null
+++ b/pkg/gui/context/parent_context_mgr.go
@@ -0,0 +1,20 @@
+package context
+
+import "github.com/jesseduffield/lazygit/pkg/gui/types"
+
+type ParentContextMgr struct {
+ ParentContext types.Context
+ // we can't know on the calling end whether a Context is actually a nil value without reflection, so we're storing this flag here to tell us. There has got to be a better way around this
+ hasParent bool
+}
+
+var _ types.ParentContexter = (*ParentContextMgr)(nil)
+
+func (self *ParentContextMgr) SetParentContext(context types.Context) {
+ self.ParentContext = context
+ self.hasParent = true
+}
+
+func (self *ParentContextMgr) GetParentContext() (types.Context, bool) {
+ return self.ParentContext, self.hasParent
+}
diff --git a/pkg/gui/context/patch_explorer_context.go b/pkg/gui/context/patch_explorer_context.go
new file mode 100644
index 000000000..3e13b8539
--- /dev/null
+++ b/pkg/gui/context/patch_explorer_context.go
@@ -0,0 +1,128 @@
+package context
+
+import (
+ "github.com/jesseduffield/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
+
+ state *patch_exploring.State
+ viewTrait *ViewTrait
+ getIncludedLineIndices func() []int
+ c *types.HelperCommon
+ mutex *deadlock.Mutex
+}
+
+var _ types.IPatchExplorerContext = (*PatchExplorerContext)(nil)
+
+func NewPatchExplorerContext(
+ view *gocui.View,
+ windowName string,
+ key types.ContextKey,
+
+ onFocus func(types.OnFocusOpts) error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+ getIncludedLineIndices func() []int,
+
+ c *types.HelperCommon,
+) *PatchExplorerContext {
+ return &PatchExplorerContext{
+ state: nil,
+ viewTrait: NewViewTrait(view),
+ c: c,
+ mutex: &deadlock.Mutex{},
+ getIncludedLineIndices: getIncludedLineIndices,
+ SimpleContext: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: windowName,
+ Key: key,
+ Kind: types.MAIN_CONTEXT,
+ Focusable: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ }),
+ }
+}
+
+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(isFocused bool) error {
+ self.setContent(isFocused)
+
+ self.focusSelection()
+ self.c.Render()
+
+ return nil
+}
+
+func (self *PatchExplorerContext) Render(isFocused bool) error {
+ self.setContent(isFocused)
+
+ self.c.Render()
+
+ return nil
+}
+
+func (self *PatchExplorerContext) Focus() error {
+ self.focusSelection()
+ self.c.Render()
+
+ return nil
+}
+
+func (self *PatchExplorerContext) setContent(isFocused bool) {
+ self.GetView().SetContent(self.GetContentToRender(isFocused))
+}
+
+func (self *PatchExplorerContext) focusSelection() {
+ view := self.GetView()
+ state := self.GetState()
+ _, viewHeight := view.Size()
+ bufferHeight := viewHeight - 1
+ _, origin := view.Origin()
+
+ selectedLineIdx := state.GetSelectedLineIdx()
+
+ newOrigin := state.CalculateOrigin(origin, bufferHeight)
+
+ _ = view.SetOriginY(newOrigin)
+ _ = view.SetCursor(0, selectedLineIdx-newOrigin)
+}
+
+func (self *PatchExplorerContext) GetContentToRender(isFocused bool) string {
+ if self.GetState() == nil {
+ return ""
+ }
+
+ return self.GetState().RenderForLineIndices(isFocused, self.GetIncludedLineIndices())
+}
+
+func (self *PatchExplorerContext) NavigateTo(isFocused bool, selectedLineIdx int) error {
+ self.GetState().SetLineSelectMode()
+ self.GetState().SelectLine(selectedLineIdx)
+
+ return self.RenderAndFocus(isFocused)
+}
+
+func (self *PatchExplorerContext) GetMutex() *deadlock.Mutex {
+ return self.mutex
+}
diff --git a/pkg/gui/context/reflog_commits_context.go b/pkg/gui/context/reflog_commits_context.go
new file mode 100644
index 000000000..e197a50bd
--- /dev/null
+++ b/pkg/gui/context/reflog_commits_context.go
@@ -0,0 +1,73 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type ReflogCommitsContext struct {
+ *BasicViewModel[*models.Commit]
+ *ListContextTrait
+}
+
+var _ types.IListContext = (*ReflogCommitsContext)(nil)
+
+func NewReflogCommitsContext(
+ getModel func() []*models.Commit,
+ view *gocui.View,
+ getDisplayStrings func(startIdx int, length int) [][]string,
+
+ onFocus func(types.OnFocusOpts) error,
+ onRenderToMain func() error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+
+ c *types.HelperCommon,
+) *ReflogCommitsContext {
+ viewModel := NewBasicViewModel(getModel)
+
+ return &ReflogCommitsContext{
+ BasicViewModel: viewModel,
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "commits",
+ Key: REFLOG_COMMITS_CONTEXT_KEY,
+ Kind: types.SIDE_CONTEXT,
+ Focusable: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ OnRenderToMain: onRenderToMain,
+ }),
+ list: viewModel,
+ getDisplayStrings: getDisplayStrings,
+ c: c,
+ },
+ }
+}
+
+func (self *ReflogCommitsContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.ID()
+}
+
+func (self *ReflogCommitsContext) CanRebase() bool {
+ return false
+}
+
+func (self *ReflogCommitsContext) GetSelectedRef() types.Ref {
+ commit := self.GetSelected()
+ if commit == nil {
+ return nil
+ }
+ return commit
+}
+
+func (self *ReflogCommitsContext) GetCommits() []*models.Commit {
+ return self.getModel()
+}
diff --git a/pkg/gui/context/remote_branches_context.go b/pkg/gui/context/remote_branches_context.go
new file mode 100644
index 000000000..44dc06848
--- /dev/null
+++ b/pkg/gui/context/remote_branches_context.go
@@ -0,0 +1,68 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type RemoteBranchesContext struct {
+ *BasicViewModel[*models.RemoteBranch]
+ *ListContextTrait
+ *DynamicTitleBuilder
+}
+
+var _ types.IListContext = (*RemoteBranchesContext)(nil)
+
+func NewRemoteBranchesContext(
+ getModel func() []*models.RemoteBranch,
+ view *gocui.View,
+ getDisplayStrings func(startIdx int, length int) [][]string,
+
+ onFocus func(types.OnFocusOpts) error,
+ onRenderToMain func() error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+
+ c *types.HelperCommon,
+) *RemoteBranchesContext {
+ viewModel := NewBasicViewModel(getModel)
+
+ return &RemoteBranchesContext{
+ BasicViewModel: viewModel,
+ DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.RemoteBranchesDynamicTitle),
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "branches",
+ Key: REMOTE_BRANCHES_CONTEXT_KEY,
+ Kind: types.SIDE_CONTEXT,
+ Focusable: true,
+ Transient: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ OnRenderToMain: onRenderToMain,
+ }),
+ list: viewModel,
+ getDisplayStrings: getDisplayStrings,
+ c: c,
+ },
+ }
+}
+
+func (self *RemoteBranchesContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.ID()
+}
+
+func (self *RemoteBranchesContext) GetSelectedRef() types.Ref {
+ remoteBranch := self.GetSelected()
+ if remoteBranch == nil {
+ return nil
+ }
+ return remoteBranch
+}
diff --git a/pkg/gui/context/remotes_context.go b/pkg/gui/context/remotes_context.go
new file mode 100644
index 000000000..0f11908dd
--- /dev/null
+++ b/pkg/gui/context/remotes_context.go
@@ -0,0 +1,57 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type RemotesContext struct {
+ *BasicViewModel[*models.Remote]
+ *ListContextTrait
+}
+
+var _ types.IListContext = (*RemotesContext)(nil)
+
+func NewRemotesContext(
+ getModel func() []*models.Remote,
+ view *gocui.View,
+ getDisplayStrings func(startIdx int, length int) [][]string,
+
+ onFocus func(types.OnFocusOpts) error,
+ onRenderToMain func() error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+
+ c *types.HelperCommon,
+) *RemotesContext {
+ viewModel := NewBasicViewModel(getModel)
+
+ return &RemotesContext{
+ BasicViewModel: viewModel,
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "branches",
+ Key: REMOTES_CONTEXT_KEY,
+ Kind: types.SIDE_CONTEXT,
+ Focusable: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ OnRenderToMain: onRenderToMain,
+ }),
+ list: viewModel,
+ getDisplayStrings: getDisplayStrings,
+ c: c,
+ },
+ }
+}
+
+func (self *RemotesContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.ID()
+}
diff --git a/pkg/gui/context/simple_context.go b/pkg/gui/context/simple_context.go
new file mode 100644
index 000000000..835576264
--- /dev/null
+++ b/pkg/gui/context/simple_context.go
@@ -0,0 +1,88 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type SimpleContext struct {
+ OnFocus func(opts types.OnFocusOpts) error
+ OnFocusLost func(opts types.OnFocusLostOpts) error
+ OnRender func() error
+ // this is for pushing some content to the main view
+ OnRenderToMain func() error
+
+ *BaseContext
+}
+
+type ContextCallbackOpts struct {
+ OnFocus func(opts types.OnFocusOpts) error
+ OnFocusLost func(opts types.OnFocusLostOpts) error
+ OnRender func() error
+ OnRenderToMain func() error
+}
+
+func NewSimpleContext(baseContext *BaseContext, opts ContextCallbackOpts) *SimpleContext {
+ return &SimpleContext{
+ OnFocus: opts.OnFocus,
+ OnFocusLost: opts.OnFocusLost,
+ OnRender: opts.OnRender,
+ OnRenderToMain: opts.OnRenderToMain,
+ BaseContext: baseContext,
+ }
+}
+
+var _ types.Context = &SimpleContext{}
+
+// A Display context only renders a view. It has no keybindings and is not focusable.
+func NewDisplayContext(key types.ContextKey, view *gocui.View, windowName string) types.Context {
+ return NewSimpleContext(
+ NewBaseContext(NewBaseContextOpts{
+ Kind: types.DISPLAY_CONTEXT,
+ Key: key,
+ View: view,
+ WindowName: windowName,
+ Focusable: false,
+ Transient: false,
+ }),
+ ContextCallbackOpts{},
+ )
+}
+
+func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) error {
+ if self.OnFocus != nil {
+ if err := self.OnFocus(opts); err != nil {
+ return err
+ }
+ }
+
+ if self.OnRenderToMain != nil {
+ if err := self.OnRenderToMain(); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (self *SimpleContext) HandleFocusLost(opts types.OnFocusLostOpts) error {
+ if self.OnFocusLost != nil {
+ return self.OnFocusLost(opts)
+ }
+ return nil
+}
+
+func (self *SimpleContext) HandleRender() error {
+ if self.OnRender != nil {
+ return self.OnRender()
+ }
+ return nil
+}
+
+func (self *SimpleContext) HandleRenderToMain() error {
+ if self.OnRenderToMain != nil {
+ return self.OnRenderToMain()
+ }
+
+ return nil
+}
diff --git a/pkg/gui/context/stash_context.go b/pkg/gui/context/stash_context.go
new file mode 100644
index 000000000..19eb5030a
--- /dev/null
+++ b/pkg/gui/context/stash_context.go
@@ -0,0 +1,69 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type StashContext struct {
+ *BasicViewModel[*models.StashEntry]
+ *ListContextTrait
+}
+
+var _ types.IListContext = (*StashContext)(nil)
+
+func NewStashContext(
+ getModel func() []*models.StashEntry,
+ view *gocui.View,
+ getDisplayStrings func(startIdx int, length int) [][]string,
+
+ onFocus func(types.OnFocusOpts) error,
+ onRenderToMain func() error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+
+ c *types.HelperCommon,
+) *StashContext {
+ viewModel := NewBasicViewModel(getModel)
+
+ return &StashContext{
+ BasicViewModel: viewModel,
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "stash",
+ Key: STASH_CONTEXT_KEY,
+ Kind: types.SIDE_CONTEXT,
+ Focusable: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ OnRenderToMain: onRenderToMain,
+ }),
+ list: viewModel,
+ getDisplayStrings: getDisplayStrings,
+ c: c,
+ },
+ }
+}
+
+func (self *StashContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.ID()
+}
+
+func (self *StashContext) CanRebase() bool {
+ return false
+}
+
+func (self *StashContext) GetSelectedRef() types.Ref {
+ stash := self.GetSelected()
+ if stash == nil {
+ return nil
+ }
+ return stash
+}
diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go
new file mode 100644
index 000000000..4070f0390
--- /dev/null
+++ b/pkg/gui/context/sub_commits_context.go
@@ -0,0 +1,98 @@
+package context
+
+import (
+ "fmt"
+
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type SubCommitsContext struct {
+ *SubCommitsViewModel
+ *ViewportListContextTrait
+ *DynamicTitleBuilder
+}
+
+var _ types.IListContext = (*SubCommitsContext)(nil)
+
+func NewSubCommitsContext(
+ getModel func() []*models.Commit,
+ view *gocui.View,
+ getDisplayStrings func(startIdx int, length int) [][]string,
+
+ onFocus func(types.OnFocusOpts) error,
+ onRenderToMain func() error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+
+ c *types.HelperCommon,
+) *SubCommitsContext {
+ viewModel := &SubCommitsViewModel{
+ BasicViewModel: NewBasicViewModel(getModel),
+ refName: "",
+ }
+
+ return &SubCommitsContext{
+ SubCommitsViewModel: viewModel,
+ DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.SubCommitsDynamicTitle),
+ ViewportListContextTrait: &ViewportListContextTrait{
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "branches",
+ Key: SUB_COMMITS_CONTEXT_KEY,
+ Kind: types.SIDE_CONTEXT,
+ Focusable: true,
+ Transient: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ OnRenderToMain: onRenderToMain,
+ }),
+ list: viewModel,
+ getDisplayStrings: getDisplayStrings,
+ c: c,
+ },
+ },
+ }
+}
+
+type SubCommitsViewModel struct {
+ // name of the ref that the sub-commits are shown for
+ refName string
+ *BasicViewModel[*models.Commit]
+}
+
+func (self *SubCommitsViewModel) SetRefName(refName string) {
+ self.refName = refName
+}
+
+func (self *SubCommitsContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.ID()
+}
+
+func (self *SubCommitsContext) CanRebase() bool {
+ return false
+}
+
+func (self *SubCommitsContext) GetSelectedRef() types.Ref {
+ commit := self.GetSelected()
+ if commit == nil {
+ return nil
+ }
+ return commit
+}
+
+func (self *SubCommitsContext) GetCommits() []*models.Commit {
+ return self.getModel()
+}
+
+func (self *SubCommitsContext) Title() string {
+ return fmt.Sprintf(self.c.Tr.SubCommitsDynamicTitle, utils.TruncateWithEllipsis(self.refName, 50))
+}
diff --git a/pkg/gui/context/submodules_context.go b/pkg/gui/context/submodules_context.go
new file mode 100644
index 000000000..5491cd137
--- /dev/null
+++ b/pkg/gui/context/submodules_context.go
@@ -0,0 +1,57 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type SubmodulesContext struct {
+ *BasicViewModel[*models.SubmoduleConfig]
+ *ListContextTrait
+}
+
+var _ types.IListContext = (*SubmodulesContext)(nil)
+
+func NewSubmodulesContext(
+ getModel func() []*models.SubmoduleConfig,
+ view *gocui.View,
+ getDisplayStrings func(startIdx int, length int) [][]string,
+
+ onFocus func(types.OnFocusOpts) error,
+ onRenderToMain func() error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+
+ c *types.HelperCommon,
+) *SubmodulesContext {
+ viewModel := NewBasicViewModel(getModel)
+
+ return &SubmodulesContext{
+ BasicViewModel: viewModel,
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "files",
+ Key: SUBMODULES_CONTEXT_KEY,
+ Kind: types.SIDE_CONTEXT,
+ Focusable: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ OnRenderToMain: onRenderToMain,
+ }),
+ list: viewModel,
+ getDisplayStrings: getDisplayStrings,
+ c: c,
+ },
+ }
+}
+
+func (self *SubmodulesContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.ID()
+}
diff --git a/pkg/gui/context/suggestions_context.go b/pkg/gui/context/suggestions_context.go
new file mode 100644
index 000000000..4be86244a
--- /dev/null
+++ b/pkg/gui/context/suggestions_context.go
@@ -0,0 +1,57 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type SuggestionsContext struct {
+ *BasicViewModel[*types.Suggestion]
+ *ListContextTrait
+}
+
+var _ types.IListContext = (*SuggestionsContext)(nil)
+
+func NewSuggestionsContext(
+ getModel func() []*types.Suggestion,
+ view *gocui.View,
+ getDisplayStrings func(startIdx int, length int) [][]string,
+
+ onFocus func(types.OnFocusOpts) error,
+ onRenderToMain func() error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+
+ c *types.HelperCommon,
+) *SuggestionsContext {
+ viewModel := NewBasicViewModel(getModel)
+
+ return &SuggestionsContext{
+ BasicViewModel: viewModel,
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "suggestions",
+ Key: SUGGESTIONS_CONTEXT_KEY,
+ Kind: types.PERSISTENT_POPUP,
+ Focusable: true,
+ HasUncontrolledBounds: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ OnRenderToMain: onRenderToMain,
+ }),
+ list: viewModel,
+ getDisplayStrings: getDisplayStrings,
+ c: c,
+ },
+ }
+}
+
+func (self *SuggestionsContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.Value
+}
diff --git a/pkg/gui/context/tags_context.go b/pkg/gui/context/tags_context.go
new file mode 100644
index 000000000..6cb14e371
--- /dev/null
+++ b/pkg/gui/context/tags_context.go
@@ -0,0 +1,65 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type TagsContext struct {
+ *BasicViewModel[*models.Tag]
+ *ListContextTrait
+}
+
+var _ types.IListContext = (*TagsContext)(nil)
+
+func NewTagsContext(
+ getModel func() []*models.Tag,
+ view *gocui.View,
+ getDisplayStrings func(startIdx int, length int) [][]string,
+
+ onFocus func(types.OnFocusOpts) error,
+ onRenderToMain func() error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+
+ c *types.HelperCommon,
+) *TagsContext {
+ viewModel := NewBasicViewModel(getModel)
+
+ return &TagsContext{
+ BasicViewModel: viewModel,
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "branches",
+ Key: TAGS_CONTEXT_KEY,
+ Kind: types.SIDE_CONTEXT,
+ Focusable: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ OnRenderToMain: onRenderToMain,
+ }),
+ list: viewModel,
+ getDisplayStrings: getDisplayStrings,
+ c: c,
+ },
+ }
+}
+
+func (self *TagsContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.ID()
+}
+
+func (self *TagsContext) GetSelectedRef() types.Ref {
+ tag := self.GetSelected()
+ if tag == nil {
+ return nil
+ }
+ return tag
+}
diff --git a/pkg/gui/context/traits/list_cursor.go b/pkg/gui/context/traits/list_cursor.go
new file mode 100644
index 000000000..9e86d5139
--- /dev/null
+++ b/pkg/gui/context/traits/list_cursor.go
@@ -0,0 +1,48 @@
+package traits
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type HasLength interface {
+ Len() int
+}
+
+type ListCursor struct {
+ selectedIdx int
+ list HasLength
+}
+
+func NewListCursor(list HasLength) *ListCursor {
+ return &ListCursor{selectedIdx: 0, list: list}
+}
+
+var _ types.IListCursor = (*ListCursor)(nil)
+
+func (self *ListCursor) GetSelectedLineIdx() int {
+ return self.selectedIdx
+}
+
+func (self *ListCursor) SetSelectedLineIdx(value int) {
+ clampedValue := -1
+ if self.list.Len() > 0 {
+ clampedValue = utils.Clamp(value, 0, self.list.Len()-1)
+ }
+
+ self.selectedIdx = clampedValue
+}
+
+// moves the cursor up or down by the given amount
+func (self *ListCursor) MoveSelectedLine(delta int) {
+ self.SetSelectedLineIdx(self.selectedIdx + delta)
+}
+
+// to be called when the model might have shrunk so that our selection is not not out of bounds
+func (self *ListCursor) RefreshSelectedIdx() {
+ self.SetSelectedLineIdx(self.selectedIdx)
+}
+
+func (self *ListCursor) Len() int {
+ return self.list.Len()
+}
diff --git a/pkg/gui/context/view_trait.go b/pkg/gui/context/view_trait.go
new file mode 100644
index 000000000..bf8a49e43
--- /dev/null
+++ b/pkg/gui/context/view_trait.go
@@ -0,0 +1,86 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+const HORIZONTAL_SCROLL_FACTOR = 3
+
+type ViewTrait struct {
+ view *gocui.View
+}
+
+var _ types.IViewTrait = &ViewTrait{}
+
+func NewViewTrait(view *gocui.View) *ViewTrait {
+ return &ViewTrait{view: view}
+}
+
+func (self *ViewTrait) FocusPoint(yIdx int) {
+ self.view.FocusPoint(self.view.OriginX(), yIdx)
+}
+
+func (self *ViewTrait) SetViewPortContent(content string) {
+ _, y := self.view.Origin()
+ self.view.OverwriteLines(y, content)
+}
+
+func (self *ViewTrait) SetContent(content string) {
+ self.view.SetContent(content)
+}
+
+func (self *ViewTrait) SetHighlight(highlight bool) {
+ self.view.Highlight = highlight
+}
+
+func (self *ViewTrait) SetFooter(value string) {
+ self.view.Footer = value
+}
+
+func (self *ViewTrait) SetOriginX(value int) {
+ _ = self.view.SetOriginX(value)
+}
+
+// tells us the start of line indexes shown in the view currently as well as the capacity of lines shown in the viewport.
+func (self *ViewTrait) ViewPortYBounds() (int, int) {
+ _, start := self.view.Origin()
+ length := self.view.InnerHeight() + 1
+ return start, length
+}
+
+func (self *ViewTrait) ScrollLeft() {
+ self.view.ScrollLeft(self.horizontalScrollAmount())
+}
+
+func (self *ViewTrait) ScrollRight() {
+ self.view.ScrollRight(self.horizontalScrollAmount())
+}
+
+func (self *ViewTrait) horizontalScrollAmount() int {
+ return self.view.InnerWidth() / HORIZONTAL_SCROLL_FACTOR
+}
+
+func (self *ViewTrait) ScrollUp(value int) {
+ self.view.ScrollUp(value)
+}
+
+func (self *ViewTrait) ScrollDown(value int) {
+ self.view.ScrollDown(value)
+}
+
+// this returns the amount we'll scroll if we want to scroll by a page.
+func (self *ViewTrait) PageDelta() int {
+ _, height := self.view.Size()
+
+ delta := height - 1
+ if delta == 0 {
+ return 1
+ }
+
+ return delta
+}
+
+func (self *ViewTrait) SelectedLineIdx() int {
+ return self.view.SelectedLineIdx()
+}
diff --git a/pkg/gui/context/viewport_list_context_trait.go b/pkg/gui/context/viewport_list_context_trait.go
new file mode 100644
index 000000000..b89dea832
--- /dev/null
+++ b/pkg/gui/context/viewport_list_context_trait.go
@@ -0,0 +1,22 @@
+package context
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+// This embeds a list context trait and adds logic to re-render the viewport
+// whenever a line is focused. We use this in the commits panel because different
+// sections of the log graph need to be highlighted depending on the currently selected line
+
+type ViewportListContextTrait struct {
+ *ListContextTrait
+}
+
+func (self *ViewportListContextTrait) FocusLine() {
+ self.ListContextTrait.FocusLine()
+
+ startIdx, length := self.GetViewTrait().ViewPortYBounds()
+ displayStrings := self.ListContextTrait.getDisplayStrings(startIdx, length)
+ content := utils.RenderDisplayStrings(displayStrings)
+ self.GetViewTrait().SetViewPortContent(content)
+}
diff --git a/pkg/gui/context/working_tree_context.go b/pkg/gui/context/working_tree_context.go
new file mode 100644
index 000000000..f8da6a068
--- /dev/null
+++ b/pkg/gui/context/working_tree_context.go
@@ -0,0 +1,58 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/filetree"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type WorkingTreeContext struct {
+ *filetree.FileTreeViewModel
+ *ListContextTrait
+}
+
+var _ types.IListContext = (*WorkingTreeContext)(nil)
+
+func NewWorkingTreeContext(
+ getModel func() []*models.File,
+ view *gocui.View,
+ getDisplayStrings func(startIdx int, length int) [][]string,
+
+ onFocus func(types.OnFocusOpts) error,
+ onRenderToMain func() error,
+ onFocusLost func(opts types.OnFocusLostOpts) error,
+
+ c *types.HelperCommon,
+) *WorkingTreeContext {
+ viewModel := filetree.NewFileTreeViewModel(getModel, c.Log, c.UserConfig.Gui.ShowFileTree)
+
+ return &WorkingTreeContext{
+ FileTreeViewModel: viewModel,
+ ListContextTrait: &ListContextTrait{
+ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
+ View: view,
+ WindowName: "files",
+ Key: FILES_CONTEXT_KEY,
+ Kind: types.SIDE_CONTEXT,
+ Focusable: true,
+ }), ContextCallbackOpts{
+ OnFocus: onFocus,
+ OnFocusLost: onFocusLost,
+ OnRenderToMain: onRenderToMain,
+ }),
+ list: viewModel,
+ getDisplayStrings: getDisplayStrings,
+ c: c,
+ },
+ }
+}
+
+func (self *WorkingTreeContext) GetSelectedItemId() string {
+ item := self.GetSelected()
+ if item == nil {
+ return ""
+ }
+
+ return item.ID()
+}
diff --git a/pkg/gui/context_config.go b/pkg/gui/context_config.go
index f567f5a5c..7bd0ca7f8 100644
--- a/pkg/gui/context_config.go
+++ b/pkg/gui/context_config.go
@@ -1,282 +1,260 @@
package gui
-type ContextKey string
-
-const (
- STATUS_CONTEXT_KEY ContextKey = "status"
- FILES_CONTEXT_KEY ContextKey = "files"
- LOCAL_BRANCHES_CONTEXT_KEY ContextKey = "localBranches"
- REMOTES_CONTEXT_KEY ContextKey = "remotes"
- REMOTE_BRANCHES_CONTEXT_KEY ContextKey = "remoteBranches"
- TAGS_CONTEXT_KEY ContextKey = "tags"
- BRANCH_COMMITS_CONTEXT_KEY ContextKey = "commits"
- REFLOG_COMMITS_CONTEXT_KEY ContextKey = "reflogCommits"
- SUB_COMMITS_CONTEXT_KEY ContextKey = "subCommits"
- COMMIT_FILES_CONTEXT_KEY ContextKey = "commitFiles"
- STASH_CONTEXT_KEY ContextKey = "stash"
- MAIN_NORMAL_CONTEXT_KEY ContextKey = "normal"
- MAIN_MERGING_CONTEXT_KEY ContextKey = "merging"
- MAIN_PATCH_BUILDING_CONTEXT_KEY ContextKey = "patchBuilding"
- MAIN_STAGING_CONTEXT_KEY ContextKey = "staging"
- MENU_CONTEXT_KEY ContextKey = "menu"
- CREDENTIALS_CONTEXT_KEY ContextKey = "credentials"
- CONFIRMATION_CONTEXT_KEY ContextKey = "confirmation"
- SEARCH_CONTEXT_KEY ContextKey = "search"
- COMMIT_MESSAGE_CONTEXT_KEY ContextKey = "commitMessage"
- SUBMODULES_CONTEXT_KEY ContextKey = "submodules"
- SUGGESTIONS_CONTEXT_KEY ContextKey = "suggestions"
- COMMAND_LOG_CONTEXT_KEY ContextKey = "cmdLog"
+import (
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
-var allContextKeys = []ContextKey{
- STATUS_CONTEXT_KEY,
- FILES_CONTEXT_KEY,
- LOCAL_BRANCHES_CONTEXT_KEY,
- REMOTES_CONTEXT_KEY,
- REMOTE_BRANCHES_CONTEXT_KEY,
- TAGS_CONTEXT_KEY,
- BRANCH_COMMITS_CONTEXT_KEY,
- REFLOG_COMMITS_CONTEXT_KEY,
- SUB_COMMITS_CONTEXT_KEY,
- COMMIT_FILES_CONTEXT_KEY,
- STASH_CONTEXT_KEY,
- MAIN_NORMAL_CONTEXT_KEY,
- MAIN_MERGING_CONTEXT_KEY,
- MAIN_PATCH_BUILDING_CONTEXT_KEY,
- MAIN_STAGING_CONTEXT_KEY,
- MENU_CONTEXT_KEY,
- CREDENTIALS_CONTEXT_KEY,
- CONFIRMATION_CONTEXT_KEY,
- SEARCH_CONTEXT_KEY,
- COMMIT_MESSAGE_CONTEXT_KEY,
- SUBMODULES_CONTEXT_KEY,
- SUGGESTIONS_CONTEXT_KEY,
- COMMAND_LOG_CONTEXT_KEY,
-}
-
-type ContextTree struct {
- Status Context
- Files IListContext
- Submodules IListContext
- Menu IListContext
- Branches IListContext
- Remotes IListContext
- RemoteBranches IListContext
- Tags IListContext
- BranchCommits IListContext
- CommitFiles IListContext
- ReflogCommits IListContext
- SubCommits IListContext
- Stash IListContext
- Suggestions IListContext
- Normal Context
- Staging Context
- PatchBuilding Context
- Merging Context
- Credentials Context
- Confirmation Context
- CommitMessage Context
- Search Context
- CommandLog Context
-}
-
-func (gui *Gui) allContexts() []Context {
- return []Context{
- gui.State.Contexts.Status,
- gui.State.Contexts.Files,
- gui.State.Contexts.Submodules,
- gui.State.Contexts.Branches,
- gui.State.Contexts.Remotes,
- gui.State.Contexts.RemoteBranches,
- gui.State.Contexts.Tags,
- gui.State.Contexts.BranchCommits,
- gui.State.Contexts.CommitFiles,
- gui.State.Contexts.ReflogCommits,
- gui.State.Contexts.Stash,
- gui.State.Contexts.Menu,
- gui.State.Contexts.Confirmation,
- gui.State.Contexts.Credentials,
- gui.State.Contexts.CommitMessage,
- gui.State.Contexts.Normal,
- gui.State.Contexts.Staging,
- gui.State.Contexts.Merging,
- gui.State.Contexts.PatchBuilding,
- gui.State.Contexts.SubCommits,
- gui.State.Contexts.Suggestions,
- gui.State.Contexts.CommandLog,
- }
-}
-
-func (gui *Gui) contextTree() ContextTree {
- return ContextTree{
- Status: &BasicContext{
- OnRenderToMain: OnFocusWrapper(gui.statusRenderToMain),
- Kind: SIDE_CONTEXT,
- ViewName: "status",
- Key: STATUS_CONTEXT_KEY,
- },
+func (gui *Gui) contextTree() *context.ContextTree {
+ return &context.ContextTree{
+ Global: context.NewSimpleContext(
+ context.NewBaseContext(context.NewBaseContextOpts{
+ Kind: types.GLOBAL_CONTEXT,
+ View: nil, // TODO: see if this breaks anything
+ WindowName: "",
+ Key: context.GLOBAL_CONTEXT_KEY,
+ Focusable: false,
+ HasUncontrolledBounds: true, // setting to true because the global context doesn't even have a view
+ }),
+ context.ContextCallbackOpts{
+ OnRenderToMain: gui.statusRenderToMain,
+ },
+ ),
+ Status: context.NewSimpleContext(
+ context.NewBaseContext(context.NewBaseContextOpts{
+ Kind: types.SIDE_CONTEXT,
+ View: gui.Views.Status,
+ WindowName: "status",
+ Key: context.STATUS_CONTEXT_KEY,
+ Focusable: true,
+ }),
+ context.ContextCallbackOpts{
+ OnRenderToMain: gui.statusRenderToMain,
+ },
+ ),
Files: gui.filesListContext(),
Submodules: gui.submodulesListContext(),
Menu: gui.menuListContext(),
Remotes: gui.remotesListContext(),
RemoteBranches: gui.remoteBranchesListContext(),
- BranchCommits: gui.branchCommitsListContext(),
+ LocalCommits: gui.branchCommitsListContext(),
CommitFiles: gui.commitFilesListContext(),
ReflogCommits: gui.reflogCommitsListContext(),
SubCommits: gui.subCommitsListContext(),
Branches: gui.branchesListContext(),
Tags: gui.tagsListContext(),
Stash: gui.stashListContext(),
- Normal: &BasicContext{
- OnFocus: func(opts ...OnFocusOpts) error {
- return nil // TODO: should we do something here? We should allow for scrolling the panel
+ Suggestions: gui.suggestionsListContext(),
+ Normal: context.NewSimpleContext(
+ context.NewBaseContext(context.NewBaseContextOpts{
+ Kind: types.MAIN_CONTEXT,
+ View: gui.Views.Main,
+ WindowName: "main",
+ Key: context.NORMAL_MAIN_CONTEXT_KEY,
+ Focusable: false,
+ }),
+ context.ContextCallbackOpts{
+ OnFocus: func(opts types.OnFocusOpts) error {
+ return nil // TODO: should we do something here? We should allow for scrolling the panel
+ },
},
- Kind: MAIN_CONTEXT,
- ViewName: "main",
- Key: MAIN_NORMAL_CONTEXT_KEY,
- },
- Staging: &BasicContext{
- OnFocus: func(opts ...OnFocusOpts) error {
- forceSecondaryFocused := false
- selectedLineIdx := -1
- if len(opts) > 0 && opts[0].ClickedViewName != "" {
- if opts[0].ClickedViewName == "main" || opts[0].ClickedViewName == "secondary" {
- selectedLineIdx = opts[0].ClickedViewLineIdx
- }
- if opts[0].ClickedViewName == "secondary" {
- forceSecondaryFocused = true
- }
- }
- return gui.onStagingFocus(forceSecondaryFocused, selectedLineIdx)
- },
- Kind: MAIN_CONTEXT,
- ViewName: "main",
- Key: MAIN_STAGING_CONTEXT_KEY,
- },
- PatchBuilding: &BasicContext{
- OnFocus: func(opts ...OnFocusOpts) error {
- selectedLineIdx := -1
- if len(opts) > 0 && (opts[0].ClickedViewName == "main" || opts[0].ClickedViewName == "secondary") {
- selectedLineIdx = opts[0].ClickedViewLineIdx
- }
+ ),
+ NormalSecondary: context.NewSimpleContext(
+ context.NewBaseContext(context.NewBaseContextOpts{
+ Kind: types.MAIN_CONTEXT,
+ View: gui.Views.Secondary,
+ WindowName: "secondary",
+ Key: context.NORMAL_SECONDARY_CONTEXT_KEY,
+ Focusable: false,
+ }),
+ context.ContextCallbackOpts{},
+ ),
+ Staging: context.NewPatchExplorerContext(
+ gui.Views.Staging,
+ "main",
+ context.STAGING_MAIN_CONTEXT_KEY,
+ func(opts types.OnFocusOpts) error {
+ gui.Views.Staging.Wrap = false
+ gui.Views.StagingSecondary.Wrap = false
- return gui.onPatchBuildingFocus(selectedLineIdx)
+ return gui.refreshStagingPanel(opts)
},
- Kind: MAIN_CONTEXT,
- ViewName: "main",
- Key: MAIN_PATCH_BUILDING_CONTEXT_KEY,
- },
- Merging: &BasicContext{
- OnFocus: OnFocusWrapper(func() error { return gui.renderConflictsWithLock(true) }),
- Kind: MAIN_CONTEXT,
- ViewName: "main",
- Key: MAIN_MERGING_CONTEXT_KEY,
- OnGetOptionsMap: gui.getMergingOptions,
- },
- Credentials: &BasicContext{
- OnFocus: OnFocusWrapper(gui.handleCredentialsViewFocused),
- Kind: PERSISTENT_POPUP,
- ViewName: "credentials",
- Key: CREDENTIALS_CONTEXT_KEY,
- },
- Confirmation: &BasicContext{
- Kind: TEMPORARY_POPUP,
- ViewName: "confirmation",
- Key: CONFIRMATION_CONTEXT_KEY,
- },
- Suggestions: gui.suggestionsListContext(),
- CommitMessage: &BasicContext{
- OnFocus: OnFocusWrapper(gui.handleCommitMessageFocused),
- Kind: PERSISTENT_POPUP,
- ViewName: "commitMessage",
- Key: COMMIT_MESSAGE_CONTEXT_KEY,
- },
- Search: &BasicContext{
- Kind: PERSISTENT_POPUP,
- ViewName: "search",
- Key: SEARCH_CONTEXT_KEY,
- },
- CommandLog: &BasicContext{
- Kind: EXTRAS_CONTEXT,
- ViewName: "extras",
- Key: COMMAND_LOG_CONTEXT_KEY,
- OnGetOptionsMap: gui.getMergingOptions,
- OnFocusLost: func() error {
- gui.Views.Extras.Autoscroll = true
+ func(opts types.OnFocusLostOpts) error {
+ gui.State.Contexts.Staging.SetState(nil)
+
+ if opts.NewContextKey != context.STAGING_SECONDARY_CONTEXT_KEY {
+ gui.Views.Staging.Wrap = true
+ gui.Views.StagingSecondary.Wrap = true
+ _ = gui.State.Contexts.Staging.Render(false)
+ _ = gui.State.Contexts.StagingSecondary.Render(false)
+ }
return nil
},
- },
+ func() []int { return nil },
+ gui.c,
+ ),
+ StagingSecondary: context.NewPatchExplorerContext(
+ gui.Views.StagingSecondary,
+ "secondary",
+ context.STAGING_SECONDARY_CONTEXT_KEY,
+ func(opts types.OnFocusOpts) error {
+ gui.Views.Staging.Wrap = false
+ gui.Views.StagingSecondary.Wrap = false
+
+ return gui.refreshStagingPanel(opts)
+ },
+ func(opts types.OnFocusLostOpts) error {
+ gui.State.Contexts.StagingSecondary.SetState(nil)
+
+ if opts.NewContextKey != context.STAGING_MAIN_CONTEXT_KEY {
+ gui.Views.Staging.Wrap = true
+ gui.Views.StagingSecondary.Wrap = true
+ _ = gui.State.Contexts.Staging.Render(false)
+ _ = gui.State.Contexts.StagingSecondary.Render(false)
+ }
+ return nil
+ },
+ func() []int { return nil },
+ gui.c,
+ ),
+ CustomPatchBuilder: context.NewPatchExplorerContext(
+ gui.Views.PatchBuilding,
+ "main",
+ context.PATCH_BUILDING_MAIN_CONTEXT_KEY,
+ func(opts types.OnFocusOpts) error {
+ // no need to change wrap on the secondary view because it can't be interacted with
+ gui.Views.PatchBuilding.Wrap = false
+
+ return gui.refreshPatchBuildingPanel(opts)
+ },
+ func(opts types.OnFocusLostOpts) error {
+ gui.Views.PatchBuilding.Wrap = true
+
+ if gui.git.Patch.PatchManager.IsEmpty() {
+ gui.git.Patch.PatchManager.Reset()
+ }
+
+ return nil
+ },
+ func() []int {
+ filename := gui.State.Contexts.CommitFiles.GetSelectedPath()
+ includedLineIndices, err := gui.git.Patch.PatchManager.GetFileIncLineIndices(filename)
+ if err != nil {
+ gui.Log.Error(err)
+ return nil
+ }
+
+ return includedLineIndices
+ },
+ gui.c,
+ ),
+ CustomPatchBuilderSecondary: context.NewSimpleContext(
+ context.NewBaseContext(context.NewBaseContextOpts{
+ Kind: types.MAIN_CONTEXT,
+ View: gui.Views.PatchBuildingSecondary,
+ WindowName: "secondary",
+ Key: context.PATCH_BUILDING_SECONDARY_CONTEXT_KEY,
+ Focusable: false,
+ }),
+ context.ContextCallbackOpts{},
+ ),
+ MergeConflicts: context.NewMergeConflictsContext(
+ gui.Views.MergeConflicts,
+ context.ContextCallbackOpts{
+ OnFocus: OnFocusWrapper(func() error {
+ gui.Views.MergeConflicts.Wrap = false
+
+ return gui.refreshMergePanel(true)
+ }),
+ OnFocusLost: func(opts types.OnFocusLostOpts) error {
+ gui.State.Contexts.MergeConflicts.SetUserScrolling(false)
+ gui.State.Contexts.MergeConflicts.GetState().ResetConflictSelection()
+ gui.Views.MergeConflicts.Wrap = true
+
+ return nil
+ },
+ },
+ gui.c,
+ func() map[string]string {
+ // wrapping in a function because contexts are initialized before helpers
+ return gui.helpers.MergeConflicts.GetMergingOptions()
+ },
+ ),
+ Confirmation: context.NewSimpleContext(
+ context.NewBaseContext(context.NewBaseContextOpts{
+ Kind: types.TEMPORARY_POPUP,
+ View: gui.Views.Confirmation,
+ WindowName: "confirmation",
+ Key: context.CONFIRMATION_CONTEXT_KEY,
+ Focusable: true,
+ HasUncontrolledBounds: true,
+ }),
+ context.ContextCallbackOpts{
+ OnFocus: OnFocusWrapper(gui.handleAskFocused),
+ OnFocusLost: func(types.OnFocusLostOpts) error {
+ gui.deactivateConfirmationPrompt()
+ return nil
+ },
+ },
+ ),
+ CommitMessage: context.NewSimpleContext(
+ context.NewBaseContext(context.NewBaseContextOpts{
+ Kind: types.PERSISTENT_POPUP,
+ View: gui.Views.CommitMessage,
+ WindowName: "commitMessage",
+ Key: context.COMMIT_MESSAGE_CONTEXT_KEY,
+ Focusable: true,
+ HasUncontrolledBounds: true,
+ }),
+ context.ContextCallbackOpts{
+ OnFocus: OnFocusWrapper(gui.handleCommitMessageFocused),
+ },
+ ),
+ Search: context.NewSimpleContext(
+ context.NewBaseContext(context.NewBaseContextOpts{
+ Kind: types.PERSISTENT_POPUP,
+ View: gui.Views.Search,
+ WindowName: "search",
+ Key: context.SEARCH_CONTEXT_KEY,
+ Focusable: true,
+ }),
+ context.ContextCallbackOpts{},
+ ),
+ CommandLog: context.NewSimpleContext(
+ context.NewBaseContext(context.NewBaseContextOpts{
+ Kind: types.EXTRAS_CONTEXT,
+ View: gui.Views.Extras,
+ WindowName: "extras",
+ Key: context.COMMAND_LOG_CONTEXT_KEY,
+ Focusable: true,
+ }),
+ context.ContextCallbackOpts{
+ OnFocusLost: func(opts types.OnFocusLostOpts) error {
+ gui.Views.Extras.Autoscroll = true
+ return nil
+ },
+ },
+ ),
+ Options: context.NewDisplayContext(context.OPTIONS_CONTEXT_KEY, gui.Views.Options, "options"),
+ AppStatus: context.NewDisplayContext(context.APP_STATUS_CONTEXT_KEY, gui.Views.AppStatus, "appStatus"),
+ SearchPrefix: context.NewDisplayContext(context.SEARCH_PREFIX_CONTEXT_KEY, gui.Views.SearchPrefix, "searchPrefix"),
+ Information: context.NewDisplayContext(context.INFORMATION_CONTEXT_KEY, gui.Views.Information, "information"),
+ Limit: context.NewDisplayContext(context.LIMIT_CONTEXT_KEY, gui.Views.Limit, "limit"),
}
}
// using this wrapper for when an onFocus function doesn't care about any potential
// props that could be passed
-func OnFocusWrapper(f func() error) func(opts ...OnFocusOpts) error {
- return func(opts ...OnFocusOpts) error {
+func OnFocusWrapper(f func() error) func(opts types.OnFocusOpts) error {
+ return func(opts types.OnFocusOpts) error {
return f()
}
}
-func (tree ContextTree) initialViewContextMap() map[string]Context {
- return map[string]Context{
- "status": tree.Status,
- "files": tree.Files,
- "branches": tree.Branches,
- "commits": tree.BranchCommits,
- "commitFiles": tree.CommitFiles,
- "stash": tree.Stash,
- "menu": tree.Menu,
- "confirmation": tree.Confirmation,
- "credentials": tree.Credentials,
- "commitMessage": tree.CommitMessage,
- "main": tree.Normal,
- "secondary": tree.Normal,
- "extras": tree.CommandLog,
- }
-}
-
-func (tree ContextTree) initialViewTabContextMap() map[string][]tabContext {
- return map[string][]tabContext{
- "branches": {
- {
- tab: "Local Branches",
- contexts: []Context{tree.Branches},
- },
- {
- tab: "Remotes",
- contexts: []Context{
- tree.Remotes,
- tree.RemoteBranches,
- },
- },
- {
- tab: "Tags",
- contexts: []Context{tree.Tags},
- },
- },
- "commits": {
- {
- tab: "Commits",
- contexts: []Context{tree.BranchCommits},
- },
- {
- tab: "Reflog",
- contexts: []Context{
- tree.ReflogCommits,
- },
- },
- },
- "files": {
- {
- tab: "Files",
- contexts: []Context{tree.Files},
- },
- {
- tab: "Submodules",
- contexts: []Context{
- tree.Submodules,
- },
- },
- },
+func (gui *Gui) getPatchExplorerContexts() []types.IPatchExplorerContext {
+ return []types.IPatchExplorerContext{
+ gui.State.Contexts.Staging,
+ gui.State.Contexts.StagingSecondary,
+ gui.State.Contexts.CustomPatchBuilder,
}
}
diff --git a/pkg/gui/context_test.go b/pkg/gui/context_test.go
deleted file mode 100644
index 7f03f7484..000000000
--- a/pkg/gui/context_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package gui
-
-import (
- "testing"
-
- "github.com/jesseduffield/gocui"
- "github.com/stretchr/testify/assert"
-)
-
-func TestCanDeactivatePopupContextsWithoutViews(t *testing.T) {
- contexts := []func(gui *Gui) Context{
- func(gui *Gui) Context { return gui.State.Contexts.Credentials },
- func(gui *Gui) Context { return gui.State.Contexts.Confirmation },
- func(gui *Gui) Context { return gui.State.Contexts.CommitMessage },
- func(gui *Gui) Context { return gui.State.Contexts.Search },
- }
-
- for _, c := range contexts {
- gui := NewDummyGui()
- context := c(gui)
- gui.g = &gocui.Gui{}
-
- _ = gui.deactivateContext(context)
-
- // This really only checks a prerequisit, not the effect of deactivateContext
- view, _ := gui.g.View(context.GetViewName())
- assert.Nil(t, view, string(context.GetKey()))
- }
-}
-
-func TestCanDeactivateCommitFilesContextsWithoutViews(t *testing.T) {
- gui := NewDummyGui()
- gui.g = &gocui.Gui{}
-
- _ = gui.deactivateContext(gui.State.Contexts.CommitFiles)
-
- // This really only checks a prerequisite, not the effect of deactivateContext
- view, _ := gui.g.View(gui.State.Contexts.CommitFiles.GetViewName())
- assert.Nil(t, view)
-}
diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go
new file mode 100644
index 000000000..4efb5e1ff
--- /dev/null
+++ b/pkg/gui/controllers.go
@@ -0,0 +1,258 @@
+package gui
+
+import (
+ "strings"
+
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/controllers"
+ "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
+ "github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking"
+ "github.com/jesseduffield/lazygit/pkg/gui/services/custom_commands"
+)
+
+func (gui *Gui) resetControllers() {
+ helperCommon := gui.c
+ osCommand := gui.os
+ model := gui.State.Model
+ refsHelper := helpers.NewRefsHelper(
+ helperCommon,
+ gui.git,
+ gui.State.Contexts,
+ model,
+ )
+
+ rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon, gui.State.Contexts, gui.git, refsHelper)
+ suggestionsHelper := helpers.NewSuggestionsHelper(helperCommon, model, gui.refreshSuggestions)
+ gui.helpers = &helpers.Helpers{
+ Refs: refsHelper,
+ Host: helpers.NewHostHelper(helperCommon, gui.git),
+ PatchBuilding: helpers.NewPatchBuildingHelper(helperCommon, gui.git, gui.State.Contexts),
+ Bisect: helpers.NewBisectHelper(helperCommon, gui.git),
+ Suggestions: suggestionsHelper,
+ Files: helpers.NewFilesHelper(helperCommon, gui.git, osCommand),
+ WorkingTree: helpers.NewWorkingTreeHelper(helperCommon, gui.git, model),
+ Tags: helpers.NewTagsHelper(helperCommon, gui.git),
+ GPG: helpers.NewGpgHelper(helperCommon, gui.os, gui.git),
+ MergeAndRebase: rebaseHelper,
+ MergeConflicts: helpers.NewMergeConflictsHelper(helperCommon, gui.State.Contexts, gui.git),
+ CherryPick: helpers.NewCherryPickHelper(
+ helperCommon,
+ gui.git,
+ gui.State.Contexts,
+ func() *cherrypicking.CherryPicking { return gui.State.Modes.CherryPicking },
+ rebaseHelper,
+ ),
+ Upstream: helpers.NewUpstreamHelper(helperCommon, model, suggestionsHelper.GetRemoteBranchesSuggestionsFunc),
+ }
+
+ gui.CustomCommandsClient = custom_commands.NewClient(
+ helperCommon,
+ gui.os,
+ gui.git,
+ gui.State.Contexts,
+ gui.helpers,
+ )
+
+ common := controllers.NewControllerCommon(
+ helperCommon,
+ osCommand,
+ gui.git,
+ gui.helpers,
+ model,
+ gui.State.Contexts,
+ gui.State.Modes,
+ &gui.Mutexes,
+ )
+
+ syncController := controllers.NewSyncController(
+ common,
+ )
+
+ submodulesController := controllers.NewSubmodulesController(
+ common,
+ gui.enterSubmodule,
+ )
+
+ bisectController := controllers.NewBisectController(common)
+
+ getSavedCommitMessage := func() string {
+ return gui.State.savedCommitMessage
+ }
+
+ getCommitMessage := func() string {
+ return strings.TrimSpace(gui.Views.CommitMessage.TextArea.GetContent())
+ }
+
+ setCommitMessage := gui.getSetTextareaTextFn(func() *gocui.View { return gui.Views.CommitMessage })
+
+ onCommitAttempt := func(message string) {
+ gui.State.savedCommitMessage = message
+ gui.Views.CommitMessage.ClearTextArea()
+ }
+
+ onCommitSuccess := func() {
+ gui.State.savedCommitMessage = ""
+ }
+
+ commitMessageController := controllers.NewCommitMessageController(
+ common,
+ getCommitMessage,
+ onCommitAttempt,
+ onCommitSuccess,
+ )
+
+ remoteBranchesController := controllers.NewRemoteBranchesController(common)
+
+ menuController := controllers.NewMenuController(common)
+ localCommitsController := controllers.NewLocalCommitsController(common, syncController.HandlePull)
+ tagsController := controllers.NewTagsController(common)
+ filesController := controllers.NewFilesController(
+ common,
+ gui.enterSubmodule,
+ setCommitMessage,
+ getSavedCommitMessage,
+ )
+ mergeConflictsController := controllers.NewMergeConflictsController(common)
+ remotesController := controllers.NewRemotesController(
+ common,
+ func(branches []*models.RemoteBranch) { gui.State.Model.RemoteBranches = branches },
+ )
+ undoController := controllers.NewUndoController(common)
+ globalController := controllers.NewGlobalController(common)
+ contextLinesController := controllers.NewContextLinesController(common)
+ verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common)
+
+ branchesController := controllers.NewBranchesController(common)
+ gitFlowController := controllers.NewGitFlowController(common)
+ filesRemoveController := controllers.NewFilesRemoveController(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)
+ patchBuildingController := controllers.NewPatchBuildingController(common)
+
+ setSubCommits := func(commits []*models.Commit) { gui.State.Model.SubCommits = commits }
+
+ for _, context := range []controllers.CanSwitchToSubCommits{
+ gui.State.Contexts.Branches,
+ gui.State.Contexts.RemoteBranches,
+ gui.State.Contexts.Tags,
+ gui.State.Contexts.ReflogCommits,
+ } {
+ controllers.AttachControllers(context, controllers.NewSwitchToSubCommitsController(
+ common, setSubCommits, context,
+ ))
+ }
+
+ for _, context := range []controllers.CanSwitchToDiffFiles{
+ gui.State.Contexts.LocalCommits,
+ gui.State.Contexts.SubCommits,
+ gui.State.Contexts.Stash,
+ } {
+ controllers.AttachControllers(context, controllers.NewSwitchToDiffFilesController(
+ common, gui.SwitchToCommitFilesContext, context,
+ ))
+ }
+
+ for _, context := range []controllers.ContainsCommits{
+ gui.State.Contexts.LocalCommits,
+ gui.State.Contexts.ReflogCommits,
+ gui.State.Contexts.SubCommits,
+ } {
+ controllers.AttachControllers(context, controllers.NewBasicCommitsController(common, context))
+ }
+
+ // 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.CustomPatchBuilder),
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.MergeConflicts,
+ mergeConflictsController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.Files,
+ filesController,
+ filesRemoveController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.Tags,
+ tagsController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.Submodules,
+ submodulesController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.LocalCommits,
+ localCommitsController,
+ bisectController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.Branches,
+ branchesController,
+ gitFlowController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.LocalCommits,
+ localCommitsController,
+ bisectController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.CommitFiles,
+ commitFilesController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.Remotes,
+ remotesController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.Stash,
+ stashController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.Menu,
+ menuController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.CommitMessage,
+ commitMessageController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.RemoteBranches,
+ remoteBranchesController,
+ )
+
+ controllers.AttachControllers(gui.State.Contexts.Global,
+ syncController,
+ undoController,
+ globalController,
+ contextLinesController,
+ )
+
+ // this must come last so that we've got our click handlers defined against the context
+ listControllerFactory := controllers.NewListControllerFactory(gui.c)
+ for _, context := range gui.getListContexts() {
+ controllers.AttachControllers(context, listControllerFactory.Create(context))
+ }
+}
diff --git a/pkg/gui/controllers/attach.go b/pkg/gui/controllers/attach.go
new file mode 100644
index 000000000..3e621c54c
--- /dev/null
+++ b/pkg/gui/controllers/attach.go
@@ -0,0 +1,11 @@
+package controllers
+
+import "github.com/jesseduffield/lazygit/pkg/gui/types"
+
+func AttachControllers(context types.Context, controllers ...types.IController) {
+ for _, controller := range controllers {
+ context.AddKeybindingsFn(controller.GetKeybindings)
+ context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
+ context.AddOnClickFn(controller.GetOnClick())
+ }
+}
diff --git a/pkg/gui/controllers/base_controller.go b/pkg/gui/controllers/base_controller.go
new file mode 100644
index 000000000..db7ad7a40
--- /dev/null
+++ b/pkg/gui/controllers/base_controller.go
@@ -0,0 +1,20 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type baseController struct{}
+
+func (self *baseController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ return nil
+}
+
+func (self *baseController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
+ return nil
+}
+
+func (self *baseController) GetOnClick() func() error {
+ return nil
+}
diff --git a/pkg/gui/controllers/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go
new file mode 100644
index 000000000..cecba00fe
--- /dev/null
+++ b/pkg/gui/controllers/basic_commits_controller.go
@@ -0,0 +1,253 @@
+package controllers
+
+import (
+ "fmt"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// This controller is for all contexts that contain a list of commits.
+
+var _ types.IController = &BasicCommitsController{}
+
+type ContainsCommits interface {
+ types.Context
+ GetSelected() *models.Commit
+ GetCommits() []*models.Commit
+ GetSelectedLineIdx() int
+}
+
+type BasicCommitsController struct {
+ baseController
+ *controllerCommon
+ context ContainsCommits
+}
+
+func NewBasicCommitsController(controllerCommon *controllerCommon, context ContainsCommits) *BasicCommitsController {
+ return &BasicCommitsController{
+ baseController: baseController{},
+ controllerCommon: controllerCommon,
+ context: context,
+ }
+}
+
+func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Commits.CheckoutCommit),
+ Handler: self.checkSelected(self.checkout),
+ Description: self.c.Tr.LcCheckoutCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.CopyCommitAttributeToClipboard),
+ Handler: self.checkSelected(self.copyCommitAttribute),
+ Description: self.c.Tr.LcCopyCommitAttributeToClipboard,
+ OpensMenu: true,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.OpenInBrowser),
+ Handler: self.checkSelected(self.openInBrowser),
+ Description: self.c.Tr.LcOpenCommitInBrowser,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.New),
+ Handler: self.checkSelected(self.newBranch),
+ Description: self.c.Tr.LcCreateNewBranchFromCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.ViewResetOptions),
+ Handler: self.checkSelected(self.createResetMenu),
+ Description: self.c.Tr.LcViewResetOptions,
+ OpensMenu: true,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.CherryPickCopy),
+ Handler: self.checkSelected(self.copy),
+ Description: self.c.Tr.LcCherryPickCopy,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.CherryPickCopyRange),
+ Handler: self.checkSelected(self.copyRange),
+ Description: self.c.Tr.LcCherryPickCopyRange,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.ResetCherryPick),
+ Handler: self.helpers.CherryPick.Reset,
+ Description: self.c.Tr.LcResetCherryPick,
+ },
+ }
+
+ return bindings
+}
+
+func (self *BasicCommitsController) checkSelected(callback func(*models.Commit) error) func() error {
+ return func() error {
+ commit := self.context.GetSelected()
+ if commit == nil {
+ return nil
+ }
+
+ return callback(commit)
+ }
+}
+
+func (self *BasicCommitsController) Context() types.Context {
+ return self.context
+}
+
+func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) error {
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: self.c.Tr.Actions.CopyCommitAttributeToClipboard,
+ Items: []*types.MenuItem{
+ {
+ Label: self.c.Tr.LcCommitSha,
+ OnPress: func() error {
+ return self.copyCommitSHAToClipboard(commit)
+ },
+ Key: 's',
+ },
+ {
+ Label: self.c.Tr.LcCommitURL,
+ OnPress: func() error {
+ return self.copyCommitURLToClipboard(commit)
+ },
+ Key: 'u',
+ },
+ {
+ Label: self.c.Tr.LcCommitDiff,
+ OnPress: func() error {
+ return self.copyCommitDiffToClipboard(commit)
+ },
+ Key: 'd',
+ },
+ {
+ Label: self.c.Tr.LcCommitMessage,
+ OnPress: func() error {
+ return self.copyCommitMessageToClipboard(commit)
+ },
+ Key: 'm',
+ },
+ {
+ Label: self.c.Tr.LcCommitAuthor,
+ OnPress: func() error {
+ return self.copyAuthorToClipboard(commit)
+ },
+ Key: 'a',
+ },
+ },
+ })
+}
+
+func (self *BasicCommitsController) copyCommitSHAToClipboard(commit *models.Commit) error {
+ self.c.LogAction(self.c.Tr.Actions.CopyCommitSHAToClipboard)
+ if err := self.os.CopyToClipboard(commit.Sha); err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.Toast(self.c.Tr.CommitSHACopiedToClipboard)
+ return nil
+}
+
+func (self *BasicCommitsController) copyCommitURLToClipboard(commit *models.Commit) error {
+ url, err := self.helpers.Host.GetCommitURL(commit.Sha)
+ if err != nil {
+ return err
+ }
+
+ self.c.LogAction(self.c.Tr.Actions.CopyCommitURLToClipboard)
+ if err := self.os.CopyToClipboard(url); err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.Toast(self.c.Tr.CommitURLCopiedToClipboard)
+ return nil
+}
+
+func (self *BasicCommitsController) copyCommitDiffToClipboard(commit *models.Commit) error {
+ diff, err := self.git.Commit.GetCommitDiff(commit.Sha)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.LogAction(self.c.Tr.Actions.CopyCommitDiffToClipboard)
+ if err := self.os.CopyToClipboard(diff); err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.Toast(self.c.Tr.CommitDiffCopiedToClipboard)
+ return nil
+}
+
+func (self *BasicCommitsController) copyAuthorToClipboard(commit *models.Commit) error {
+ author, err := self.git.Commit.GetCommitAuthor(commit.Sha)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ formattedAuthor := fmt.Sprintf("%s <%s>", author.Name, author.Email)
+
+ self.c.LogAction(self.c.Tr.Actions.CopyCommitAuthorToClipboard)
+ if err := self.os.CopyToClipboard(formattedAuthor); err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.Toast(self.c.Tr.CommitAuthorCopiedToClipboard)
+ return nil
+}
+
+func (self *BasicCommitsController) copyCommitMessageToClipboard(commit *models.Commit) error {
+ message, err := self.git.Commit.GetCommitMessage(commit.Sha)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.LogAction(self.c.Tr.Actions.CopyCommitMessageToClipboard)
+ if err := self.os.CopyToClipboard(message); err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.Toast(self.c.Tr.CommitMessageCopiedToClipboard)
+ return nil
+}
+
+func (self *BasicCommitsController) openInBrowser(commit *models.Commit) error {
+ url, err := self.helpers.Host.GetCommitURL(commit.Sha)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.LogAction(self.c.Tr.Actions.OpenCommitInBrowser)
+ if err := self.os.OpenLink(url); err != nil {
+ return self.c.Error(err)
+ }
+
+ return nil
+}
+
+func (self *BasicCommitsController) newBranch(commit *models.Commit) error {
+ return self.helpers.Refs.NewBranch(commit.RefName(), commit.Description(), "")
+}
+
+func (self *BasicCommitsController) createResetMenu(commit *models.Commit) error {
+ return self.helpers.Refs.CreateGitResetMenu(commit.Sha)
+}
+
+func (self *BasicCommitsController) checkout(commit *models.Commit) error {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.LcCheckoutCommit,
+ Prompt: self.c.Tr.SureCheckoutThisCommit,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.CheckoutCommit)
+ return self.helpers.Refs.CheckoutRef(commit.Sha, types.CheckoutRefOptions{})
+ },
+ })
+}
+
+func (self *BasicCommitsController) copy(commit *models.Commit) error {
+ return self.helpers.CherryPick.Copy(commit, self.context.GetCommits(), self.context)
+}
+
+func (self *BasicCommitsController) copyRange(*models.Commit) error {
+ return self.helpers.CherryPick.CopyRange(self.context.GetSelectedLineIdx(), self.context.GetCommits(), self.context)
+}
diff --git a/pkg/gui/controllers/bisect_controller.go b/pkg/gui/controllers/bisect_controller.go
new file mode 100644
index 000000000..083b6dce8
--- /dev/null
+++ b/pkg/gui/controllers/bisect_controller.go
@@ -0,0 +1,249 @@
+package controllers
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/git_commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type BisectController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &BisectController{}
+
+func NewBisectController(
+ common *controllerCommon,
+) *BisectController {
+ return &BisectController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *BisectController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Commits.ViewBisectOptions),
+ Handler: opts.Guards.OutsideFilterMode(self.checkSelected(self.openMenu)),
+ Description: self.c.Tr.LcViewBisectOptions,
+ OpensMenu: true,
+ },
+ }
+
+ return bindings
+}
+
+func (self *BisectController) openMenu(commit *models.Commit) error {
+ // no shame in getting this directly rather than using the cached value
+ // given how cheap it is to obtain
+ info := self.git.Bisect.GetInfo()
+ if info.Started() {
+ return self.openMidBisectMenu(info, commit)
+ } else {
+ return self.openStartBisectMenu(info, commit)
+ }
+}
+
+func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, commit *models.Commit) error {
+ // if there is not yet a 'current' bisect commit, or if we have
+ // selected the current commit, we need to jump to the next 'current' commit
+ // after we perform a bisect action. The reason we don't unconditionally jump
+ // is that sometimes the user will want to go and mark a few commits as skipped
+ // in a row and they wouldn't want to be jumped back to the current bisect
+ // commit each time.
+ // Originally we were allowing the user to, from the bisect menu, select whether
+ // they were talking about the selected commit or the current bisect commit,
+ // and that was a bit confusing (and required extra keypresses).
+ selectCurrentAfter := info.GetCurrentSha() == "" || info.GetCurrentSha() == commit.Sha
+ // we need to wait to reselect if our bisect commits aren't ancestors of our 'start'
+ // ref, because we'll be reloading our commits in that case.
+ waitToReselect := selectCurrentAfter && !self.git.Bisect.ReachableFromStart(info)
+
+ menuItems := []*types.MenuItem{
+ {
+ Label: fmt.Sprintf(self.c.Tr.Bisect.Mark, commit.ShortSha(), info.NewTerm()),
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.BisectMark)
+ if err := self.git.Bisect.Mark(commit.Sha, info.NewTerm()); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.afterMark(selectCurrentAfter, waitToReselect)
+ },
+ Key: 'b',
+ },
+ {
+ Label: fmt.Sprintf(self.c.Tr.Bisect.Mark, commit.ShortSha(), info.OldTerm()),
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.BisectMark)
+ if err := self.git.Bisect.Mark(commit.Sha, info.OldTerm()); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.afterMark(selectCurrentAfter, waitToReselect)
+ },
+ Key: 'g',
+ },
+ {
+ Label: fmt.Sprintf(self.c.Tr.Bisect.Skip, commit.ShortSha()),
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.BisectSkip)
+ if err := self.git.Bisect.Skip(commit.Sha); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.afterMark(selectCurrentAfter, waitToReselect)
+ },
+ Key: 's',
+ },
+ {
+ Label: self.c.Tr.Bisect.ResetOption,
+ OnPress: func() error {
+ return self.helpers.Bisect.Reset()
+ },
+ Key: 'r',
+ },
+ }
+
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: self.c.Tr.Bisect.BisectMenuTitle,
+ Items: menuItems,
+ })
+}
+
+func (self *BisectController) openStartBisectMenu(info *git_commands.BisectInfo, commit *models.Commit) error {
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: self.c.Tr.Bisect.BisectMenuTitle,
+ Items: []*types.MenuItem{
+ {
+ Label: fmt.Sprintf(self.c.Tr.Bisect.MarkStart, commit.ShortSha(), info.NewTerm()),
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.StartBisect)
+ if err := self.git.Bisect.Start(); err != nil {
+ return self.c.Error(err)
+ }
+
+ if err := self.git.Bisect.Mark(commit.Sha, info.NewTerm()); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.helpers.Bisect.PostBisectCommandRefresh()
+ },
+ Key: 'b',
+ },
+ {
+ Label: fmt.Sprintf(self.c.Tr.Bisect.MarkStart, commit.ShortSha(), info.OldTerm()),
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.StartBisect)
+ if err := self.git.Bisect.Start(); err != nil {
+ return self.c.Error(err)
+ }
+
+ if err := self.git.Bisect.Mark(commit.Sha, info.OldTerm()); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.helpers.Bisect.PostBisectCommandRefresh()
+ },
+ Key: 'g',
+ },
+ },
+ })
+}
+
+func (self *BisectController) showBisectCompleteMessage(candidateShas []string) error {
+ prompt := self.c.Tr.Bisect.CompletePrompt
+ if len(candidateShas) > 1 {
+ prompt = self.c.Tr.Bisect.CompletePromptIndeterminate
+ }
+
+ formattedCommits, err := self.git.Commit.GetCommitsOneline(candidateShas)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.Bisect.CompleteTitle,
+ Prompt: fmt.Sprintf(prompt, strings.TrimSpace(formattedCommits)),
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.ResetBisect)
+ if err := self.git.Bisect.Reset(); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.helpers.Bisect.PostBisectCommandRefresh()
+ },
+ })
+}
+
+func (self *BisectController) afterMark(selectCurrent bool, waitToReselect bool) error {
+ done, candidateShas, err := self.git.Bisect.IsDone()
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ if err := self.afterBisectMarkRefresh(selectCurrent, waitToReselect); err != nil {
+ return self.c.Error(err)
+ }
+
+ if done {
+ return self.showBisectCompleteMessage(candidateShas)
+ }
+
+ return nil
+}
+
+func (self *BisectController) afterBisectMarkRefresh(selectCurrent bool, waitToReselect bool) error {
+ selectFn := func() {
+ if selectCurrent {
+ self.selectCurrentBisectCommit()
+ }
+ }
+
+ if waitToReselect {
+ return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{}, Then: selectFn})
+ } else {
+ selectFn()
+
+ return self.helpers.Bisect.PostBisectCommandRefresh()
+ }
+}
+
+func (self *BisectController) selectCurrentBisectCommit() {
+ info := self.git.Bisect.GetInfo()
+ if info.GetCurrentSha() != "" {
+ // find index of commit with that sha, move cursor to that.
+ for i, commit := range self.model.Commits {
+ if commit.Sha == info.GetCurrentSha() {
+ self.context().SetSelectedLineIdx(i)
+ _ = self.context().HandleFocus(types.OnFocusOpts{})
+ break
+ }
+ }
+ }
+}
+
+func (self *BisectController) checkSelected(callback func(*models.Commit) error) func() error {
+ return func() error {
+ commit := self.context().GetSelected()
+ if commit == nil {
+ return nil
+ }
+
+ return callback(commit)
+ }
+}
+
+func (self *BisectController) Context() types.Context {
+ return self.context()
+}
+
+func (self *BisectController) context() *context.LocalCommitsContext {
+ return self.contexts.LocalCommits
+}
diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go
new file mode 100644
index 000000000..b049a0a0b
--- /dev/null
+++ b/pkg/gui/controllers/branches_controller.go
@@ -0,0 +1,499 @@
+package controllers
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/git_commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type BranchesController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &BranchesController{}
+
+func NewBranchesController(
+ common *controllerCommon,
+) *BranchesController {
+ return &BranchesController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ return []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.Select),
+ Handler: self.checkSelected(self.press),
+ Description: self.c.Tr.LcCheckout,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.New),
+ Handler: self.checkSelected(self.newBranch),
+ Description: self.c.Tr.LcNewBranch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.CreatePullRequest),
+ Handler: self.checkSelected(self.handleCreatePullRequest),
+ Description: self.c.Tr.LcCreatePullRequest,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.ViewPullRequestOptions),
+ Handler: self.checkSelected(self.handleCreatePullRequestMenu),
+ Description: self.c.Tr.LcCreatePullRequestOptions,
+ OpensMenu: true,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.CopyPullRequestURL),
+ Handler: self.copyPullRequestURL,
+ Description: self.c.Tr.LcCopyPullRequestURL,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.CheckoutBranchByName),
+ Handler: self.checkoutByName,
+ Description: self.c.Tr.LcCheckoutByName,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.ForceCheckoutBranch),
+ Handler: self.forceCheckout,
+ Description: self.c.Tr.LcForceCheckout,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Remove),
+ Handler: self.checkSelectedAndReal(self.delete),
+ Description: self.c.Tr.LcDeleteBranch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.RebaseBranch),
+ Handler: opts.Guards.OutsideFilterMode(self.rebase),
+ Description: self.c.Tr.LcRebaseBranch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.MergeIntoCurrentBranch),
+ Handler: opts.Guards.OutsideFilterMode(self.merge),
+ Description: self.c.Tr.LcMergeIntoCurrentBranch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.FastForward),
+ Handler: self.checkSelectedAndReal(self.fastForward),
+ Description: self.c.Tr.FastForward,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.ViewResetOptions),
+ Handler: self.checkSelected(self.createResetMenu),
+ Description: self.c.Tr.LcViewResetOptions,
+ OpensMenu: true,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.RenameBranch),
+ Handler: self.checkSelectedAndReal(self.rename),
+ Description: self.c.Tr.LcRenameBranch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.SetUpstream),
+ Handler: self.checkSelected(self.setUpstream),
+ Description: self.c.Tr.LcSetUnsetUpstream,
+ OpensMenu: true,
+ },
+ }
+}
+
+func (self *BranchesController) setUpstream(selectedBranch *models.Branch) error {
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: self.c.Tr.Actions.SetUnsetUpstream,
+ Items: []*types.MenuItem{
+ {
+ LabelColumns: []string{self.c.Tr.LcUnsetUpstream},
+ OnPress: func() error {
+ if err := self.git.Branch.UnsetUpstream(selectedBranch.Name); err != nil {
+ return self.c.Error(err)
+ }
+ if err := self.c.Refresh(types.RefreshOptions{
+ Mode: types.SYNC,
+ Scope: []types.RefreshableView{
+ types.BRANCHES,
+ types.COMMITS,
+ },
+ }); err != nil {
+ return self.c.Error(err)
+ }
+ return nil
+ },
+ Key: 'u',
+ },
+ {
+ LabelColumns: []string{self.c.Tr.LcSetUpstream},
+ OnPress: func() error {
+ return self.helpers.Upstream.PromptForUpstreamWithoutInitialContent(selectedBranch, func(upstream string) error {
+ upstreamRemote, upstreamBranch, err := self.helpers.Upstream.ParseUpstream(upstream)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ if err := self.git.Branch.SetUpstream(upstreamRemote, upstreamBranch, selectedBranch.Name); err != nil {
+ return self.c.Error(err)
+ }
+ if err := self.c.Refresh(types.RefreshOptions{
+ Mode: types.SYNC,
+ Scope: []types.RefreshableView{
+ types.BRANCHES,
+ types.COMMITS,
+ },
+ }); err != nil {
+ return self.c.Error(err)
+ }
+ return nil
+ })
+ },
+ Key: 's',
+ },
+ },
+ })
+}
+
+func (self *BranchesController) Context() types.Context {
+ return self.context()
+}
+
+func (self *BranchesController) context() *context.BranchesContext {
+ return self.contexts.Branches
+}
+
+func (self *BranchesController) press(selectedBranch *models.Branch) error {
+ if selectedBranch == self.helpers.Refs.GetCheckedOutRef() {
+ return self.c.ErrorMsg(self.c.Tr.AlreadyCheckedOutBranch)
+ }
+
+ self.c.LogAction(self.c.Tr.Actions.CheckoutBranch)
+ return self.helpers.Refs.CheckoutRef(selectedBranch.Name, types.CheckoutRefOptions{})
+}
+
+func (self *BranchesController) handleCreatePullRequest(selectedBranch *models.Branch) error {
+ return self.createPullRequest(selectedBranch.Name, "")
+}
+
+func (self *BranchesController) handleCreatePullRequestMenu(selectedBranch *models.Branch) error {
+ checkedOutBranch := self.helpers.Refs.GetCheckedOutRef()
+
+ return self.createPullRequestMenu(selectedBranch, checkedOutBranch)
+}
+
+func (self *BranchesController) copyPullRequestURL() error {
+ branch := self.context().GetSelected()
+
+ branchExistsOnRemote := self.git.Remote.CheckRemoteBranchExists(branch.Name)
+
+ if !branchExistsOnRemote {
+ return self.c.Error(errors.New(self.c.Tr.NoBranchOnRemote))
+ }
+
+ url, err := self.helpers.Host.GetPullRequestURL(branch.Name, "")
+ if err != nil {
+ return self.c.Error(err)
+ }
+ self.c.LogAction(self.c.Tr.Actions.CopyPullRequestURL)
+ if err := self.os.CopyToClipboard(url); err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.Toast(self.c.Tr.PullRequestURLCopiedToClipboard)
+
+ return nil
+}
+
+func (self *BranchesController) forceCheckout() error {
+ branch := self.context().GetSelected()
+ message := self.c.Tr.SureForceCheckout
+ title := self.c.Tr.ForceCheckoutBranch
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: title,
+ Prompt: message,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.ForceCheckoutBranch)
+ if err := self.git.Branch.Checkout(branch.Name, git_commands.CheckoutOptions{Force: true}); err != nil {
+ _ = self.c.Error(err)
+ }
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+ },
+ })
+}
+
+func (self *BranchesController) checkoutByName() error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.BranchName + ":",
+ FindSuggestionsFunc: self.helpers.Suggestions.GetRefsSuggestionsFunc(),
+ HandleConfirm: func(response string) error {
+ self.c.LogAction("Checkout branch")
+ return self.helpers.Refs.CheckoutRef(response, types.CheckoutRefOptions{
+ OnRefNotFound: func(ref string) error {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.BranchNotFoundTitle,
+ Prompt: fmt.Sprintf("%s %s%s", self.c.Tr.BranchNotFoundPrompt, ref, "?"),
+ HandleConfirm: func() error {
+ return self.createNewBranchWithName(ref)
+ },
+ })
+ },
+ })
+ },
+ },
+ )
+}
+
+func (self *BranchesController) createNewBranchWithName(newBranchName string) error {
+ branch := self.context().GetSelected()
+ if branch == nil {
+ return nil
+ }
+
+ if err := self.git.Branch.New(newBranchName, branch.Name); err != nil {
+ return self.c.Error(err)
+ }
+
+ self.context().SetSelectedLineIdx(0)
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+}
+
+func (self *BranchesController) delete(branch *models.Branch) error {
+ checkedOutBranch := self.helpers.Refs.GetCheckedOutRef()
+ if checkedOutBranch.Name == branch.Name {
+ return self.c.ErrorMsg(self.c.Tr.CantDeleteCheckOutBranch)
+ }
+ return self.deleteWithForce(branch, false)
+}
+
+func (self *BranchesController) deleteWithForce(selectedBranch *models.Branch, force bool) error {
+ title := self.c.Tr.DeleteBranch
+ var templateStr string
+ if force {
+ templateStr = self.c.Tr.ForceDeleteBranchMessage
+ } else {
+ templateStr = self.c.Tr.DeleteBranchMessage
+ }
+ message := utils.ResolvePlaceholderString(
+ templateStr,
+ map[string]string{
+ "selectedBranchName": selectedBranch.Name,
+ },
+ )
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: title,
+ Prompt: message,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.DeleteBranch)
+ if err := self.git.Branch.Delete(selectedBranch.Name, force); err != nil {
+ errMessage := err.Error()
+ if !force && strings.Contains(errMessage, "git branch -D ") {
+ return self.deleteWithForce(selectedBranch, true)
+ }
+ return self.c.ErrorMsg(errMessage)
+ }
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}})
+ },
+ })
+}
+
+func (self *BranchesController) merge() error {
+ selectedBranchName := self.context().GetSelected().Name
+ return self.helpers.MergeAndRebase.MergeRefIntoCheckedOutBranch(selectedBranchName)
+}
+
+func (self *BranchesController) rebase() error {
+ selectedBranchName := self.context().GetSelected().Name
+ return self.helpers.MergeAndRebase.RebaseOntoRef(selectedBranchName)
+}
+
+func (self *BranchesController) fastForward(branch *models.Branch) error {
+ if !branch.IsTrackingRemote() {
+ return self.c.ErrorMsg(self.c.Tr.FwdNoUpstream)
+ }
+ if !branch.RemoteBranchStoredLocally() {
+ return self.c.ErrorMsg(self.c.Tr.FwdNoLocalUpstream)
+ }
+ if branch.HasCommitsToPush() {
+ return self.c.ErrorMsg(self.c.Tr.FwdCommitsToPush)
+ }
+
+ action := self.c.Tr.Actions.FastForwardBranch
+
+ message := utils.ResolvePlaceholderString(
+ self.c.Tr.Fetching,
+ map[string]string{
+ "from": fmt.Sprintf("%s/%s", branch.UpstreamRemote, branch.UpstreamBranch),
+ "to": branch.Name,
+ },
+ )
+
+ return self.c.WithLoaderPanel(message, func() error {
+ if branch == self.helpers.Refs.GetCheckedOutRef() {
+ self.c.LogAction(action)
+
+ err := self.git.Sync.Pull(
+ git_commands.PullOptions{
+ RemoteName: branch.UpstreamRemote,
+ BranchName: branch.UpstreamBranch,
+ FastForwardOnly: true,
+ },
+ )
+ if err != nil {
+ _ = self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+ } else {
+ self.c.LogAction(action)
+ err := self.git.Sync.FastForward(branch.Name, branch.UpstreamRemote, branch.UpstreamBranch)
+ if err != nil {
+ _ = self.c.Error(err)
+ }
+ _ = self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}})
+ }
+
+ return nil
+ })
+}
+
+func (self *BranchesController) createResetMenu(selectedBranch *models.Branch) error {
+ return self.helpers.Refs.CreateGitResetMenu(selectedBranch.Name)
+}
+
+func (self *BranchesController) rename(branch *models.Branch) error {
+ promptForNewName := func() error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.NewBranchNamePrompt + " " + branch.Name + ":",
+ InitialContent: branch.Name,
+ HandleConfirm: func(newBranchName string) error {
+ self.c.LogAction(self.c.Tr.Actions.RenameBranch)
+ if err := self.git.Branch.Rename(branch.Name, newBranchName); err != nil {
+ return self.c.Error(err)
+ }
+
+ // need to find where the branch is now so that we can re-select it. That means we need to refetch the branches synchronously and then find our branch
+ _ = self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}})
+
+ // now that we've got our stuff again we need to find that branch and reselect it.
+ for i, newBranch := range self.model.Branches {
+ if newBranch.Name == newBranchName {
+ self.context().SetSelectedLineIdx(i)
+ if err := self.context().HandleRender(); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+ },
+ })
+ }
+
+ // I could do an explicit check here for whether the branch is tracking a remote branch
+ // but if we've selected it we'll already know that via Pullables and Pullables.
+ // Bit of a hack but I'm lazy.
+ if !branch.IsTrackingRemote() {
+ return promptForNewName()
+ }
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.LcRenameBranch,
+ Prompt: self.c.Tr.RenameBranchWarning,
+ HandleConfirm: promptForNewName,
+ })
+}
+
+func (self *BranchesController) newBranch(selectedBranch *models.Branch) error {
+ return self.helpers.Refs.NewBranch(selectedBranch.RefName(), selectedBranch.RefName(), "")
+}
+
+func (self *BranchesController) createPullRequestMenu(selectedBranch *models.Branch, checkedOutBranch *models.Branch) error {
+ menuItems := make([]*types.MenuItem, 0, 4)
+
+ fromToLabelColumns := func(from string, to string) []string {
+ return []string{fmt.Sprintf("%s → %s", from, to)}
+ }
+
+ menuItemsForBranch := func(branch *models.Branch) []*types.MenuItem {
+ return []*types.MenuItem{
+ {
+ LabelColumns: fromToLabelColumns(branch.Name, self.c.Tr.LcDefaultBranch),
+ OnPress: func() error {
+ return self.createPullRequest(branch.Name, "")
+ },
+ },
+ {
+ LabelColumns: fromToLabelColumns(branch.Name, self.c.Tr.LcSelectBranch),
+ OnPress: func() error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: branch.Name + " →",
+ FindSuggestionsFunc: self.helpers.Suggestions.GetBranchNameSuggestionsFunc(),
+ HandleConfirm: func(targetBranchName string) error {
+ return self.createPullRequest(branch.Name, targetBranchName)
+ },
+ })
+ },
+ },
+ }
+ }
+
+ if selectedBranch != checkedOutBranch {
+ menuItems = append(menuItems,
+ &types.MenuItem{
+ LabelColumns: fromToLabelColumns(checkedOutBranch.Name, selectedBranch.Name),
+ OnPress: func() error {
+ return self.createPullRequest(checkedOutBranch.Name, selectedBranch.Name)
+ },
+ },
+ )
+ menuItems = append(menuItems, menuItemsForBranch(checkedOutBranch)...)
+ }
+
+ menuItems = append(menuItems, menuItemsForBranch(selectedBranch)...)
+
+ return self.c.Menu(types.CreateMenuOptions{Title: fmt.Sprintf(self.c.Tr.CreatePullRequestOptions), Items: menuItems})
+}
+
+func (self *BranchesController) createPullRequest(from string, to string) error {
+ url, err := self.helpers.Host.GetPullRequestURL(from, to)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
+
+ if err := self.os.OpenLink(url); err != nil {
+ return self.c.Error(err)
+ }
+
+ return nil
+}
+
+func (self *BranchesController) checkSelected(callback func(*models.Branch) error) func() error {
+ return func() error {
+ selectedItem := self.context().GetSelected()
+ if selectedItem == nil {
+ return nil
+ }
+
+ return callback(selectedItem)
+ }
+}
+
+func (self *BranchesController) checkSelectedAndReal(callback func(*models.Branch) error) func() error {
+ return func() error {
+ selectedItem := self.context().GetSelected()
+ if selectedItem == nil || !selectedItem.IsRealBranch() {
+ return nil
+ }
+
+ return callback(selectedItem)
+ }
+}
diff --git a/pkg/gui/controllers/commit_message_controller.go b/pkg/gui/controllers/commit_message_controller.go
new file mode 100644
index 000000000..e5cdb866d
--- /dev/null
+++ b/pkg/gui/controllers/commit_message_controller.go
@@ -0,0 +1,79 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type CommitMessageController struct {
+ baseController
+ *controllerCommon
+
+ getCommitMessage func() string
+ onCommitAttempt func(message string)
+ onCommitSuccess func()
+}
+
+var _ types.IController = &CommitMessageController{}
+
+func NewCommitMessageController(
+ common *controllerCommon,
+ getCommitMessage func() string,
+ onCommitAttempt func(message string),
+ onCommitSuccess func(),
+) *CommitMessageController {
+ return &CommitMessageController{
+ baseController: baseController{},
+ controllerCommon: common,
+
+ getCommitMessage: getCommitMessage,
+ onCommitAttempt: onCommitAttempt,
+ onCommitSuccess: onCommitSuccess,
+ }
+}
+
+func (self *CommitMessageController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.SubmitEditorText),
+ Handler: self.confirm,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Return),
+ Handler: self.close,
+ },
+ }
+
+ return bindings
+}
+
+func (self *CommitMessageController) Context() types.Context {
+ return self.context()
+}
+
+// this method is pointless in this context but I'm keeping it consistent
+// with other contexts so that when generics arrive it's easier to refactor
+func (self *CommitMessageController) context() types.Context {
+ return self.contexts.CommitMessage
+}
+
+func (self *CommitMessageController) confirm() error {
+ message := self.getCommitMessage()
+ self.onCommitAttempt(message)
+
+ if message == "" {
+ return self.c.ErrorMsg(self.c.Tr.CommitWithoutMessageErr)
+ }
+
+ cmdObj := self.git.Commit.CommitCmdObj(message)
+ self.c.LogAction(self.c.Tr.Actions.Commit)
+
+ _ = self.c.PopContext()
+ return self.helpers.GPG.WithGpgHandling(cmdObj, self.c.Tr.CommittingStatus, func() error {
+ self.onCommitSuccess()
+ return nil
+ })
+}
+
+func (self *CommitMessageController) close() error {
+ return self.c.PopContext()
+}
diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go
new file mode 100644
index 000000000..f11005cd4
--- /dev/null
+++ b/pkg/gui/controllers/commits_files_controller.go
@@ -0,0 +1,270 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/gocui"
+ "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/filetree"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type CommitFilesController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &CommitFilesController{}
+
+func NewCommitFilesController(
+ common *controllerCommon,
+) *CommitFilesController {
+ return &CommitFilesController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.CommitFiles.CheckoutCommitFile),
+ Handler: self.checkSelected(self.checkout),
+ Description: self.c.Tr.LcCheckoutCommitFile,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Remove),
+ Handler: self.checkSelected(self.discard),
+ Description: self.c.Tr.LcDiscardOldFileChange,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.OpenFile),
+ Handler: self.checkSelected(self.open),
+ Description: self.c.Tr.LcOpenFile,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Edit),
+ Handler: self.checkSelected(self.edit),
+ Description: self.c.Tr.LcEditFile,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Select),
+ Handler: self.checkSelected(self.toggleForPatch),
+ Description: self.c.Tr.LcToggleAddToPatch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.ToggleStagedAll),
+ Handler: self.checkSelected(self.toggleAllForPatch),
+ Description: self.c.Tr.LcToggleAllInPatch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.GoInto),
+ Handler: self.checkSelected(self.enter),
+ Description: self.c.Tr.LcEnterFile,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.ToggleTreeView),
+ Handler: self.toggleTreeView,
+ Description: self.c.Tr.LcToggleTreeView,
+ },
+ }
+
+ return bindings
+}
+
+func (self *CommitFilesController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
+ return []*gocui.ViewMouseBinding{
+ {
+ ViewName: "patchBuilding",
+ Key: gocui.MouseLeft,
+ Handler: self.onClickMain,
+ FocusedView: self.context().GetViewName(),
+ },
+ }
+}
+
+func (self *CommitFilesController) checkSelected(callback func(*filetree.CommitFileNode) error) func() error {
+ return func() error {
+ selected := self.context().GetSelected()
+ if selected == nil {
+ return nil
+ }
+
+ return callback(selected)
+ }
+}
+
+func (self *CommitFilesController) Context() types.Context {
+ return self.context()
+}
+
+func (self *CommitFilesController) context() *context.CommitFilesContext {
+ return self.contexts.CommitFiles
+}
+
+func (self *CommitFilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
+ node := self.context().GetSelected()
+ if node == nil {
+ return nil
+ }
+ return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y})
+}
+
+func (self *CommitFilesController) checkout(node *filetree.CommitFileNode) error {
+ self.c.LogAction(self.c.Tr.Actions.CheckoutFile)
+ if err := self.git.WorkingTree.CheckoutFile(self.context().GetRef().RefName(), node.GetPath()); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+}
+
+func (self *CommitFilesController) discard(node *filetree.CommitFileNode) error {
+ if ok, err := self.helpers.PatchBuilding.ValidateNormalWorkingTreeState(); !ok {
+ return err
+ }
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.DiscardFileChangesTitle,
+ Prompt: self.c.Tr.DiscardFileChangesPrompt,
+ HandleConfirm: func() error {
+ return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.DiscardOldFileChange)
+ if err := self.git.Rebase.DiscardOldFileChanges(self.model.Commits, self.contexts.LocalCommits.GetSelectedLineIdx(), node.GetPath()); err != nil {
+ if err := self.helpers.MergeAndRebase.CheckMergeOrRebase(err); err != nil {
+ return err
+ }
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI})
+ })
+ },
+ })
+}
+
+func (self *CommitFilesController) open(node *filetree.CommitFileNode) error {
+ return self.helpers.Files.OpenFile(node.GetPath())
+}
+
+func (self *CommitFilesController) edit(node *filetree.CommitFileNode) error {
+ if node.File == nil {
+ return self.c.ErrorMsg(self.c.Tr.ErrCannotEditDirectory)
+ }
+
+ return self.helpers.Files.EditFile(node.GetPath())
+}
+
+func (self *CommitFilesController) toggleForPatch(node *filetree.CommitFileNode) error {
+ toggle := func() error {
+ return self.c.WithWaitingStatus(self.c.Tr.LcUpdatingPatch, func() error {
+ if !self.git.Patch.PatchManager.Active() {
+ if err := self.startPatchManager(); err != nil {
+ return err
+ }
+ }
+
+ // if there is any file that hasn't been fully added we'll fully add everything,
+ // otherwise we'll remove everything
+ adding := node.SomeFile(func(file *models.CommitFile) bool {
+ return self.git.Patch.PatchManager.GetFileStatus(file.Name, self.context().GetRef().RefName()) != patch.WHOLE
+ })
+
+ err := node.ForEachFile(func(file *models.CommitFile) error {
+ if adding {
+ return self.git.Patch.PatchManager.AddFileWhole(file.Name)
+ } else {
+ return self.git.Patch.PatchManager.RemoveFile(file.Name)
+ }
+ })
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ if self.git.Patch.PatchManager.IsEmpty() {
+ self.git.Patch.PatchManager.Reset()
+ }
+
+ return self.c.PostRefreshUpdate(self.context())
+ })
+ }
+
+ if self.git.Patch.PatchManager.Active() && self.git.Patch.PatchManager.To != self.context().GetRef().RefName() {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.DiscardPatch,
+ Prompt: self.c.Tr.DiscardPatchConfirm,
+ HandleConfirm: func() error {
+ self.git.Patch.PatchManager.Reset()
+ return toggle()
+ },
+ })
+ }
+
+ return toggle()
+}
+
+func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode) error {
+ root := self.context().CommitFileTreeViewModel.GetRoot()
+ return self.toggleForPatch(root)
+}
+
+func (self *CommitFilesController) startPatchManager() error {
+ commitFilesContext := self.context()
+
+ canRebase := commitFilesContext.GetCanRebase()
+ ref := commitFilesContext.GetRef()
+ to := ref.RefName()
+ from, reverse := self.modes.Diffing.GetFromAndReverseArgsForDiff(ref.ParentRefName())
+
+ self.git.Patch.PatchManager.Start(from, to, reverse, canRebase)
+ return nil
+}
+
+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)
+ }
+
+ enterTheFile := func() error {
+ if !self.git.Patch.PatchManager.Active() {
+ if err := self.startPatchManager(); err != nil {
+ return err
+ }
+ }
+
+ return self.c.PushContext(self.contexts.CustomPatchBuilder, opts)
+ }
+
+ if self.git.Patch.PatchManager.Active() && self.git.Patch.PatchManager.To != self.context().GetRef().RefName() {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.DiscardPatch,
+ Prompt: self.c.Tr.DiscardPatchConfirm,
+ HandleConfirm: func() error {
+ self.git.Patch.PatchManager.Reset()
+ return enterTheFile()
+ },
+ })
+ }
+
+ return enterTheFile()
+}
+
+func (self *CommitFilesController) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) error {
+ self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetPath())
+
+ if err := self.c.PostRefreshUpdate(self.context()); err != nil {
+ self.c.Log.Error(err)
+ }
+
+ return nil
+}
+
+// NOTE: this is very similar to handleToggleFileTreeView, could be DRY'd with generics
+func (self *CommitFilesController) toggleTreeView() error {
+ self.context().CommitFileTreeViewModel.ToggleShowTree()
+
+ return self.c.PostRefreshUpdate(self.context())
+}
diff --git a/pkg/gui/controllers/common.go b/pkg/gui/controllers/common.go
new file mode 100644
index 000000000..12a3788fd
--- /dev/null
+++ b/pkg/gui/controllers/common.go
@@ -0,0 +1,42 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type controllerCommon struct {
+ c *types.HelperCommon
+ os *oscommands.OSCommand
+ git *commands.GitCommand
+ helpers *helpers.Helpers
+ model *types.Model
+ contexts *context.ContextTree
+ modes *types.Modes
+ mutexes *types.Mutexes
+}
+
+func NewControllerCommon(
+ c *types.HelperCommon,
+ os *oscommands.OSCommand,
+ git *commands.GitCommand,
+ helpers *helpers.Helpers,
+ model *types.Model,
+ contexts *context.ContextTree,
+ modes *types.Modes,
+ mutexes *types.Mutexes,
+) *controllerCommon {
+ return &controllerCommon{
+ c: c,
+ os: os,
+ git: git,
+ helpers: helpers,
+ model: model,
+ contexts: contexts,
+ modes: modes,
+ mutexes: mutexes,
+ }
+}
diff --git a/pkg/gui/controllers/context_lines_controller.go b/pkg/gui/controllers/context_lines_controller.go
new file mode 100644
index 000000000..c90bd9c9f
--- /dev/null
+++ b/pkg/gui/controllers/context_lines_controller.go
@@ -0,0 +1,116 @@
+package controllers
+
+import (
+ "errors"
+
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/samber/lo"
+)
+
+// This controller lets you change the context size for diffs. The 'context' in 'context size' refers to the conventional meaning of the word 'context' in a diff, as opposed to lazygit's own idea of a 'context'.
+
+var CONTEXT_KEYS_SHOWING_DIFFS = []types.ContextKey{
+ context.FILES_CONTEXT_KEY,
+ context.COMMIT_FILES_CONTEXT_KEY,
+ context.STASH_CONTEXT_KEY,
+ context.LOCAL_COMMITS_CONTEXT_KEY,
+ context.SUB_COMMITS_CONTEXT_KEY,
+ context.STAGING_MAIN_CONTEXT_KEY,
+ context.STAGING_SECONDARY_CONTEXT_KEY,
+ context.PATCH_BUILDING_MAIN_CONTEXT_KEY,
+ context.PATCH_BUILDING_SECONDARY_CONTEXT_KEY,
+}
+
+type ContextLinesController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &ContextLinesController{}
+
+func NewContextLinesController(
+ common *controllerCommon,
+) *ContextLinesController {
+ return &ContextLinesController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *ContextLinesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.IncreaseContextInDiffView),
+ Handler: self.Increase,
+ Description: self.c.Tr.IncreaseContextInDiffView,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.DecreaseContextInDiffView),
+ Handler: self.Decrease,
+ Description: self.c.Tr.DecreaseContextInDiffView,
+ },
+ }
+
+ return bindings
+}
+
+func (self *ContextLinesController) Context() types.Context {
+ return nil
+}
+
+func (self *ContextLinesController) Increase() error {
+ if self.isShowingDiff() {
+ if err := self.checkCanChangeContext(); err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.UserConfig.Git.DiffContextSize = self.c.UserConfig.Git.DiffContextSize + 1
+ return self.applyChange()
+ }
+
+ return nil
+}
+
+func (self *ContextLinesController) Decrease() error {
+ old_size := self.c.UserConfig.Git.DiffContextSize
+
+ if self.isShowingDiff() && old_size > 1 {
+ if err := self.checkCanChangeContext(); err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.UserConfig.Git.DiffContextSize = old_size - 1
+ return self.applyChange()
+ }
+
+ return nil
+}
+
+func (self *ContextLinesController) applyChange() error {
+ currentContext := self.c.CurrentStaticContext()
+ 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:
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.PATCH_BUILDING}})
+ case context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY:
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STAGING}})
+ default:
+ return currentContext.HandleRenderToMain()
+ }
+}
+
+func (self *ContextLinesController) checkCanChangeContext() error {
+ if self.git.Patch.PatchManager.Active() {
+ return errors.New(self.c.Tr.CantChangeContextSizeError)
+ }
+
+ return nil
+}
+
+func (self *ContextLinesController) isShowingDiff() bool {
+ return lo.Contains(
+ CONTEXT_KEYS_SHOWING_DIFFS,
+ self.c.CurrentStaticContext().GetKey(),
+ )
+}
diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go
new file mode 100644
index 000000000..8f0b8333f
--- /dev/null
+++ b/pkg/gui/controllers/files_controller.go
@@ -0,0 +1,874 @@
+package controllers
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/git_commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/filetree"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type FilesController struct {
+ baseController
+ *controllerCommon
+
+ enterSubmodule func(submodule *models.SubmoduleConfig) error
+ setCommitMessage func(message string)
+ getSavedCommitMessage func() string
+}
+
+var _ types.IController = &FilesController{}
+
+func NewFilesController(
+ common *controllerCommon,
+ enterSubmodule func(submodule *models.SubmoduleConfig) error,
+ setCommitMessage func(message string),
+ getSavedCommitMessage func() string,
+) *FilesController {
+ return &FilesController{
+ controllerCommon: common,
+ enterSubmodule: enterSubmodule,
+ setCommitMessage: setCommitMessage,
+ getSavedCommitMessage: getSavedCommitMessage,
+ }
+}
+
+func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ return []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.Select),
+ Handler: self.checkSelectedFileNode(self.press),
+ Description: self.c.Tr.LcToggleStaged,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.OpenStatusFilter),
+ Handler: self.handleStatusFilterPressed,
+ Description: self.c.Tr.LcFileFilter,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.CommitChanges),
+ Handler: self.HandleCommitPress,
+ Description: self.c.Tr.CommitChanges,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.CommitChangesWithoutHook),
+ Handler: self.HandleWIPCommitPress,
+ Description: self.c.Tr.LcCommitChangesWithoutHook,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.AmendLastCommit),
+ Handler: self.handleAmendCommitPress,
+ Description: self.c.Tr.AmendLastCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.CommitChangesWithEditor),
+ Handler: self.HandleCommitEditorPress,
+ Description: self.c.Tr.CommitChangesWithEditor,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Edit),
+ Handler: self.checkSelectedFileNode(self.edit),
+ Description: self.c.Tr.LcEditFile,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.OpenFile),
+ Handler: self.Open,
+ Description: self.c.Tr.LcOpenFile,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.IgnoreOrExcludeFile),
+ Handler: self.checkSelectedFileNode(self.ignoreOrExcludeMenu),
+ Description: self.c.Tr.Actions.LcIgnoreExcludeFile,
+ OpensMenu: true,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.RefreshFiles),
+ Handler: self.refresh,
+ Description: self.c.Tr.LcRefreshFiles,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.StashAllChanges),
+ Handler: self.stash,
+ Description: self.c.Tr.LcStashAllChanges,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.ViewStashOptions),
+ Handler: self.createStashMenu,
+ Description: self.c.Tr.LcViewStashOptions,
+ OpensMenu: true,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.ToggleStagedAll),
+ Handler: self.toggleStagedAll,
+ Description: self.c.Tr.LcToggleStagedAll,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.GoInto),
+ Handler: self.enter,
+ Description: self.c.Tr.FileEnter,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.ViewResetOptions),
+ Handler: self.createResetToUpstreamMenu,
+ Description: self.c.Tr.LcViewResetToUpstreamOptions,
+ OpensMenu: true,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.ViewResetOptions),
+ Handler: self.createResetMenu,
+ Description: self.c.Tr.LcViewResetOptions,
+ OpensMenu: true,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.ToggleTreeView),
+ Handler: self.toggleTreeView,
+ Description: self.c.Tr.LcToggleTreeView,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.OpenMergeTool),
+ Handler: self.helpers.WorkingTree.OpenMergeTool,
+ Description: self.c.Tr.LcOpenMergeTool,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.Fetch),
+ Handler: self.fetch,
+ Description: self.c.Tr.LcFetch,
+ },
+ }
+}
+
+func (self *FilesController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
+ return []*gocui.ViewMouseBinding{
+ {
+ ViewName: "main",
+ Key: gocui.MouseLeft,
+ Handler: self.onClickMain,
+ FocusedView: self.context().GetViewName(),
+ },
+ {
+ ViewName: "patchBuilding",
+ Key: gocui.MouseLeft,
+ Handler: self.onClickMain,
+ FocusedView: self.context().GetViewName(),
+ },
+ {
+ ViewName: "mergeConflicts",
+ Key: gocui.MouseLeft,
+ Handler: self.onClickMain,
+ FocusedView: self.context().GetViewName(),
+ },
+ {
+ ViewName: "secondary",
+ Key: gocui.MouseLeft,
+ Handler: self.onClickSecondary,
+ FocusedView: self.context().GetViewName(),
+ },
+ {
+ ViewName: "patchBuildingSecondary",
+ Key: gocui.MouseLeft,
+ Handler: self.onClickSecondary,
+ FocusedView: self.context().GetViewName(),
+ },
+ }
+}
+
+func (self *FilesController) GetOnClick() func() error {
+ return self.checkSelectedFileNode(self.press)
+}
+
+// if we are dealing with a status for which there is no key in this map,
+// then we won't optimistically render: we'll just let `git status` tell
+// us what the new status is.
+// There are no doubt more entries that could be added to these two maps.
+var stageStatusMap = map[string]string{
+ "??": "A ",
+ " M": "M ",
+ "MM": "M ",
+ " D": "D ",
+ " A": "A ",
+ "AM": "A ",
+ "MD": "D ",
+}
+
+var unstageStatusMap = map[string]string{
+ "A ": "??",
+ "M ": " M",
+ "D ": " D",
+}
+
+func (self *FilesController) optimisticStage(file *models.File) bool {
+ newShortStatus, ok := stageStatusMap[file.ShortStatus]
+ if !ok {
+ return false
+ }
+
+ models.SetStatusFields(file, newShortStatus)
+ return true
+}
+
+func (self *FilesController) optimisticUnstage(file *models.File) bool {
+ newShortStatus, ok := unstageStatusMap[file.ShortStatus]
+ if !ok {
+ return false
+ }
+
+ models.SetStatusFields(file, newShortStatus)
+ return true
+}
+
+// Running a git add command followed by a git status command can take some time (e.g. 200ms).
+// Given how often users stage/unstage files in Lazygit, we're adding some
+// optimistic rendering to make things feel faster. When we go to stage
+// a file, we'll first update that file's status in-memory, then re-render
+// the files panel. Then we'll immediately do a proper git status call
+// so that if the optimistic rendering got something wrong, it's quickly
+// corrected.
+func (self *FilesController) optimisticChange(node *filetree.FileNode, optimisticChangeFn func(*models.File) bool) error {
+ rerender := false
+ err := node.ForEachFile(func(f *models.File) error {
+ // can't act on the file itself: we need to update the original model file
+ for _, modelFile := range self.model.Files {
+ if modelFile.Name == f.Name {
+ if optimisticChangeFn(modelFile) {
+ rerender = true
+ }
+ break
+ }
+ }
+
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ if rerender {
+ if err := self.c.PostRefreshUpdate(self.contexts.Files); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (self *FilesController) pressWithLock(node *filetree.FileNode) error {
+ // Obtaining this lock because optimistic rendering requires us to mutate
+ // the files in our model.
+ self.mutexes.RefreshingFilesMutex.Lock()
+ defer self.mutexes.RefreshingFilesMutex.Unlock()
+
+ if node.IsFile() {
+ file := node.File
+
+ if file.HasUnstagedChanges {
+ self.c.LogAction(self.c.Tr.Actions.StageFile)
+
+ if err := self.optimisticChange(node, self.optimisticStage); err != nil {
+ return err
+ }
+
+ if err := self.git.WorkingTree.StageFile(file.Name); err != nil {
+ return self.c.Error(err)
+ }
+ } else {
+ self.c.LogAction(self.c.Tr.Actions.UnstageFile)
+
+ if err := self.optimisticChange(node, self.optimisticUnstage); err != nil {
+ return err
+ }
+
+ if err := self.git.WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
+ return self.c.Error(err)
+ }
+ }
+ } else {
+ // if any files within have inline merge conflicts we can't stage or unstage,
+ // or it'll end up with those >>>>>> lines actually staged
+ if node.GetHasInlineMergeConflicts() {
+ return self.c.ErrorMsg(self.c.Tr.ErrStageDirWithInlineMergeConflicts)
+ }
+
+ if node.GetHasUnstagedChanges() {
+ self.c.LogAction(self.c.Tr.Actions.StageFile)
+
+ if err := self.optimisticChange(node, self.optimisticStage); err != nil {
+ return err
+ }
+
+ if err := self.git.WorkingTree.StageFile(node.Path); err != nil {
+ return self.c.Error(err)
+ }
+ } else {
+ self.c.LogAction(self.c.Tr.Actions.UnstageFile)
+
+ if err := self.optimisticChange(node, self.optimisticUnstage); err != nil {
+ return err
+ }
+
+ // pretty sure it doesn't matter that we're always passing true here
+ if err := self.git.WorkingTree.UnStageFile([]string{node.Path}, true); err != nil {
+ return self.c.Error(err)
+ }
+ }
+ }
+
+ return nil
+}
+
+func (self *FilesController) press(node *filetree.FileNode) error {
+ if node.IsFile() && node.File.HasInlineMergeConflicts {
+ return self.switchToMerge()
+ }
+
+ if err := self.pressWithLock(node); err != nil {
+ return err
+ }
+
+ if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}); err != nil {
+ return err
+ }
+
+ return self.context().HandleFocus(types.OnFocusOpts{})
+}
+
+func (self *FilesController) checkSelectedFileNode(callback func(*filetree.FileNode) error) func() error {
+ return func() error {
+ node := self.context().GetSelected()
+ if node == nil {
+ return nil
+ }
+
+ return callback(node)
+ }
+}
+
+func (self *FilesController) Context() types.Context {
+ return self.context()
+}
+
+func (self *FilesController) context() *context.WorkingTreeContext {
+ return self.contexts.Files
+}
+
+func (self *FilesController) getSelectedFile() *models.File {
+ node := self.context().GetSelected()
+ if node == nil {
+ return nil
+ }
+ return node.File
+}
+
+func (self *FilesController) enter() error {
+ return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
+}
+
+func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
+ node := self.context().GetSelected()
+ if node == nil {
+ return nil
+ }
+
+ if node.File == nil {
+ return self.handleToggleDirCollapsed()
+ }
+
+ file := node.File
+
+ submoduleConfigs := self.model.Submodules
+ if file.IsSubmodule(submoduleConfigs) {
+ submoduleConfig := file.SubmoduleConfig(submoduleConfigs)
+ return self.enterSubmodule(submoduleConfig)
+ }
+
+ if file.HasInlineMergeConflicts {
+ return self.switchToMerge()
+ }
+ if file.HasMergeConflicts {
+ return self.c.ErrorMsg(self.c.Tr.FileStagingRequirements)
+ }
+
+ return self.c.PushContext(self.contexts.Staging, opts)
+}
+
+func (self *FilesController) toggleStagedAll() error {
+ if err := self.toggleStagedAllWithLock(); err != nil {
+ return err
+ }
+
+ if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}); err != nil {
+ return err
+ }
+
+ return self.context().HandleFocus(types.OnFocusOpts{})
+}
+
+func (self *FilesController) toggleStagedAllWithLock() error {
+ self.mutexes.RefreshingFilesMutex.Lock()
+ defer self.mutexes.RefreshingFilesMutex.Unlock()
+
+ root := self.context().FileTreeViewModel.GetRoot()
+
+ // if any files within have inline merge conflicts we can't stage or unstage,
+ // or it'll end up with those >>>>>> lines actually staged
+ if root.GetHasInlineMergeConflicts() {
+ return self.c.ErrorMsg(self.c.Tr.ErrStageDirWithInlineMergeConflicts)
+ }
+
+ if root.GetHasUnstagedChanges() {
+ self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
+
+ if err := self.optimisticChange(root, self.optimisticStage); err != nil {
+ return err
+ }
+
+ if err := self.git.WorkingTree.StageAll(); err != nil {
+ return self.c.Error(err)
+ }
+ } else {
+ self.c.LogAction(self.c.Tr.Actions.UnstageAllFiles)
+
+ if err := self.optimisticChange(root, self.optimisticUnstage); err != nil {
+ return err
+ }
+
+ if err := self.git.WorkingTree.UnstageAll(); err != nil {
+ return self.c.Error(err)
+ }
+ }
+
+ return nil
+}
+
+func (self *FilesController) unstageFiles(node *filetree.FileNode) error {
+ return node.ForEachFile(func(file *models.File) error {
+ if file.HasStagedChanges {
+ if err := self.git.WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+}
+
+func (self *FilesController) ignoreOrExcludeTracked(node *filetree.FileNode, trAction string, f func(string) error) error {
+ self.c.LogAction(trAction)
+ // not 100% sure if this is necessary but I'll assume it is
+ if err := self.unstageFiles(node); err != nil {
+ return err
+ }
+
+ if err := self.git.WorkingTree.RemoveTrackedFiles(node.GetPath()); err != nil {
+ return err
+ }
+
+ if err := f(node.GetPath()); err != nil {
+ return err
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
+}
+
+func (self *FilesController) ignoreOrExcludeUntracked(node *filetree.FileNode, trAction string, f func(string) error) error {
+ self.c.LogAction(trAction)
+
+ if err := f(node.GetPath()); err != nil {
+ return err
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
+}
+
+func (self *FilesController) ignoreOrExcludeFile(node *filetree.FileNode, trText string, trPrompt string, trAction string, f func(string) error) error {
+ if node.GetIsTracked() {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: trText,
+ Prompt: trPrompt,
+ HandleConfirm: func() error {
+ return self.ignoreOrExcludeTracked(node, trAction, f)
+ },
+ })
+ }
+ return self.ignoreOrExcludeUntracked(node, trAction, f)
+}
+
+func (self *FilesController) ignore(node *filetree.FileNode) error {
+ if node.GetPath() == ".gitignore" {
+ return self.c.ErrorMsg(self.c.Tr.Actions.IgnoreFileErr)
+ }
+ err := self.ignoreOrExcludeFile(node, self.c.Tr.IgnoreTracked, self.c.Tr.IgnoreTrackedPrompt, self.c.Tr.Actions.LcIgnoreExcludeFile, self.git.WorkingTree.Ignore)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (self *FilesController) exclude(node *filetree.FileNode) error {
+ if node.GetPath() == ".git/info/exclude" {
+ return self.c.ErrorMsg(self.c.Tr.Actions.ExcludeFileErr)
+ }
+
+ if node.GetPath() == ".gitignore" {
+ return self.c.ErrorMsg(self.c.Tr.Actions.ExcludeGitIgnoreErr)
+ }
+
+ err := self.ignoreOrExcludeFile(node, self.c.Tr.ExcludeTracked, self.c.Tr.ExcludeTrackedPrompt, self.c.Tr.Actions.ExcludeFile, self.git.WorkingTree.Exclude)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error {
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: self.c.Tr.Actions.LcIgnoreExcludeFile,
+ Items: []*types.MenuItem{
+ {
+ LabelColumns: []string{self.c.Tr.LcIgnoreFile},
+ OnPress: func() error {
+ if err := self.ignore(node); err != nil {
+ return self.c.Error(err)
+ }
+ return nil
+ },
+ Key: 'i',
+ },
+ {
+ LabelColumns: []string{self.c.Tr.LcExcludeFile},
+ OnPress: func() error {
+ if err := self.exclude(node); err != nil {
+ return self.c.Error(err)
+ }
+ return nil
+ },
+ Key: 'e',
+ },
+ },
+ })
+}
+
+func (self *FilesController) HandleWIPCommitPress() error {
+ skipHookPrefix := self.c.UserConfig.Git.SkipHookPrefix
+ if skipHookPrefix == "" {
+ return self.c.ErrorMsg(self.c.Tr.SkipHookPrefixNotConfigured)
+ }
+
+ self.setCommitMessage(skipHookPrefix)
+
+ return self.HandleCommitPress()
+}
+
+func (self *FilesController) commitPrefixConfigForRepo() *config.CommitPrefixConfig {
+ cfg, ok := self.c.UserConfig.Git.CommitPrefixes[utils.GetCurrentRepoName()]
+ if !ok {
+ return nil
+ }
+
+ return &cfg
+}
+
+func (self *FilesController) prepareFilesForCommit() error {
+ noStagedFiles := !self.helpers.WorkingTree.AnyStagedFiles()
+ if noStagedFiles && self.c.UserConfig.Gui.SkipNoStagedFilesWarning {
+ self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
+ err := self.git.WorkingTree.StageAll()
+ if err != nil {
+ return err
+ }
+
+ return self.syncRefresh()
+ }
+
+ return nil
+}
+
+// for when you need to refetch files before continuing an action. Runs synchronously.
+func (self *FilesController) syncRefresh() error {
+ return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
+}
+
+func (self *FilesController) refresh() error {
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
+}
+
+func (self *FilesController) HandleCommitPress() error {
+ if err := self.prepareFilesForCommit(); err != nil {
+ return self.c.Error(err)
+ }
+
+ if len(self.model.Files) == 0 {
+ return self.c.ErrorMsg(self.c.Tr.NoFilesStagedTitle)
+ }
+
+ if !self.helpers.WorkingTree.AnyStagedFiles() {
+ return self.promptToStageAllAndRetry(self.HandleCommitPress)
+ }
+
+ savedCommitMessage := self.getSavedCommitMessage()
+ if len(savedCommitMessage) > 0 {
+ self.setCommitMessage(savedCommitMessage)
+ } else {
+ commitPrefixConfig := self.commitPrefixConfigForRepo()
+ if commitPrefixConfig != nil {
+ prefixPattern := commitPrefixConfig.Pattern
+ prefixReplace := commitPrefixConfig.Replace
+ rgx, err := regexp.Compile(prefixPattern)
+ if err != nil {
+ return self.c.ErrorMsg(fmt.Sprintf("%s: %s", self.c.Tr.LcCommitPrefixPatternError, err.Error()))
+ }
+ prefix := rgx.ReplaceAllString(self.helpers.Refs.GetCheckedOutRef().Name, prefixReplace)
+ self.setCommitMessage(prefix)
+ }
+ }
+
+ if err := self.c.PushContext(self.contexts.CommitMessage); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (self *FilesController) promptToStageAllAndRetry(retry func() error) error {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.NoFilesStagedTitle,
+ Prompt: self.c.Tr.NoFilesStagedPrompt,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
+ if err := self.git.WorkingTree.StageAll(); err != nil {
+ return self.c.Error(err)
+ }
+ if err := self.syncRefresh(); err != nil {
+ return self.c.Error(err)
+ }
+
+ return retry()
+ },
+ })
+}
+
+func (self *FilesController) handleAmendCommitPress() error {
+ if len(self.model.Files) == 0 {
+ return self.c.ErrorMsg(self.c.Tr.NoFilesStagedTitle)
+ }
+
+ if !self.helpers.WorkingTree.AnyStagedFiles() {
+ return self.promptToStageAllAndRetry(self.handleAmendCommitPress)
+ }
+
+ if len(self.model.Commits) == 0 {
+ return self.c.ErrorMsg(self.c.Tr.NoCommitToAmend)
+ }
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.AmendLastCommitTitle,
+ Prompt: self.c.Tr.SureToAmend,
+ HandleConfirm: func() error {
+ cmdObj := self.git.Commit.AmendHeadCmdObj()
+ self.c.LogAction(self.c.Tr.Actions.AmendCommit)
+ return self.helpers.GPG.WithGpgHandling(cmdObj, self.c.Tr.AmendingStatus, nil)
+ },
+ })
+}
+
+// HandleCommitEditorPress - handle when the user wants to commit changes via
+// their editor rather than via the popup panel
+func (self *FilesController) HandleCommitEditorPress() error {
+ if len(self.model.Files) == 0 {
+ return self.c.ErrorMsg(self.c.Tr.NoFilesStagedTitle)
+ }
+
+ if !self.helpers.WorkingTree.AnyStagedFiles() {
+ return self.promptToStageAllAndRetry(self.HandleCommitEditorPress)
+ }
+
+ self.c.LogAction(self.c.Tr.Actions.Commit)
+ return self.c.RunSubprocessAndRefresh(
+ self.git.Commit.CommitEditorCmdObj(),
+ )
+}
+
+func (self *FilesController) handleStatusFilterPressed() error {
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: self.c.Tr.FilteringMenuTitle,
+ Items: []*types.MenuItem{
+ {
+ Label: self.c.Tr.FilterStagedFiles,
+ OnPress: func() error {
+ return self.setStatusFiltering(filetree.DisplayStaged)
+ },
+ },
+ {
+ Label: self.c.Tr.FilterUnstagedFiles,
+ OnPress: func() error {
+ return self.setStatusFiltering(filetree.DisplayUnstaged)
+ },
+ },
+ {
+ Label: self.c.Tr.ResetCommitFilterState,
+ OnPress: func() error {
+ return self.setStatusFiltering(filetree.DisplayAll)
+ },
+ },
+ },
+ })
+}
+
+func (self *FilesController) setStatusFiltering(filter filetree.FileTreeDisplayFilter) error {
+ self.context().FileTreeViewModel.SetFilter(filter)
+ return self.c.PostRefreshUpdate(self.context())
+}
+
+func (self *FilesController) edit(node *filetree.FileNode) error {
+ if node.File == nil {
+ return self.c.ErrorMsg(self.c.Tr.ErrCannotEditDirectory)
+ }
+
+ return self.helpers.Files.EditFile(node.GetPath())
+}
+
+func (self *FilesController) Open() error {
+ node := self.context().GetSelected()
+ if node == nil {
+ return nil
+ }
+
+ return self.helpers.Files.OpenFile(node.GetPath())
+}
+
+func (self *FilesController) switchToMerge() error {
+ file := self.getSelectedFile()
+ if file == nil {
+ return nil
+ }
+
+ return self.helpers.MergeConflicts.SwitchToMerge(file.Name)
+}
+
+func (self *FilesController) createStashMenu() error {
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: self.c.Tr.LcStashOptions,
+ Items: []*types.MenuItem{
+ {
+ Label: self.c.Tr.LcStashAllChanges,
+ OnPress: func() error {
+ return self.handleStashSave(self.git.Stash.Save, self.c.Tr.Actions.StashAllChanges, self.c.Tr.NoFilesToStash)
+ },
+ Key: 'a',
+ },
+ {
+ Label: self.c.Tr.LcStashAllChangesKeepIndex,
+ OnPress: func() error {
+ // if there are no staged files it behaves the same as Stash.Save
+ return self.handleStashSave(self.git.Stash.StashAndKeepIndex, self.c.Tr.Actions.StashAllChangesKeepIndex, self.c.Tr.NoFilesToStash)
+ },
+ Key: 'i',
+ },
+ {
+ Label: self.c.Tr.LcStashStagedChanges,
+ OnPress: func() error {
+ // there must be something in staging otherwise the current implementation mucks the stash up
+ if !self.helpers.WorkingTree.AnyStagedFiles() {
+ return self.c.ErrorMsg(self.c.Tr.NoTrackedStagedFilesStash)
+ }
+ return self.handleStashSave(self.git.Stash.SaveStagedChanges, self.c.Tr.Actions.StashStagedChanges, self.c.Tr.NoTrackedStagedFilesStash)
+ },
+ Key: 's',
+ },
+ {
+ Label: self.c.Tr.LcStashUnstagedChanges,
+ OnPress: func() error {
+ if self.helpers.WorkingTree.AnyStagedFiles() {
+ return self.handleStashSave(self.git.Stash.StashUnstagedChanges, self.c.Tr.Actions.StashUnstagedChanges, self.c.Tr.NoFilesToStash)
+ }
+ // ordinary stash
+ return self.handleStashSave(self.git.Stash.Save, self.c.Tr.Actions.StashUnstagedChanges, self.c.Tr.NoFilesToStash)
+ },
+ Key: 'u',
+ },
+ },
+ })
+}
+
+func (self *FilesController) stash() error {
+ return self.handleStashSave(self.git.Stash.Save, self.c.Tr.Actions.StashAllChanges, self.c.Tr.NoTrackedStagedFilesStash)
+}
+
+func (self *FilesController) createResetToUpstreamMenu() error {
+ return self.helpers.Refs.CreateGitResetMenu("@{upstream}")
+}
+
+func (self *FilesController) handleToggleDirCollapsed() error {
+ node := self.context().GetSelected()
+ if node == nil {
+ return nil
+ }
+
+ self.context().FileTreeViewModel.ToggleCollapsed(node.GetPath())
+
+ if err := self.c.PostRefreshUpdate(self.contexts.Files); err != nil {
+ self.c.Log.Error(err)
+ }
+
+ return nil
+}
+
+func (self *FilesController) toggleTreeView() error {
+ self.context().FileTreeViewModel.ToggleShowTree()
+
+ return self.c.PostRefreshUpdate(self.context())
+}
+
+func (self *FilesController) handleStashSave(stashFunc func(message string) error, action string, errorMsg string) error {
+ if !self.helpers.WorkingTree.IsWorkingTreeDirty() {
+ return self.c.ErrorMsg(errorMsg)
+ }
+
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.StashChanges,
+ HandleConfirm: func(stashComment string) error {
+ self.c.LogAction(action)
+
+ if err := stashFunc(stashComment); err != nil {
+ return self.c.Error(err)
+ }
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}})
+ },
+ })
+}
+
+func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
+ return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y})
+}
+
+func (self *FilesController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error {
+ return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "secondary", ClickedViewLineIdx: opts.Y})
+}
+
+func (self *FilesController) fetch() error {
+ return self.c.WithLoaderPanel(self.c.Tr.FetchWait, func() error {
+ if err := self.fetchAux(); err != nil {
+ _ = self.c.Error(err)
+ }
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+ })
+}
+
+func (self *FilesController) fetchAux() (err error) {
+ self.c.LogAction("Fetch")
+ err = self.git.Sync.Fetch(git_commands.FetchOptions{})
+
+ if err != nil && strings.Contains(err.Error(), "exit status 128") {
+ _ = self.c.ErrorMsg(self.c.Tr.PassUnameWrong)
+ }
+
+ _ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.ASYNC})
+
+ return err
+}
diff --git a/pkg/gui/controllers/files_remove_controller.go b/pkg/gui/controllers/files_remove_controller.go
new file mode 100644
index 000000000..73449f1ec
--- /dev/null
+++ b/pkg/gui/controllers/files_remove_controller.go
@@ -0,0 +1,161 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/filetree"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// splitting this action out into its own file because it's self-contained
+
+type FilesRemoveController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &FilesRemoveController{}
+
+func NewFilesRemoveController(
+ common *controllerCommon,
+) *FilesRemoveController {
+ return &FilesRemoveController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *FilesRemoveController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.Remove),
+ Handler: self.checkSelectedFileNode(self.remove),
+ Description: self.c.Tr.LcViewDiscardOptions,
+ OpensMenu: true,
+ },
+ }
+
+ return bindings
+}
+
+func (self *FilesRemoveController) remove(node *filetree.FileNode) error {
+ var menuItems []*types.MenuItem
+ if node.File == nil {
+ menuItems = []*types.MenuItem{
+ {
+ Label: self.c.Tr.LcDiscardAllChanges,
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.DiscardAllChangesInDirectory)
+ if err := self.git.WorkingTree.DiscardAllDirChanges(node); err != nil {
+ return self.c.Error(err)
+ }
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
+ },
+ Key: 'd',
+ },
+ }
+
+ if node.GetHasStagedChanges() && node.GetHasUnstagedChanges() {
+ menuItems = append(menuItems, &types.MenuItem{
+ Label: self.c.Tr.LcDiscardUnstagedChanges,
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.DiscardUnstagedChangesInDirectory)
+ if err := self.git.WorkingTree.DiscardUnstagedDirChanges(node); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
+ },
+ Key: 'u',
+ })
+ }
+ } else {
+ file := node.File
+
+ submodules := self.model.Submodules
+ if file.IsSubmodule(submodules) {
+ submodule := file.SubmoduleConfig(submodules)
+
+ menuItems = []*types.MenuItem{
+ {
+ Label: self.c.Tr.LcSubmoduleStashAndReset,
+ OnPress: func() error {
+ return self.ResetSubmodule(submodule)
+ },
+ },
+ }
+ } else {
+ menuItems = []*types.MenuItem{
+ {
+ Label: self.c.Tr.LcDiscardAllChanges,
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.DiscardAllChangesInFile)
+ if err := self.git.WorkingTree.DiscardAllFileChanges(file); err != nil {
+ return self.c.Error(err)
+ }
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
+ },
+ Key: 'd',
+ },
+ }
+
+ if file.HasStagedChanges && file.HasUnstagedChanges {
+ menuItems = append(menuItems, &types.MenuItem{
+ Label: self.c.Tr.LcDiscardUnstagedChanges,
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.DiscardAllUnstagedChangesInFile)
+ if err := self.git.WorkingTree.DiscardUnstagedFileChanges(file); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
+ },
+ Key: 'u',
+ })
+ }
+ }
+ }
+
+ return self.c.Menu(types.CreateMenuOptions{Title: node.GetPath(), Items: menuItems})
+}
+
+func (self *FilesRemoveController) ResetSubmodule(submodule *models.SubmoduleConfig) error {
+ return self.c.WithWaitingStatus(self.c.Tr.LcResettingSubmoduleStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.ResetSubmodule)
+
+ file := self.helpers.WorkingTree.FileForSubmodule(submodule)
+ if file != nil {
+ if err := self.git.WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
+ return self.c.Error(err)
+ }
+ }
+
+ if err := self.git.Submodule.Stash(submodule); err != nil {
+ return self.c.Error(err)
+ }
+ if err := self.git.Submodule.Reset(submodule); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}})
+ })
+}
+
+func (self *FilesRemoveController) checkSelectedFileNode(callback func(*filetree.FileNode) error) func() error {
+ return func() error {
+ node := self.context().GetSelected()
+ if node == nil {
+ return nil
+ }
+
+ return callback(node)
+ }
+}
+
+func (self *FilesRemoveController) Context() types.Context {
+ return self.context()
+}
+
+func (self *FilesRemoveController) context() *context.WorkingTreeContext {
+ return self.contexts.Files
+}
diff --git a/pkg/gui/controllers/git_flow_controller.go b/pkg/gui/controllers/git_flow_controller.go
new file mode 100644
index 000000000..2504ad2dd
--- /dev/null
+++ b/pkg/gui/controllers/git_flow_controller.go
@@ -0,0 +1,123 @@
+package controllers
+
+import (
+ "fmt"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type GitFlowController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &GitFlowController{}
+
+func NewGitFlowController(
+ common *controllerCommon,
+) *GitFlowController {
+ return &GitFlowController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *GitFlowController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Branches.ViewGitFlowOptions),
+ Handler: self.checkSelected(self.handleCreateGitFlowMenu),
+ Description: self.c.Tr.LcGitFlowOptions,
+ OpensMenu: true,
+ },
+ }
+
+ return bindings
+}
+
+func (self *GitFlowController) handleCreateGitFlowMenu(branch *models.Branch) error {
+ if !self.git.Flow.GitFlowEnabled() {
+ return self.c.ErrorMsg("You need to install git-flow and enable it in this repo to use git-flow features")
+ }
+
+ startHandler := func(branchType string) func() error {
+ return func() error {
+ title := utils.ResolvePlaceholderString(self.c.Tr.NewGitFlowBranchPrompt, map[string]string{"branchType": branchType})
+
+ return self.c.Prompt(types.PromptOpts{
+ Title: title,
+ HandleConfirm: func(name string) error {
+ self.c.LogAction(self.c.Tr.Actions.GitFlowStart)
+ return self.c.RunSubprocessAndRefresh(
+ self.git.Flow.StartCmdObj(branchType, name),
+ )
+ },
+ })
+ }
+ }
+
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: "git flow",
+ Items: []*types.MenuItem{
+ {
+ // not localising here because it's one to one with the actual git flow commands
+ Label: fmt.Sprintf("finish branch '%s'", branch.Name),
+ OnPress: func() error {
+ return self.gitFlowFinishBranch(branch.Name)
+ },
+ },
+ {
+ Label: "start feature",
+ OnPress: startHandler("feature"),
+ Key: 'f',
+ },
+ {
+ Label: "start hotfix",
+ OnPress: startHandler("hotfix"),
+ Key: 'h',
+ },
+ {
+ Label: "start bugfix",
+ OnPress: startHandler("bugfix"),
+ Key: 'b',
+ },
+ {
+ Label: "start release",
+ OnPress: startHandler("release"),
+ Key: 'r',
+ },
+ },
+ })
+}
+
+func (self *GitFlowController) gitFlowFinishBranch(branchName string) error {
+ cmdObj, err := self.git.Flow.FinishCmdObj(branchName)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.LogAction(self.c.Tr.Actions.GitFlowFinish)
+ return self.c.RunSubprocessAndRefresh(cmdObj)
+}
+
+func (self *GitFlowController) checkSelected(callback func(*models.Branch) error) func() error {
+ return func() error {
+ node := self.context().GetSelected()
+ if node == nil {
+ return nil
+ }
+
+ return callback(node)
+ }
+}
+
+func (self *GitFlowController) Context() types.Context {
+ return self.context()
+}
+
+func (self *GitFlowController) context() *context.BranchesContext {
+ return self.contexts.Branches
+}
diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go
new file mode 100644
index 000000000..e59231739
--- /dev/null
+++ b/pkg/gui/controllers/global_controller.go
@@ -0,0 +1,67 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/samber/lo"
+)
+
+type GlobalController struct {
+ baseController
+ *controllerCommon
+}
+
+func NewGlobalController(
+ common *controllerCommon,
+) *GlobalController {
+ return &GlobalController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ return []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.ExecuteCustomCommand),
+ Handler: self.customCommand,
+ Description: self.c.Tr.LcExecuteCustomCommand,
+ },
+ }
+}
+
+func (self *GlobalController) customCommand() error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.CustomCommand,
+ FindSuggestionsFunc: self.GetCustomCommandsHistorySuggestionsFunc(),
+ HandleConfirm: func(command string) error {
+ self.c.GetAppState().CustomCommandsHistory = utils.Limit(
+ lo.Uniq(append(self.c.GetAppState().CustomCommandsHistory, command)),
+ 1000,
+ )
+
+ err := self.c.SaveAppState()
+ if err != nil {
+ self.c.Log.Error(err)
+ }
+
+ self.c.LogAction(self.c.Tr.Actions.CustomCommand)
+ return self.c.RunSubprocessAndRefresh(
+ self.os.Cmd.NewShell(command),
+ )
+ },
+ })
+}
+
+func (self *GlobalController) GetCustomCommandsHistorySuggestionsFunc() func(string) []*types.Suggestion {
+ // reversing so that we display the latest command first
+ history := slices.Reverse(self.c.GetAppState().CustomCommandsHistory)
+
+ return helpers.FuzzySearchFunc(history)
+}
+
+func (self *GlobalController) Context() types.Context {
+ return nil
+}
diff --git a/pkg/gui/controllers/helpers/bisect_helper.go b/pkg/gui/controllers/helpers/bisect_helper.go
new file mode 100644
index 000000000..65fb781d4
--- /dev/null
+++ b/pkg/gui/controllers/helpers/bisect_helper.go
@@ -0,0 +1,40 @@
+package helpers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type BisectHelper struct {
+ c *types.HelperCommon
+ git *commands.GitCommand
+}
+
+func NewBisectHelper(
+ c *types.HelperCommon,
+ git *commands.GitCommand,
+) *BisectHelper {
+ return &BisectHelper{
+ c: c,
+ git: git,
+ }
+}
+
+func (self *BisectHelper) Reset() error {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.Bisect.ResetTitle,
+ Prompt: self.c.Tr.Bisect.ResetPrompt,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.ResetBisect)
+ if err := self.git.Bisect.Reset(); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.PostBisectCommandRefresh()
+ },
+ })
+}
+
+func (self *BisectHelper) PostBisectCommandRefresh() error {
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{}})
+}
diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go
new file mode 100644
index 000000000..a5c4427a7
--- /dev/null
+++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go
@@ -0,0 +1,155 @@
+package helpers
+
+import (
+ "github.com/jesseduffield/generics/set"
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type CherryPickHelper struct {
+ c *types.HelperCommon
+
+ git *commands.GitCommand
+
+ contexts *context.ContextTree
+ getData func() *cherrypicking.CherryPicking
+
+ rebaseHelper *MergeAndRebaseHelper
+}
+
+// I'm using the analogy of copy+paste in the terminology here because it's intuitively what's going on,
+// even if in truth we're running git cherry-pick
+
+func NewCherryPickHelper(
+ c *types.HelperCommon,
+ git *commands.GitCommand,
+ contexts *context.ContextTree,
+ getData func() *cherrypicking.CherryPicking,
+ rebaseHelper *MergeAndRebaseHelper,
+) *CherryPickHelper {
+ return &CherryPickHelper{
+ c: c,
+ git: git,
+ contexts: contexts,
+ getData: getData,
+ rebaseHelper: rebaseHelper,
+ }
+}
+
+func (self *CherryPickHelper) Copy(commit *models.Commit, commitsList []*models.Commit, context types.Context) error {
+ if err := self.resetIfNecessary(context); err != nil {
+ return err
+ }
+
+ // we will un-copy it if it's already copied
+ for index, cherryPickedCommit := range self.getData().CherryPickedCommits {
+ if commit.Sha == cherryPickedCommit.Sha {
+ self.getData().CherryPickedCommits = append(
+ self.getData().CherryPickedCommits[0:index],
+ self.getData().CherryPickedCommits[index+1:]...,
+ )
+ return self.rerender()
+ }
+ }
+
+ self.add(commit, commitsList)
+ return self.rerender()
+}
+
+func (self *CherryPickHelper) CopyRange(selectedIndex int, commitsList []*models.Commit, context types.Context) error {
+ if err := self.resetIfNecessary(context); err != nil {
+ return err
+ }
+
+ commitSet := self.CherryPickedCommitShaSet()
+
+ // find the last commit that is copied that's above our position
+ // if there are none, startIndex = 0
+ startIndex := 0
+ for index, commit := range commitsList[0:selectedIndex] {
+ if commitSet.Includes(commit.Sha) {
+ startIndex = index
+ }
+ }
+
+ for index := startIndex; index <= selectedIndex; index++ {
+ commit := commitsList[index]
+ self.add(commit, commitsList)
+ }
+
+ return self.rerender()
+}
+
+// HandlePasteCommits begins a cherry-pick rebase with the commits the user has copied.
+// Only to be called from the branch commits controller
+func (self *CherryPickHelper) Paste() error {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.CherryPick,
+ Prompt: self.c.Tr.SureCherryPick,
+ HandleConfirm: func() error {
+ return self.c.WithWaitingStatus(self.c.Tr.CherryPickingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.CherryPick)
+ err := self.git.Rebase.CherryPickCommits(self.getData().CherryPickedCommits)
+ return self.rebaseHelper.CheckMergeOrRebase(err)
+ })
+ },
+ })
+}
+
+func (self *CherryPickHelper) Reset() error {
+ self.getData().ContextKey = ""
+ self.getData().CherryPickedCommits = nil
+
+ return self.rerender()
+}
+
+func (self *CherryPickHelper) CherryPickedCommitShaSet() *set.Set[string] {
+ shas := slices.Map(self.getData().CherryPickedCommits, func(commit *models.Commit) string {
+ return commit.Sha
+ })
+ return set.NewFromSlice(shas)
+}
+
+func (self *CherryPickHelper) add(selectedCommit *models.Commit, commitsList []*models.Commit) {
+ commitSet := self.CherryPickedCommitShaSet()
+ commitSet.Add(selectedCommit.Sha)
+
+ cherryPickedCommits := slices.Filter(commitsList, func(commit *models.Commit) bool {
+ return commitSet.Includes(commit.Sha)
+ })
+
+ self.getData().CherryPickedCommits = slices.Map(cherryPickedCommits, func(commit *models.Commit) *models.Commit {
+ return &models.Commit{Name: commit.Name, Sha: commit.Sha}
+ })
+}
+
+// you can only copy from one context at a time, because the order and position of commits matter
+func (self *CherryPickHelper) resetIfNecessary(context types.Context) error {
+ oldContextKey := types.ContextKey(self.getData().ContextKey)
+
+ if oldContextKey != context.GetKey() {
+ // need to reset the cherry picking mode
+ self.getData().ContextKey = string(context.GetKey())
+ self.getData().CherryPickedCommits = make([]*models.Commit, 0)
+ }
+
+ return nil
+}
+
+func (self *CherryPickHelper) rerender() error {
+ for _, context := range []types.Context{
+ self.contexts.LocalCommits,
+ self.contexts.ReflogCommits,
+ self.contexts.SubCommits,
+ } {
+ if err := self.c.PostRefreshUpdate(context); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/gui/controllers/helpers/credentials_helper.go b/pkg/gui/controllers/helpers/credentials_helper.go
new file mode 100644
index 000000000..9da3a46a2
--- /dev/null
+++ b/pkg/gui/controllers/helpers/credentials_helper.go
@@ -0,0 +1,68 @@
+package helpers
+
+import (
+ "sync"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type CredentialsHelper struct {
+ c *types.HelperCommon
+}
+
+func NewCredentialsHelper(
+ c *types.HelperCommon,
+) *CredentialsHelper {
+ return &CredentialsHelper{
+ c: c,
+ }
+}
+
+// promptUserForCredential wait for a username, password or passphrase input from the credentials popup
+func (self *CredentialsHelper) PromptUserForCredential(passOrUname oscommands.CredentialType) string {
+ waitGroup := sync.WaitGroup{}
+ waitGroup.Add(1)
+
+ userInput := ""
+
+ self.c.OnUIThread(func() error {
+ title, mask := self.getTitleAndMask(passOrUname)
+
+ return self.c.Prompt(types.PromptOpts{
+ Title: title,
+ Mask: mask,
+ HandleConfirm: func(input string) error {
+ userInput = input
+
+ waitGroup.Done()
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+ },
+ HandleClose: func() error {
+ waitGroup.Done()
+
+ return nil
+ },
+ })
+ })
+
+ // wait for username/passwords/passphrase input
+ waitGroup.Wait()
+
+ return userInput + "\n"
+}
+
+func (self *CredentialsHelper) getTitleAndMask(passOrUname oscommands.CredentialType) (string, bool) {
+ switch passOrUname {
+ case oscommands.Username:
+ return self.c.Tr.CredentialsUsername, false
+ case oscommands.Password:
+ return self.c.Tr.CredentialsPassword, true
+ case oscommands.Passphrase:
+ return self.c.Tr.CredentialsPassphrase, true
+ }
+
+ // should never land here
+ panic("unexpected credential request")
+}
diff --git a/pkg/gui/controllers/helpers/files_helper.go b/pkg/gui/controllers/helpers/files_helper.go
new file mode 100644
index 000000000..72be6e4e5
--- /dev/null
+++ b/pkg/gui/controllers/helpers/files_helper.go
@@ -0,0 +1,62 @@
+package helpers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type IFilesHelper interface {
+ EditFile(filename string) error
+ EditFileAtLine(filename string, lineNumber int) error
+ OpenFile(filename string) error
+ OpenFileAtLine(filename string, lineNumber int) error
+}
+
+type FilesHelper struct {
+ c *types.HelperCommon
+ git *commands.GitCommand
+ os *oscommands.OSCommand
+}
+
+func NewFilesHelper(
+ c *types.HelperCommon,
+ git *commands.GitCommand,
+ os *oscommands.OSCommand,
+) *FilesHelper {
+ return &FilesHelper{
+ c: c,
+ git: git,
+ os: os,
+ }
+}
+
+var _ IFilesHelper = &FilesHelper{}
+
+func (self *FilesHelper) EditFile(filename string) error {
+ return self.EditFileAtLine(filename, 1)
+}
+
+func (self *FilesHelper) EditFileAtLine(filename string, lineNumber int) error {
+ cmdStr, err := self.git.File.GetEditCmdStr(filename, lineNumber)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ self.c.LogAction(self.c.Tr.Actions.EditFile)
+ return self.c.RunSubprocessAndRefresh(
+ self.os.Cmd.NewShell(cmdStr),
+ )
+}
+
+func (self *FilesHelper) OpenFile(filename string) error {
+ return self.OpenFileAtLine(filename, 1)
+}
+
+func (self *FilesHelper) OpenFileAtLine(filename string, lineNumber int) error {
+ self.c.LogAction(self.c.Tr.Actions.OpenFile)
+ if err := self.os.OpenFileAtLine(filename, lineNumber); err != nil {
+ return self.c.Error(err)
+ }
+ return nil
+}
diff --git a/pkg/gui/controllers/helpers/gpg_helper.go b/pkg/gui/controllers/helpers/gpg_helper.go
new file mode 100644
index 000000000..2e287c2b4
--- /dev/null
+++ b/pkg/gui/controllers/helpers/gpg_helper.go
@@ -0,0 +1,74 @@
+package helpers
+
+import (
+ "fmt"
+
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type GpgHelper struct {
+ c *types.HelperCommon
+ os *oscommands.OSCommand
+ git *commands.GitCommand
+}
+
+func NewGpgHelper(
+ c *types.HelperCommon,
+ os *oscommands.OSCommand,
+ git *commands.GitCommand,
+) *GpgHelper {
+ return &GpgHelper{
+ c: c,
+ os: os,
+ git: git,
+ }
+}
+
+// Currently there is a bug where if we switch to a subprocess from within
+// WithWaitingStatus we get stuck there and can't return to lazygit. We could
+// fix this bug, or just stop running subprocesses from within there, given that
+// we don't need to see a loading status if we're in a subprocess.
+// TODO: we shouldn't need to use a shell here, but looks like that NewShell function contains some windows specific quoting stuff. We should centralise that.
+func (self *GpgHelper) WithGpgHandling(cmdObj oscommands.ICmdObj, waitingStatus string, onSuccess func() error) error {
+ useSubprocess := self.git.Config.UsingGpg()
+ if useSubprocess {
+ success, err := self.c.RunSubprocess(self.os.Cmd.NewShell(cmdObj.ToString()))
+ if success && onSuccess != nil {
+ if err := onSuccess(); err != nil {
+ return err
+ }
+ }
+ if err := self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); err != nil {
+ return err
+ }
+
+ return err
+ } else {
+ return self.runAndStream(cmdObj, waitingStatus, onSuccess)
+ }
+}
+
+func (self *GpgHelper) runAndStream(cmdObj oscommands.ICmdObj, waitingStatus string, onSuccess func() error) error {
+ cmdObj = self.os.Cmd.NewShell(cmdObj.ToString())
+
+ return self.c.WithWaitingStatus(waitingStatus, func() error {
+ if err := cmdObj.StreamOutput().Run(); err != nil {
+ _ = self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+ return self.c.Error(
+ fmt.Errorf(
+ self.c.Tr.GitCommandFailed, self.c.UserConfig.Keybinding.Universal.ExtrasMenu,
+ ),
+ )
+ }
+
+ if onSuccess != nil {
+ if err := onSuccess(); err != nil {
+ return err
+ }
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+ })
+}
diff --git a/pkg/gui/controllers/helpers/helpers.go b/pkg/gui/controllers/helpers/helpers.go
new file mode 100644
index 000000000..b8c279fac
--- /dev/null
+++ b/pkg/gui/controllers/helpers/helpers.go
@@ -0,0 +1,35 @@
+package helpers
+
+type Helpers struct {
+ Refs *RefsHelper
+ Bisect *BisectHelper
+ Suggestions *SuggestionsHelper
+ Files *FilesHelper
+ WorkingTree *WorkingTreeHelper
+ Tags *TagsHelper
+ MergeAndRebase *MergeAndRebaseHelper
+ MergeConflicts *MergeConflictsHelper
+ CherryPick *CherryPickHelper
+ Host *HostHelper
+ PatchBuilding *PatchBuildingHelper
+ GPG *GpgHelper
+ Upstream *UpstreamHelper
+}
+
+func NewStubHelpers() *Helpers {
+ return &Helpers{
+ Refs: &RefsHelper{},
+ Bisect: &BisectHelper{},
+ Suggestions: &SuggestionsHelper{},
+ Files: &FilesHelper{},
+ WorkingTree: &WorkingTreeHelper{},
+ Tags: &TagsHelper{},
+ MergeAndRebase: &MergeAndRebaseHelper{},
+ MergeConflicts: &MergeConflictsHelper{},
+ CherryPick: &CherryPickHelper{},
+ Host: &HostHelper{},
+ PatchBuilding: &PatchBuildingHelper{},
+ GPG: &GpgHelper{},
+ Upstream: &UpstreamHelper{},
+ }
+}
diff --git a/pkg/gui/controllers/helpers/host_helper.go b/pkg/gui/controllers/helpers/host_helper.go
new file mode 100644
index 000000000..edc0bc7ba
--- /dev/null
+++ b/pkg/gui/controllers/helpers/host_helper.go
@@ -0,0 +1,46 @@
+package helpers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// this helper just wraps our hosting_service package
+
+type IHostHelper interface {
+ GetPullRequestURL(from string, to string) (string, error)
+ GetCommitURL(commitSha string) (string, error)
+}
+
+type HostHelper struct {
+ c *types.HelperCommon
+ git *commands.GitCommand
+}
+
+func NewHostHelper(
+ c *types.HelperCommon,
+ git *commands.GitCommand,
+) *HostHelper {
+ return &HostHelper{
+ c: c,
+ git: git,
+ }
+}
+
+func (self *HostHelper) GetPullRequestURL(from string, to string) (string, error) {
+ return self.getHostingServiceMgr().GetPullRequestURL(from, to)
+}
+
+func (self *HostHelper) GetCommitURL(commitSha string) (string, error) {
+ return self.getHostingServiceMgr().GetCommitURL(commitSha)
+}
+
+// getting this on every request rather than storing it in state in case our remoteURL changes
+// from one invocation to the next. Note however that we're currently caching config
+// results so we might want to invalidate the cache here if it becomes a problem.
+func (self *HostHelper) getHostingServiceMgr() *hosting_service.HostingServiceMgr {
+ remoteUrl := self.git.Config.GetRemoteURL()
+ configServices := self.c.UserConfig.Services
+ return hosting_service.NewHostingServiceMgr(self.c.Log, self.c.Tr, remoteUrl, configServices)
+}
diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go
new file mode 100644
index 000000000..21c02d6f4
--- /dev/null
+++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go
@@ -0,0 +1,247 @@
+package helpers
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/git_commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/types/enums"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type MergeAndRebaseHelper struct {
+ c *types.HelperCommon
+ contexts *context.ContextTree
+ git *commands.GitCommand
+ refsHelper *RefsHelper
+}
+
+func NewMergeAndRebaseHelper(
+ c *types.HelperCommon,
+ contexts *context.ContextTree,
+ git *commands.GitCommand,
+ refsHelper *RefsHelper,
+) *MergeAndRebaseHelper {
+ return &MergeAndRebaseHelper{
+ c: c,
+ contexts: contexts,
+ git: git,
+ refsHelper: refsHelper,
+ }
+}
+
+type RebaseOption string
+
+const (
+ REBASE_OPTION_CONTINUE string = "continue"
+ REBASE_OPTION_ABORT string = "abort"
+ REBASE_OPTION_SKIP string = "skip"
+)
+
+func (self *MergeAndRebaseHelper) CreateRebaseOptionsMenu() error {
+ type optionAndKey struct {
+ option string
+ key types.Key
+ }
+
+ options := []optionAndKey{
+ {option: REBASE_OPTION_CONTINUE, key: 'c'},
+ {option: REBASE_OPTION_ABORT, key: 'a'},
+ }
+
+ if self.git.Status.WorkingTreeState() == enums.REBASE_MODE_REBASING {
+ options = append(options, optionAndKey{
+ option: REBASE_OPTION_SKIP, key: 's',
+ })
+ }
+
+ menuItems := slices.Map(options, func(row optionAndKey) *types.MenuItem {
+ return &types.MenuItem{
+ Label: row.option,
+ OnPress: func() error {
+ return self.genericMergeCommand(row.option)
+ },
+ Key: row.key,
+ }
+ })
+
+ var title string
+ if self.git.Status.WorkingTreeState() == enums.REBASE_MODE_MERGING {
+ title = self.c.Tr.MergeOptionsTitle
+ } else {
+ title = self.c.Tr.RebaseOptionsTitle
+ }
+
+ return self.c.Menu(types.CreateMenuOptions{Title: title, Items: menuItems})
+}
+
+func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
+ status := self.git.Status.WorkingTreeState()
+
+ if status != enums.REBASE_MODE_MERGING && status != enums.REBASE_MODE_REBASING {
+ return self.c.ErrorMsg(self.c.Tr.NotMergingOrRebasing)
+ }
+
+ self.c.LogAction(fmt.Sprintf("Merge/Rebase: %s", command))
+
+ commandType := ""
+ switch status {
+ case enums.REBASE_MODE_MERGING:
+ commandType = "merge"
+ case enums.REBASE_MODE_REBASING:
+ commandType = "rebase"
+ default:
+ // shouldn't be possible to land here
+ }
+
+ // we should end up with a command like 'git merge --continue'
+
+ // it's impossible for a rebase to require a commit so we'll use a subprocess only if it's a merge
+ if status == enums.REBASE_MODE_MERGING && command != REBASE_OPTION_ABORT && self.c.UserConfig.Git.Merging.ManualCommit {
+ // TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction
+ return self.c.RunSubprocessAndRefresh(
+ self.git.Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command),
+ )
+ }
+ result := self.git.Rebase.GenericMergeOrRebaseAction(commandType, command)
+ if err := self.CheckMergeOrRebase(result); err != nil {
+ return err
+ }
+ return nil
+}
+
+var conflictStrings = []string{
+ "Failed to merge in the changes",
+ "When you have resolved this problem",
+ "fix conflicts",
+ "Resolve all conflicts manually",
+}
+
+func isMergeConflictErr(errStr string) bool {
+ for _, str := range conflictStrings {
+ if strings.Contains(errStr, str) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error {
+ if err := self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); err != nil {
+ return err
+ }
+ if result == nil {
+ return nil
+ } else if strings.Contains(result.Error(), "No changes - did you forget to use") {
+ return self.genericMergeCommand(REBASE_OPTION_SKIP)
+ } else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") {
+ return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
+ } else if strings.Contains(result.Error(), "No rebase in progress?") {
+ // assume in this case that we're already done
+ return nil
+ } else if isMergeConflictErr(result.Error()) {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.FoundConflictsTitle,
+ Prompt: self.c.Tr.FoundConflicts,
+ HandleConfirm: func() error {
+ return self.c.PushContext(self.contexts.Files)
+ },
+ HandleClose: func() error {
+ return self.genericMergeCommand(REBASE_OPTION_ABORT)
+ },
+ })
+ } else {
+ return self.c.ErrorMsg(result.Error())
+ }
+}
+
+func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error {
+ // prompt user to confirm that they want to abort, then do it
+ mode := self.workingTreeStateNoun()
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: fmt.Sprintf(self.c.Tr.AbortTitle, mode),
+ Prompt: fmt.Sprintf(self.c.Tr.AbortPrompt, mode),
+ HandleConfirm: func() error {
+ return self.genericMergeCommand(REBASE_OPTION_ABORT)
+ },
+ })
+}
+
+func (self *MergeAndRebaseHelper) workingTreeStateNoun() string {
+ workingTreeState := self.git.Status.WorkingTreeState()
+ switch workingTreeState {
+ case enums.REBASE_MODE_NONE:
+ return ""
+ case enums.REBASE_MODE_MERGING:
+ return "merge"
+ default:
+ return "rebase"
+ }
+}
+
+// PromptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress
+func (self *MergeAndRebaseHelper) PromptToContinueRebase() error {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: "continue",
+ Prompt: self.c.Tr.ConflictsResolved,
+ HandleConfirm: func() error {
+ return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
+ },
+ })
+}
+
+func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
+ checkedOutBranch := self.refsHelper.GetCheckedOutRef().Name
+ if ref == checkedOutBranch {
+ return self.c.ErrorMsg(self.c.Tr.CantRebaseOntoSelf)
+ }
+ prompt := utils.ResolvePlaceholderString(
+ self.c.Tr.ConfirmRebase,
+ map[string]string{
+ "checkedOutBranch": checkedOutBranch,
+ "selectedBranch": ref,
+ },
+ )
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.RebasingTitle,
+ Prompt: prompt,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.RebaseBranch)
+ err := self.git.Rebase.RebaseBranch(ref)
+ return self.CheckMergeOrRebase(err)
+ },
+ })
+}
+
+func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) error {
+ if self.git.Branch.IsHeadDetached() {
+ return self.c.ErrorMsg("Cannot merge branch in detached head state. You might have checked out a commit directly or a remote branch, in which case you should checkout the local branch you want to be on")
+ }
+ checkedOutBranchName := self.refsHelper.GetCheckedOutRef().Name
+ if checkedOutBranchName == refName {
+ return self.c.ErrorMsg(self.c.Tr.CantMergeBranchIntoItself)
+ }
+ prompt := utils.ResolvePlaceholderString(
+ self.c.Tr.ConfirmMerge,
+ map[string]string{
+ "checkedOutBranch": checkedOutBranchName,
+ "selectedBranch": refName,
+ },
+ )
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.MergeConfirmTitle,
+ Prompt: prompt,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.Merge)
+ err := self.git.Branch.Merge(refName, git_commands.MergeOpts{})
+ return self.CheckMergeOrRebase(err)
+ },
+ })
+}
diff --git a/pkg/gui/controllers/helpers/merge_conflicts_helper.go b/pkg/gui/controllers/helpers/merge_conflicts_helper.go
new file mode 100644
index 000000000..d7f7aa747
--- /dev/null
+++ b/pkg/gui/controllers/helpers/merge_conflicts_helper.go
@@ -0,0 +1,115 @@
+package helpers
+
+import (
+ "fmt"
+
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type MergeConflictsHelper struct {
+ c *types.HelperCommon
+ contexts *context.ContextTree
+ git *commands.GitCommand
+}
+
+func NewMergeConflictsHelper(
+ c *types.HelperCommon,
+ contexts *context.ContextTree,
+ git *commands.GitCommand,
+) *MergeConflictsHelper {
+ return &MergeConflictsHelper{
+ c: c,
+ contexts: contexts,
+ git: git,
+ }
+}
+
+func (self *MergeConflictsHelper) GetMergingOptions() map[string]string {
+ keybindingConfig := self.c.UserConfig.Keybinding
+
+ return map[string]string{
+ fmt.Sprintf("%s %s", keybindings.Label(keybindingConfig.Universal.PrevItem), keybindings.Label(keybindingConfig.Universal.NextItem)): self.c.Tr.LcSelectHunk,
+ fmt.Sprintf("%s %s", keybindings.Label(keybindingConfig.Universal.PrevBlock), keybindings.Label(keybindingConfig.Universal.NextBlock)): self.c.Tr.LcNavigateConflicts,
+ keybindings.Label(keybindingConfig.Universal.Select): self.c.Tr.LcPickHunk,
+ keybindings.Label(keybindingConfig.Main.PickBothHunks): self.c.Tr.LcPickAllHunks,
+ keybindings.Label(keybindingConfig.Universal.Undo): self.c.Tr.LcUndo,
+ }
+}
+
+func (self *MergeConflictsHelper) SetMergeState(path string) (bool, error) {
+ self.context().GetMutex().Lock()
+ defer self.context().GetMutex().Unlock()
+
+ return self.setMergeStateWithoutLock(path)
+}
+
+func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, error) {
+ content, err := self.git.File.Cat(path)
+ if err != nil {
+ return false, err
+ }
+
+ if path != self.context().GetState().GetPath() {
+ self.context().SetUserScrolling(false)
+ }
+
+ self.context().GetState().SetContent(content, path)
+
+ return !self.context().GetState().NoConflicts(), nil
+}
+
+func (self *MergeConflictsHelper) ResetMergeState() {
+ self.context().GetMutex().Lock()
+ defer self.context().GetMutex().Unlock()
+
+ self.resetMergeState()
+}
+
+func (self *MergeConflictsHelper) resetMergeState() {
+ self.context().SetUserScrolling(false)
+ self.context().GetState().Reset()
+}
+
+func (self *MergeConflictsHelper) EscapeMerge() error {
+ self.resetMergeState()
+
+ // doing this in separate UI thread so that we're not still holding the lock by the time refresh the file
+ self.c.OnUIThread(func() error {
+ return self.c.PushContext(self.contexts.Files)
+ })
+ return nil
+}
+
+func (self *MergeConflictsHelper) SetConflictsAndRender(path string, isFocused bool) (bool, error) {
+ hasConflicts, err := self.setMergeStateWithoutLock(path)
+ if err != nil {
+ return false, err
+ }
+
+ if hasConflicts {
+ return true, self.context().Render(isFocused)
+ }
+
+ return false, nil
+}
+
+func (self *MergeConflictsHelper) SwitchToMerge(path string) error {
+ if self.context().GetState().GetPath() != path {
+ hasConflicts, err := self.SetMergeState(path)
+ if err != nil {
+ return err
+ }
+ if !hasConflicts {
+ return nil
+ }
+ }
+
+ return self.c.PushContext(self.contexts.MergeConflicts)
+}
+
+func (self *MergeConflictsHelper) context() *context.MergeConflictsContext {
+ return self.contexts.MergeConflicts
+}
diff --git a/pkg/gui/controllers/helpers/patch_building_helper.go b/pkg/gui/controllers/helpers/patch_building_helper.go
new file mode 100644
index 000000000..25ac63a08
--- /dev/null
+++ b/pkg/gui/controllers/helpers/patch_building_helper.go
@@ -0,0 +1,62 @@
+package helpers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/types/enums"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type IPatchBuildingHelper interface {
+ ValidateNormalWorkingTreeState() (bool, error)
+}
+
+type PatchBuildingHelper struct {
+ c *types.HelperCommon
+ git *commands.GitCommand
+ contexts *context.ContextTree
+}
+
+func NewPatchBuildingHelper(
+ c *types.HelperCommon,
+ git *commands.GitCommand,
+ contexts *context.ContextTree,
+) *PatchBuildingHelper {
+ return &PatchBuildingHelper{
+ c: c,
+ git: git,
+ contexts: contexts,
+ }
+}
+
+func (self *PatchBuildingHelper) ValidateNormalWorkingTreeState() (bool, error) {
+ if self.git.Status.WorkingTreeState() != enums.REBASE_MODE_NONE {
+ return false, self.c.ErrorMsg(self.c.Tr.CantPatchWhileRebasingError)
+ }
+ return true, nil
+}
+
+// takes us from the patch building panel back to the commit files panel
+func (self *PatchBuildingHelper) Escape() error {
+ return self.c.PushContext(self.contexts.CommitFiles)
+}
+
+// kills the custom patch and returns us back to the commit files panel if needed
+func (self *PatchBuildingHelper) Reset() error {
+ self.git.Patch.PatchManager.Reset()
+
+ if self.c.CurrentStaticContext().GetKind() != types.SIDE_CONTEXT {
+ if err := self.Escape(); err != nil {
+ return err
+ }
+ }
+
+ if err := self.c.Refresh(types.RefreshOptions{
+ Scope: []types.RefreshableView{types.COMMIT_FILES},
+ }); err != nil {
+ return err
+ }
+
+ // refreshing the current context so that the secondary panel is hidden if necessary.
+ return self.c.PostRefreshUpdate(self.c.CurrentContext())
+}
diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go
new file mode 100644
index 000000000..7630349ec
--- /dev/null
+++ b/pkg/gui/controllers/helpers/refs_helper.go
@@ -0,0 +1,202 @@
+package helpers
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/git_commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type IRefsHelper interface {
+ CheckoutRef(ref string, options types.CheckoutRefOptions) error
+ GetCheckedOutRef() *models.Branch
+ CreateGitResetMenu(ref string) error
+ ResetToRef(ref string, strength string, envVars []string) error
+ NewBranch(from string, fromDescription string, suggestedBranchname string) error
+}
+
+type RefsHelper struct {
+ c *types.HelperCommon
+ git *commands.GitCommand
+ contexts *context.ContextTree
+ model *types.Model
+}
+
+func NewRefsHelper(
+ c *types.HelperCommon,
+ git *commands.GitCommand,
+ contexts *context.ContextTree,
+ model *types.Model,
+) *RefsHelper {
+ return &RefsHelper{
+ c: c,
+ git: git,
+ contexts: contexts,
+ model: model,
+ }
+}
+
+var _ IRefsHelper = &RefsHelper{}
+
+func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions) error {
+ waitingStatus := options.WaitingStatus
+ if waitingStatus == "" {
+ waitingStatus = self.c.Tr.CheckingOutStatus
+ }
+
+ cmdOptions := git_commands.CheckoutOptions{Force: false, EnvVars: options.EnvVars}
+
+ onSuccess := func() {
+ self.contexts.Branches.SetSelectedLineIdx(0)
+ self.contexts.ReflogCommits.SetSelectedLineIdx(0)
+ self.contexts.LocalCommits.SetSelectedLineIdx(0)
+ // loading a heap of commits is slow so we limit them whenever doing a reset
+ self.contexts.LocalCommits.SetLimitCommits(true)
+ }
+
+ return self.c.WithWaitingStatus(waitingStatus, func() error {
+ if err := self.git.Branch.Checkout(ref, cmdOptions); err != nil {
+ // note, this will only work for english-language git commands. If we force git to use english, and the error isn't this one, then the user will receive an english command they may not understand. I'm not sure what the best solution to this is. Running the command once in english and a second time in the native language is one option
+
+ if options.OnRefNotFound != nil && strings.Contains(err.Error(), "did not match any file(s) known to git") {
+ return options.OnRefNotFound(ref)
+ }
+
+ if strings.Contains(err.Error(), "Please commit your changes or stash them before you switch branch") {
+ // offer to autostash changes
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.AutoStashTitle,
+ Prompt: self.c.Tr.AutoStashPrompt,
+ HandleConfirm: func() error {
+ if err := self.git.Stash.Save(self.c.Tr.StashPrefix + ref); err != nil {
+ return self.c.Error(err)
+ }
+ if err := self.git.Branch.Checkout(ref, cmdOptions); err != nil {
+ return self.c.Error(err)
+ }
+
+ onSuccess()
+ if err := self.git.Stash.Pop(0); err != nil {
+ if err := self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI}); err != nil {
+ return err
+ }
+ return self.c.Error(err)
+ }
+ return self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI})
+ },
+ })
+ }
+
+ if err := self.c.Error(err); err != nil {
+ return err
+ }
+ }
+ onSuccess()
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI})
+ })
+}
+
+func (self *RefsHelper) GetCheckedOutRef() *models.Branch {
+ if len(self.model.Branches) == 0 {
+ return nil
+ }
+
+ return self.model.Branches[0]
+}
+
+func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string) error {
+ if err := self.git.Commit.ResetToCommit(ref, strength, envVars); err != nil {
+ return self.c.Error(err)
+ }
+
+ self.contexts.LocalCommits.SetSelectedLineIdx(0)
+ self.contexts.ReflogCommits.SetSelectedLineIdx(0)
+ // loading a heap of commits is slow so we limit them whenever doing a reset
+ self.contexts.LocalCommits.SetLimitCommits(true)
+
+ if err := self.c.PushContext(self.contexts.LocalCommits); err != nil {
+ return err
+ }
+
+ if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}}); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (self *RefsHelper) CreateGitResetMenu(ref string) error {
+ type strengthWithKey struct {
+ strength string
+ key types.Key
+ }
+ strengths := []strengthWithKey{
+ {strength: "soft", key: 's'},
+ {strength: "mixed", key: 'm'},
+ {strength: "hard", key: 'h'},
+ }
+
+ menuItems := slices.Map(strengths, func(row strengthWithKey) *types.MenuItem {
+ return &types.MenuItem{
+ LabelColumns: []string{
+ fmt.Sprintf("%s reset", row.strength),
+ style.FgRed.Sprintf("reset --%s %s", row.strength, ref),
+ },
+ OnPress: func() error {
+ self.c.LogAction("Reset")
+ return self.ResetToRef(ref, row.strength, []string{})
+ },
+ Key: row.key,
+ }
+ })
+
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: fmt.Sprintf("%s %s", self.c.Tr.LcResetTo, ref),
+ Items: menuItems,
+ })
+}
+
+func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggestedBranchName string) error {
+ message := utils.ResolvePlaceholderString(
+ self.c.Tr.NewBranchNameBranchOff,
+ map[string]string{
+ "branchName": fromFormattedName,
+ },
+ )
+
+ return self.c.Prompt(types.PromptOpts{
+ Title: message,
+ InitialContent: suggestedBranchName,
+ HandleConfirm: func(response string) error {
+ self.c.LogAction(self.c.Tr.Actions.CreateBranch)
+ if err := self.git.Branch.New(sanitizedBranchName(response), from); err != nil {
+ return err
+ }
+
+ if self.c.CurrentContext() != self.contexts.Branches {
+ if err := self.c.PushContext(self.contexts.Branches); err != nil {
+ return err
+ }
+ }
+
+ self.contexts.LocalCommits.SetSelectedLineIdx(0)
+ self.contexts.Branches.SetSelectedLineIdx(0)
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+ },
+ })
+}
+
+// sanitizedBranchName will remove all spaces in favor of a dash "-" to meet
+// git's branch naming requirement.
+func sanitizedBranchName(input string) string {
+ return strings.Replace(input, " ", "-", -1)
+}
diff --git a/pkg/gui/controllers/helpers/suggestions_helper.go b/pkg/gui/controllers/helpers/suggestions_helper.go
new file mode 100644
index 000000000..0cc4a642b
--- /dev/null
+++ b/pkg/gui/controllers/helpers/suggestions_helper.go
@@ -0,0 +1,197 @@
+package helpers
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/presentation"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/jesseduffield/minimal/gitignore"
+ "github.com/samber/lo"
+ "gopkg.in/ozeidan/fuzzy-patricia.v3/patricia"
+)
+
+// Thinking out loud: I'm typically a staunch advocate of organising code by feature rather than type,
+// because colocating code that relates to the same feature means far less effort
+// to get all the context you need to work on any particular feature. But the one
+// major benefit of grouping by type is that it makes it makes it less likely that
+// somebody will re-implement the same logic twice, because they can quickly see
+// if a certain method has been used for some use case, given that as a starting point
+// they know about the type. In that vein, I'm including all our functions for
+// finding suggestions in this file, so that it's easy to see if a function already
+// exists for fetching a particular model.
+
+type ISuggestionsHelper interface {
+ GetRemoteSuggestionsFunc() func(string) []*types.Suggestion
+ GetBranchNameSuggestionsFunc() func(string) []*types.Suggestion
+ GetFilePathSuggestionsFunc() func(string) []*types.Suggestion
+ GetRemoteBranchesSuggestionsFunc(separator string) func(string) []*types.Suggestion
+ GetRefsSuggestionsFunc() func(string) []*types.Suggestion
+}
+
+type SuggestionsHelper struct {
+ c *types.HelperCommon
+
+ model *types.Model
+ refreshSuggestionsFn func()
+}
+
+var _ ISuggestionsHelper = &SuggestionsHelper{}
+
+func NewSuggestionsHelper(
+ c *types.HelperCommon,
+ model *types.Model,
+ refreshSuggestionsFn func(),
+) *SuggestionsHelper {
+ return &SuggestionsHelper{
+ c: c,
+ model: model,
+ refreshSuggestionsFn: refreshSuggestionsFn,
+ }
+}
+
+func (self *SuggestionsHelper) getRemoteNames() []string {
+ return slices.Map(self.model.Remotes, func(remote *models.Remote) string {
+ return remote.Name
+ })
+}
+
+func matchesToSuggestions(matches []string) []*types.Suggestion {
+ return slices.Map(matches, func(match string) *types.Suggestion {
+ return &types.Suggestion{
+ Value: match,
+ Label: match,
+ }
+ })
+}
+
+func (self *SuggestionsHelper) GetRemoteSuggestionsFunc() func(string) []*types.Suggestion {
+ remoteNames := self.getRemoteNames()
+
+ return FuzzySearchFunc(remoteNames)
+}
+
+func (self *SuggestionsHelper) getBranchNames() []string {
+ return slices.Map(self.model.Branches, func(branch *models.Branch) string {
+ return branch.Name
+ })
+}
+
+func (self *SuggestionsHelper) GetBranchNameSuggestionsFunc() func(string) []*types.Suggestion {
+ branchNames := self.getBranchNames()
+
+ return func(input string) []*types.Suggestion {
+ var matchingBranchNames []string
+ if input == "" {
+ matchingBranchNames = branchNames
+ } else {
+ matchingBranchNames = utils.FuzzySearch(input, branchNames)
+ }
+
+ return slices.Map(matchingBranchNames, func(branchName string) *types.Suggestion {
+ return &types.Suggestion{
+ Value: branchName,
+ Label: presentation.GetBranchTextStyle(branchName).Sprint(branchName),
+ }
+ })
+ }
+}
+
+// here we asynchronously fetch the latest set of paths in the repo and store in
+// self.model.FilesTrie. On the main thread we'll be doing a fuzzy search via
+// self.model.FilesTrie. So if we've looked for a file previously, we'll start with
+// the old trie and eventually it'll be swapped out for the new one.
+// Notably, unlike other suggestion functions we're not showing all the options
+// if nothing has been typed because there'll be too much to display efficiently
+func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*types.Suggestion {
+ _ = self.c.WithWaitingStatus(self.c.Tr.LcLoadingFileSuggestions, func() error {
+ trie := patricia.NewTrie()
+ // load every non-gitignored file in the repo
+ ignore, err := gitignore.FromGit()
+ if err != nil {
+ return err
+ }
+
+ err = ignore.Walk(".",
+ func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ trie.Insert(patricia.Prefix(path), path)
+ return nil
+ })
+
+ // cache the trie for future use
+ self.model.FilesTrie = trie
+
+ self.refreshSuggestionsFn()
+
+ return err
+ })
+
+ return func(input string) []*types.Suggestion {
+ matchingNames := []string{}
+ _ = self.model.FilesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error {
+ matchingNames = append(matchingNames, item.(string))
+ return nil
+ })
+
+ // doing another fuzzy search for good measure
+ matchingNames = utils.FuzzySearch(input, matchingNames)
+
+ return matchesToSuggestions(matchingNames)
+ }
+}
+
+func (self *SuggestionsHelper) getRemoteBranchNames(separator string) []string {
+ return slices.FlatMap(self.model.Remotes, func(remote *models.Remote) []string {
+ return slices.Map(remote.Branches, func(branch *models.RemoteBranch) string {
+ return fmt.Sprintf("%s%s%s", remote.Name, separator, branch.Name)
+ })
+ })
+}
+
+func (self *SuggestionsHelper) GetRemoteBranchesSuggestionsFunc(separator string) func(string) []*types.Suggestion {
+ return FuzzySearchFunc(self.getRemoteBranchNames(separator))
+}
+
+func (self *SuggestionsHelper) getTagNames() []string {
+ return slices.Map(self.model.Tags, func(tag *models.Tag) string {
+ return tag.Name
+ })
+}
+
+func (self *SuggestionsHelper) GetRefsSuggestionsFunc() func(string) []*types.Suggestion {
+ remoteBranchNames := self.getRemoteBranchNames("/")
+ localBranchNames := self.getBranchNames()
+ tagNames := self.getTagNames()
+ additionalRefNames := []string{"HEAD", "FETCH_HEAD", "MERGE_HEAD", "ORIG_HEAD"}
+
+ refNames := append(append(append(remoteBranchNames, localBranchNames...), tagNames...), additionalRefNames...)
+
+ return FuzzySearchFunc(refNames)
+}
+
+func (self *SuggestionsHelper) GetAuthorsSuggestionsFunc() func(string) []*types.Suggestion {
+ authors := lo.Uniq(slices.Map(self.model.Commits, func(commit *models.Commit) string {
+ return fmt.Sprintf("%s <%s>", commit.AuthorName, commit.AuthorEmail)
+ }))
+
+ return FuzzySearchFunc(authors)
+}
+
+func FuzzySearchFunc(options []string) func(string) []*types.Suggestion {
+ return func(input string) []*types.Suggestion {
+ var matches []string
+ if input == "" {
+ matches = options
+ } else {
+ matches = utils.FuzzySearch(input, options)
+ }
+
+ return matchesToSuggestions(matches)
+ }
+}
diff --git a/pkg/gui/controllers/helpers/tags_helper.go b/pkg/gui/controllers/helpers/tags_helper.go
new file mode 100644
index 000000000..4683ffd0d
--- /dev/null
+++ b/pkg/gui/controllers/helpers/tags_helper.go
@@ -0,0 +1,80 @@
+package helpers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// Helper structs are for defining functionality that could be used by multiple contexts.
+// For example, here we have a CreateTagMenu which is applicable to both the tags context
+// and the commits context.
+
+type TagsHelper struct {
+ c *types.HelperCommon
+ git *commands.GitCommand
+}
+
+func NewTagsHelper(c *types.HelperCommon, git *commands.GitCommand) *TagsHelper {
+ return &TagsHelper{
+ c: c,
+ git: git,
+ }
+}
+
+func (self *TagsHelper) CreateTagMenu(commitSha string, onCreate func()) error {
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: self.c.Tr.TagMenuTitle,
+ Items: []*types.MenuItem{
+ {
+ Label: self.c.Tr.LcLightweightTag,
+ OnPress: func() error {
+ return self.handleCreateLightweightTag(commitSha, onCreate)
+ },
+ },
+ {
+ Label: self.c.Tr.LcAnnotatedTag,
+ OnPress: func() error {
+ return self.handleCreateAnnotatedTag(commitSha, onCreate)
+ },
+ },
+ },
+ })
+}
+
+func (self *TagsHelper) afterTagCreate(onCreate func()) error {
+ onCreate()
+ return self.c.Refresh(types.RefreshOptions{
+ Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS},
+ })
+}
+
+func (self *TagsHelper) handleCreateAnnotatedTag(commitSha string, onCreate func()) error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.TagNameTitle,
+ HandleConfirm: func(tagName string) error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.TagMessageTitle,
+ HandleConfirm: func(msg string) error {
+ self.c.LogAction(self.c.Tr.Actions.CreateAnnotatedTag)
+ if err := self.git.Tag.CreateAnnotated(tagName, commitSha, msg); err != nil {
+ return self.c.Error(err)
+ }
+ return self.afterTagCreate(onCreate)
+ },
+ })
+ },
+ })
+}
+
+func (self *TagsHelper) handleCreateLightweightTag(commitSha string, onCreate func()) error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.TagNameTitle,
+ HandleConfirm: func(tagName string) error {
+ self.c.LogAction(self.c.Tr.Actions.CreateLightweightTag)
+ if err := self.git.Tag.CreateLightweight(tagName, commitSha); err != nil {
+ return self.c.Error(err)
+ }
+ return self.afterTagCreate(onCreate)
+ },
+ })
+}
diff --git a/pkg/gui/controllers/helpers/upstream_helper.go b/pkg/gui/controllers/helpers/upstream_helper.go
new file mode 100644
index 000000000..a2d8e8ae2
--- /dev/null
+++ b/pkg/gui/controllers/helpers/upstream_helper.go
@@ -0,0 +1,88 @@
+package helpers
+
+import (
+ "errors"
+ "strings"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type UpstreamHelper struct {
+ c *types.HelperCommon
+ model *types.Model
+
+ getRemoteBranchesSuggestionsFunc func(string) func(string) []*types.Suggestion
+}
+
+type IUpstreamHelper interface {
+ ParseUpstream(string) (string, string, error)
+ PromptForUpstreamWithInitialContent(*models.Branch, func(string) error) error
+ PromptForUpstreamWithoutInitialContent(*models.Branch, func(string) error) error
+ GetSuggestedRemote() string
+}
+
+var _ IUpstreamHelper = &UpstreamHelper{}
+
+func NewUpstreamHelper(
+ c *types.HelperCommon,
+ model *types.Model,
+ getRemoteBranchesSuggestionsFunc func(string) func(string) []*types.Suggestion,
+) *UpstreamHelper {
+ return &UpstreamHelper{
+ c: c,
+ model: model,
+ getRemoteBranchesSuggestionsFunc: getRemoteBranchesSuggestionsFunc,
+ }
+}
+
+func (self *UpstreamHelper) ParseUpstream(upstream string) (string, string, error) {
+ var upstreamBranch, upstreamRemote string
+ split := strings.Split(upstream, " ")
+ if len(split) != 2 {
+ return "", "", errors.New(self.c.Tr.InvalidUpstream)
+ }
+
+ upstreamRemote = split[0]
+ upstreamBranch = split[1]
+
+ return upstreamRemote, upstreamBranch, nil
+}
+
+func (self *UpstreamHelper) promptForUpstream(initialContent string, onConfirm func(string) error) error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.EnterUpstream,
+ InitialContent: initialContent,
+ FindSuggestionsFunc: self.getRemoteBranchesSuggestionsFunc(" "),
+ HandleConfirm: onConfirm,
+ })
+}
+
+func (self *UpstreamHelper) PromptForUpstreamWithInitialContent(currentBranch *models.Branch, onConfirm func(string) error) error {
+ suggestedRemote := self.GetSuggestedRemote()
+ initialContent := suggestedRemote + " " + currentBranch.Name
+
+ return self.promptForUpstream(initialContent, onConfirm)
+}
+
+func (self *UpstreamHelper) PromptForUpstreamWithoutInitialContent(_ *models.Branch, onConfirm func(string) error) error {
+ return self.promptForUpstream("", onConfirm)
+}
+
+func (self *UpstreamHelper) GetSuggestedRemote() string {
+ return getSuggestedRemote(self.model.Remotes)
+}
+
+func getSuggestedRemote(remotes []*models.Remote) string {
+ if len(remotes) == 0 {
+ return "origin"
+ }
+
+ for _, remote := range remotes {
+ if remote.Name == "origin" {
+ return remote.Name
+ }
+ }
+
+ return remotes[0].Name
+}
diff --git a/pkg/gui/controllers/helpers/upstream_helper_test.go b/pkg/gui/controllers/helpers/upstream_helper_test.go
new file mode 100644
index 000000000..ac7a6a8bf
--- /dev/null
+++ b/pkg/gui/controllers/helpers/upstream_helper_test.go
@@ -0,0 +1,31 @@
+package helpers
+
+import (
+ "testing"
+
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetSuggestedRemote(t *testing.T) {
+ cases := []struct {
+ remotes []*models.Remote
+ expected string
+ }{
+ {mkRemoteList(), "origin"},
+ {mkRemoteList("upstream", "origin", "foo"), "origin"},
+ {mkRemoteList("upstream", "foo", "bar"), "upstream"},
+ }
+
+ for _, c := range cases {
+ result := getSuggestedRemote(c.remotes)
+ assert.EqualValues(t, c.expected, result)
+ }
+}
+
+func mkRemoteList(names ...string) []*models.Remote {
+ return slices.Map(names, func(name string) *models.Remote {
+ return &models.Remote{Name: name}
+ })
+}
diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go
new file mode 100644
index 000000000..ab52a37c7
--- /dev/null
+++ b/pkg/gui/controllers/helpers/working_tree_helper.go
@@ -0,0 +1,74 @@
+package helpers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type IWorkingTreeHelper interface {
+ AnyStagedFiles() bool
+ AnyTrackedFiles() bool
+ IsWorkingTreeDirty() bool
+ FileForSubmodule(submodule *models.SubmoduleConfig) *models.File
+}
+
+type WorkingTreeHelper struct {
+ c *types.HelperCommon
+ git *commands.GitCommand
+
+ model *types.Model
+}
+
+func NewWorkingTreeHelper(c *types.HelperCommon, git *commands.GitCommand, model *types.Model) *WorkingTreeHelper {
+ return &WorkingTreeHelper{
+ c: c,
+ git: git,
+ model: model,
+ }
+}
+
+func (self *WorkingTreeHelper) AnyStagedFiles() bool {
+ for _, file := range self.model.Files {
+ if file.HasStagedChanges {
+ return true
+ }
+ }
+ return false
+}
+
+func (self *WorkingTreeHelper) AnyTrackedFiles() bool {
+ for _, file := range self.model.Files {
+ if file.Tracked {
+ return true
+ }
+ }
+ return false
+}
+
+func (self *WorkingTreeHelper) IsWorkingTreeDirty() bool {
+ return self.AnyStagedFiles() || self.AnyTrackedFiles()
+}
+
+func (self *WorkingTreeHelper) FileForSubmodule(submodule *models.SubmoduleConfig) *models.File {
+ for _, file := range self.model.Files {
+ if file.IsSubmodule([]*models.SubmoduleConfig{submodule}) {
+ return file
+ }
+ }
+
+ return nil
+}
+
+func (self *WorkingTreeHelper) OpenMergeTool() error {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.MergeToolTitle,
+ Prompt: self.c.Tr.MergeToolPrompt,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.OpenMergeTool)
+ return self.c.RunSubprocessAndRefresh(
+ self.git.WorkingTree.OpenMergeToolCmdObj(),
+ )
+ },
+ })
+}
diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go
new file mode 100644
index 000000000..c74d87244
--- /dev/null
+++ b/pkg/gui/controllers/list_controller.go
@@ -0,0 +1,202 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type ListControllerFactory struct {
+ c *types.HelperCommon
+}
+
+func NewListControllerFactory(c *types.HelperCommon) *ListControllerFactory {
+ return &ListControllerFactory{
+ c: c,
+ }
+}
+
+func (self *ListControllerFactory) Create(context types.IListContext) *ListController {
+ return &ListController{
+ baseController: baseController{},
+ c: self.c,
+ context: context,
+ }
+}
+
+type ListController struct {
+ baseController
+ c *types.HelperCommon
+
+ context types.IListContext
+}
+
+func (self *ListController) Context() types.Context {
+ return self.context
+}
+
+func (self *ListController) HandlePrevLine() error {
+ return self.handleLineChange(-1)
+}
+
+func (self *ListController) HandleNextLine() error {
+ return self.handleLineChange(1)
+}
+
+func (self *ListController) HandleScrollLeft() error {
+ return self.scrollHorizontal(self.context.GetViewTrait().ScrollLeft)
+}
+
+func (self *ListController) HandleScrollRight() error {
+ return self.scrollHorizontal(self.context.GetViewTrait().ScrollRight)
+}
+
+func (self *ListController) HandleScrollUp() error {
+ scrollHeight := self.c.UserConfig.Gui.ScrollHeight
+ self.context.GetViewTrait().ScrollUp(scrollHeight)
+
+ // we only need to do a line change if our line has been pushed out of the viewport, because
+ // at the moment much logic depends on the selected line always being visible
+ if !self.isSelectedLineInViewPort() {
+ return self.handleLineChange(-scrollHeight)
+ }
+
+ return nil
+}
+
+func (self *ListController) HandleScrollDown() error {
+ scrollHeight := self.c.UserConfig.Gui.ScrollHeight
+ self.context.GetViewTrait().ScrollDown(scrollHeight)
+
+ if !self.isSelectedLineInViewPort() {
+ return self.handleLineChange(scrollHeight)
+ }
+
+ return nil
+}
+
+func (self *ListController) isSelectedLineInViewPort() bool {
+ selectedLineIdx := self.context.GetList().GetSelectedLineIdx()
+ startIdx, length := self.context.GetViewTrait().ViewPortYBounds()
+ return selectedLineIdx >= startIdx && selectedLineIdx < startIdx+length
+}
+
+func (self *ListController) scrollHorizontal(scrollFunc func()) error {
+ scrollFunc()
+
+ return self.context.HandleFocus(types.OnFocusOpts{})
+}
+
+func (self *ListController) handleLineChange(change int) error {
+ before := self.context.GetList().GetSelectedLineIdx()
+ self.context.GetList().MoveSelectedLine(change)
+ after := self.context.GetList().GetSelectedLineIdx()
+
+ if err := self.pushContextIfNotFocused(); err != nil {
+ return err
+ }
+
+ // doing this check so that if we're holding the up key at the start of the list
+ // we're not constantly re-rendering the main view.
+ if before != after {
+ return self.context.HandleFocus(types.OnFocusOpts{})
+ }
+
+ return nil
+}
+
+func (self *ListController) HandlePrevPage() error {
+ return self.handleLineChange(-self.context.GetViewTrait().PageDelta())
+}
+
+func (self *ListController) HandleNextPage() error {
+ return self.handleLineChange(self.context.GetViewTrait().PageDelta())
+}
+
+func (self *ListController) HandleGotoTop() error {
+ return self.handleLineChange(-self.context.GetList().Len())
+}
+
+func (self *ListController) HandleGotoBottom() error {
+ return self.handleLineChange(self.context.GetList().Len())
+}
+
+func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error {
+ prevSelectedLineIdx := self.context.GetList().GetSelectedLineIdx()
+ newSelectedLineIdx := opts.Y
+ alreadyFocused := self.isFocused()
+
+ if err := self.pushContextIfNotFocused(); err != nil {
+ return err
+ }
+
+ if newSelectedLineIdx > self.context.GetList().Len()-1 {
+ return nil
+ }
+
+ self.context.GetList().SetSelectedLineIdx(newSelectedLineIdx)
+
+ if prevSelectedLineIdx == newSelectedLineIdx && alreadyFocused && self.context.GetOnClick() != nil {
+ return self.context.GetOnClick()()
+ }
+ return self.context.HandleFocus(types.OnFocusOpts{})
+}
+
+func (self *ListController) pushContextIfNotFocused() error {
+ if !self.isFocused() {
+ if err := self.c.PushContext(self.context); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (self *ListController) isFocused() bool {
+ return self.c.CurrentContext().GetKey() == self.context.GetKey()
+}
+
+func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ return []*types.Binding{
+ {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), Handler: self.HandlePrevLine},
+ {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItem), Handler: self.HandlePrevLine},
+ {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItemAlt), Handler: self.HandleNextLine},
+ {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItem), Handler: self.HandleNextLine},
+ {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevPage), Handler: self.HandlePrevPage, Description: self.c.Tr.LcPrevPage},
+ {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextPage), Handler: self.HandleNextPage, Description: self.c.Tr.LcNextPage},
+ {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTop), Handler: self.HandleGotoTop, Description: self.c.Tr.LcGotoTop},
+ {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollLeft), Handler: self.HandleScrollLeft},
+ {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollRight), Handler: self.HandleScrollRight},
+ {
+ Key: opts.GetKey(opts.Config.Universal.StartSearch),
+ Handler: func() error { self.c.OpenSearch(); return nil },
+ Description: self.c.Tr.LcStartSearch,
+ Tag: "navigation",
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.GotoBottom),
+ Description: self.c.Tr.LcGotoBottom,
+ Handler: self.HandleGotoBottom,
+ Tag: "navigation",
+ },
+ }
+}
+
+func (self *ListController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
+ return []*gocui.ViewMouseBinding{
+ {
+ ViewName: self.context.GetViewName(),
+ Key: gocui.MouseWheelUp,
+ Handler: func(gocui.ViewMouseBindingOpts) error { return self.HandleScrollUp() },
+ },
+ {
+ ViewName: self.context.GetViewName(),
+ Key: gocui.MouseLeft,
+ Handler: func(opts gocui.ViewMouseBindingOpts) error { return self.HandleClick(opts) },
+ },
+ {
+ ViewName: self.context.GetViewName(),
+ Key: gocui.MouseWheelDown,
+ Handler: func(gocui.ViewMouseBindingOpts) error { return self.HandleScrollDown() },
+ },
+ }
+}
diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go
new file mode 100644
index 000000000..6539a13af
--- /dev/null
+++ b/pkg/gui/controllers/local_commits_controller.go
@@ -0,0 +1,717 @@
+package controllers
+
+import (
+ "fmt"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type (
+ PullFilesFn func() error
+)
+
+type LocalCommitsController struct {
+ baseController
+ *controllerCommon
+
+ pullFiles PullFilesFn
+}
+
+var _ types.IController = &LocalCommitsController{}
+
+func NewLocalCommitsController(
+ common *controllerCommon,
+ pullFiles PullFilesFn,
+) *LocalCommitsController {
+ return &LocalCommitsController{
+ baseController: baseController{},
+ controllerCommon: common,
+ pullFiles: pullFiles,
+ }
+}
+
+func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ outsideFilterModeBindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Commits.SquashDown),
+ Handler: self.checkSelected(self.squashDown),
+ Description: self.c.Tr.LcSquashDown,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.MarkCommitAsFixup),
+ Handler: self.checkSelected(self.fixup),
+ Description: self.c.Tr.LcFixupCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.RenameCommit),
+ Handler: self.checkSelected(self.reword),
+ Description: self.c.Tr.LcRewordCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.RenameCommitWithEditor),
+ Handler: self.checkSelected(self.rewordEditor),
+ Description: self.c.Tr.LcRenameCommitEditor,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Remove),
+ Handler: self.checkSelected(self.drop),
+ Description: self.c.Tr.LcDeleteCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Edit),
+ Handler: self.checkSelected(self.edit),
+ Description: self.c.Tr.LcEditCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.PickCommit),
+ Handler: self.checkSelected(self.pick),
+ Description: self.c.Tr.LcPickCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.CreateFixupCommit),
+ Handler: self.checkSelected(self.createFixupCommit),
+ Description: self.c.Tr.LcCreateFixupCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.SquashAboveCommits),
+ Handler: self.checkSelected(self.squashAllAboveFixupCommits),
+ Description: self.c.Tr.LcSquashAboveCommits,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.MoveDownCommit),
+ Handler: self.checkSelected(self.moveDown),
+ Description: self.c.Tr.LcMoveDownCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.MoveUpCommit),
+ Handler: self.checkSelected(self.moveUp),
+ Description: self.c.Tr.LcMoveUpCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.PasteCommits),
+ Handler: opts.Guards.OutsideFilterMode(self.paste),
+ Description: self.c.Tr.LcPasteCommits,
+ },
+ // overriding these navigation keybindings because we might need to load
+ // more commits on demand
+ {
+ Key: opts.GetKey(opts.Config.Universal.StartSearch),
+ Handler: self.openSearch,
+ Description: self.c.Tr.LcStartSearch,
+ Tag: "navigation",
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.GotoBottom),
+ Handler: self.gotoBottom,
+ Description: self.c.Tr.LcGotoBottom,
+ Tag: "navigation",
+ },
+ }
+
+ for _, binding := range outsideFilterModeBindings {
+ binding.Handler = opts.Guards.OutsideFilterMode(binding.Handler)
+ }
+
+ bindings := append(outsideFilterModeBindings, []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Commits.AmendToCommit),
+ Handler: self.checkSelected(self.amendTo),
+ Description: self.c.Tr.LcAmendToCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.ResetCommitAuthor),
+ Handler: self.checkSelected(self.amendAttribute),
+ Description: self.c.Tr.LcResetCommitAuthor,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.RevertCommit),
+ Handler: self.checkSelected(self.revert),
+ Description: self.c.Tr.LcRevertCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.TagCommit),
+ Handler: self.checkSelected(self.createTag),
+ Description: self.c.Tr.LcTagCommit,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.OpenLogMenu),
+ Handler: self.handleOpenLogMenu,
+ Description: self.c.Tr.LcOpenLogMenu,
+ OpensMenu: true,
+ },
+ }...)
+
+ return bindings
+}
+
+func (self *LocalCommitsController) squashDown(commit *models.Commit) error {
+ if len(self.model.Commits) <= 1 {
+ return self.c.ErrorMsg(self.c.Tr.YouNoCommitsToSquash)
+ }
+
+ applied, err := self.handleMidRebaseCommand("squash", commit)
+ if err != nil {
+ return err
+ }
+ if applied {
+ return nil
+ }
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.Squash,
+ Prompt: self.c.Tr.SureSquashThisCommit,
+ HandleConfirm: func() error {
+ return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.SquashCommitDown)
+ return self.interactiveRebase("squash")
+ })
+ },
+ })
+}
+
+func (self *LocalCommitsController) fixup(commit *models.Commit) error {
+ if len(self.model.Commits) <= 1 {
+ return self.c.ErrorMsg(self.c.Tr.YouNoCommitsToSquash)
+ }
+
+ applied, err := self.handleMidRebaseCommand("fixup", commit)
+ if err != nil {
+ return err
+ }
+ if applied {
+ return nil
+ }
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.Fixup,
+ Prompt: self.c.Tr.SureFixupThisCommit,
+ HandleConfirm: func() error {
+ return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.FixupCommit)
+ return self.interactiveRebase("fixup")
+ })
+ },
+ })
+}
+
+func (self *LocalCommitsController) reword(commit *models.Commit) error {
+ applied, err := self.handleMidRebaseCommand("reword", commit)
+ if err != nil {
+ return err
+ }
+ if applied {
+ return nil
+ }
+
+ message, err := self.git.Commit.GetCommitMessage(commit.Sha)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ // TODO: use the commit message panel here
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.LcRewordCommit,
+ InitialContent: message,
+ HandleConfirm: func(response string) error {
+ self.c.LogAction(self.c.Tr.Actions.RewordCommit)
+ if err := self.git.Rebase.RewordCommit(self.model.Commits, self.context().GetSelectedLineIdx(), response); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+ },
+ })
+}
+
+func (self *LocalCommitsController) rewordEditor(commit *models.Commit) error {
+ midRebase, err := self.handleMidRebaseCommand("reword", commit)
+ if err != nil {
+ return err
+ }
+ if midRebase {
+ return nil
+ }
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.RewordInEditorTitle,
+ Prompt: self.c.Tr.RewordInEditorPrompt,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.RewordCommit)
+
+ if self.context().GetSelectedLineIdx() == 0 {
+ return self.c.RunSubprocessAndRefresh(self.os.Cmd.New("git commit --allow-empty --amend --only"))
+ }
+
+ subProcess, err := self.git.Rebase.RewordCommitInEditor(
+ self.model.Commits, self.context().GetSelectedLineIdx(),
+ )
+ if err != nil {
+ return self.c.Error(err)
+ }
+ if subProcess != nil {
+ return self.c.RunSubprocessAndRefresh(subProcess)
+ }
+
+ return nil
+ },
+ })
+}
+
+func (self *LocalCommitsController) drop(commit *models.Commit) error {
+ applied, err := self.handleMidRebaseCommand("drop", commit)
+ if err != nil {
+ return err
+ }
+ if applied {
+ return nil
+ }
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.DeleteCommitTitle,
+ Prompt: self.c.Tr.DeleteCommitPrompt,
+ HandleConfirm: func() error {
+ return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.DropCommit)
+ return self.interactiveRebase("drop")
+ })
+ },
+ })
+}
+
+func (self *LocalCommitsController) edit(commit *models.Commit) error {
+ applied, err := self.handleMidRebaseCommand("edit", commit)
+ if err != nil {
+ return err
+ }
+ if applied {
+ return nil
+ }
+
+ return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.EditCommit)
+ return self.interactiveRebase("edit")
+ })
+}
+
+func (self *LocalCommitsController) pick(commit *models.Commit) error {
+ applied, err := self.handleMidRebaseCommand("pick", commit)
+ if err != nil {
+ return err
+ }
+ if applied {
+ return nil
+ }
+
+ // at this point we aren't actually rebasing so we will interpret this as an
+ // attempt to pull. We might revoke this later after enabling configurable keybindings
+ return self.pullFiles()
+}
+
+func (self *LocalCommitsController) interactiveRebase(action string) error {
+ err := self.git.Rebase.InteractiveRebase(self.model.Commits, self.context().GetSelectedLineIdx(), action)
+ return self.helpers.MergeAndRebase.CheckMergeOrRebase(err)
+}
+
+// handleMidRebaseCommand sees if the selected commit is in fact a rebasing
+// commit meaning you are trying to edit the todo file rather than actually
+// begin a rebase. It then updates the todo file with that action
+func (self *LocalCommitsController) handleMidRebaseCommand(action string, commit *models.Commit) (bool, error) {
+ if commit.Status != "rebasing" {
+ return false, nil
+ }
+
+ // for now we do not support setting 'reword' because it requires an editor
+ // and that means we either unconditionally wait around for the subprocess to ask for
+ // our input or we set a lazygit client as the EDITOR env variable and have it
+ // request us to edit the commit message when prompted.
+ if action == "reword" {
+ return true, self.c.ErrorMsg(self.c.Tr.LcRewordNotSupported)
+ }
+
+ self.c.LogAction("Update rebase TODO")
+ self.c.LogCommand(
+ fmt.Sprintf("Updating rebase action of commit %s to '%s'", commit.ShortSha(), action),
+ false,
+ )
+
+ if err := self.git.Rebase.EditRebaseTodo(
+ self.context().GetSelectedLineIdx(), action,
+ ); err != nil {
+ return false, self.c.Error(err)
+ }
+
+ return true, self.c.Refresh(types.RefreshOptions{
+ Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS},
+ })
+}
+
+func (self *LocalCommitsController) moveDown(commit *models.Commit) error {
+ index := self.context().GetSelectedLineIdx()
+ commits := self.model.Commits
+ if commit.Status == "rebasing" {
+ if commits[index+1].Status != "rebasing" {
+ return nil
+ }
+
+ // logging directly here because MoveTodoDown doesn't have enough information
+ // to provide a useful log
+ self.c.LogAction(self.c.Tr.Actions.MoveCommitDown)
+ self.c.LogCommand(fmt.Sprintf("Moving commit %s down", commit.ShortSha()), false)
+
+ if err := self.git.Rebase.MoveTodoDown(index); err != nil {
+ return self.c.Error(err)
+ }
+ self.context().MoveSelectedLine(1)
+ return self.c.Refresh(types.RefreshOptions{
+ Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS},
+ })
+ }
+
+ return self.c.WithWaitingStatus(self.c.Tr.MovingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.MoveCommitDown)
+ err := self.git.Rebase.MoveCommitDown(self.model.Commits, index)
+ if err == nil {
+ self.context().MoveSelectedLine(1)
+ }
+ return self.helpers.MergeAndRebase.CheckMergeOrRebase(err)
+ })
+}
+
+func (self *LocalCommitsController) moveUp(commit *models.Commit) error {
+ index := self.context().GetSelectedLineIdx()
+ if index == 0 {
+ return nil
+ }
+
+ if commit.Status == "rebasing" {
+ // logging directly here because MoveTodoDown doesn't have enough information
+ // to provide a useful log
+ self.c.LogAction(self.c.Tr.Actions.MoveCommitUp)
+ self.c.LogCommand(
+ fmt.Sprintf("Moving commit %s up", commit.ShortSha()),
+ false,
+ )
+
+ if err := self.git.Rebase.MoveTodoDown(index - 1); err != nil {
+ return self.c.Error(err)
+ }
+ self.context().MoveSelectedLine(-1)
+ return self.c.Refresh(types.RefreshOptions{
+ Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS},
+ })
+ }
+
+ return self.c.WithWaitingStatus(self.c.Tr.MovingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.MoveCommitUp)
+ err := self.git.Rebase.MoveCommitDown(self.model.Commits, index-1)
+ if err == nil {
+ self.context().MoveSelectedLine(-1)
+ }
+ return self.helpers.MergeAndRebase.CheckMergeOrRebase(err)
+ })
+}
+
+func (self *LocalCommitsController) amendTo(commit *models.Commit) error {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.AmendCommitTitle,
+ Prompt: self.c.Tr.AmendCommitPrompt,
+ HandleConfirm: func() error {
+ return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.AmendCommit)
+ err := self.git.Rebase.AmendTo(commit.Sha)
+ return self.helpers.MergeAndRebase.CheckMergeOrRebase(err)
+ })
+ },
+ })
+}
+
+func (self *LocalCommitsController) amendAttribute(commit *models.Commit) error {
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: "Amend commit attribute",
+ Items: []*types.MenuItem{
+ {
+ Label: "reset author",
+ OnPress: self.resetAuthor,
+ Key: 'a',
+ Tooltip: "Reset the commit's author to the currently configured user. This will also renew the author timestamp",
+ },
+ {
+ Label: "set author",
+ OnPress: self.setAuthor,
+ Key: 'A',
+ Tooltip: "Set the author based on a prompt",
+ },
+ },
+ })
+}
+
+func (self *LocalCommitsController) resetAuthor() error {
+ return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor)
+ if err := self.git.Rebase.ResetCommitAuthor(self.model.Commits, self.context().GetSelectedLineIdx()); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+ })
+}
+
+func (self *LocalCommitsController) setAuthor() error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.SetAuthorPromptTitle,
+ FindSuggestionsFunc: self.helpers.Suggestions.GetAuthorsSuggestionsFunc(),
+ HandleConfirm: func(value string) error {
+ return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor)
+ if err := self.git.Rebase.SetCommitAuthor(self.model.Commits, self.context().GetSelectedLineIdx(), value); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+ })
+ },
+ })
+}
+
+func (self *LocalCommitsController) revert(commit *models.Commit) error {
+ if commit.IsMerge() {
+ return self.createRevertMergeCommitMenu(commit)
+ } else {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.Actions.RevertCommit,
+ Prompt: utils.ResolvePlaceholderString(
+ self.c.Tr.ConfirmRevertCommit,
+ map[string]string{
+ "selectedCommit": commit.ShortSha(),
+ }),
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.RevertCommit)
+ if err := self.git.Commit.Revert(commit.Sha); err != nil {
+ return self.c.Error(err)
+ }
+ return self.afterRevertCommit()
+ },
+ })
+ }
+}
+
+func (self *LocalCommitsController) createRevertMergeCommitMenu(commit *models.Commit) error {
+ menuItems := make([]*types.MenuItem, len(commit.Parents))
+ for i, parentSha := range commit.Parents {
+ i := i
+ message, err := self.git.Commit.GetCommitMessageFirstLine(parentSha)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ menuItems[i] = &types.MenuItem{
+ Label: fmt.Sprintf("%s: %s", utils.SafeTruncate(parentSha, 8), message),
+ OnPress: func() error {
+ parentNumber := i + 1
+ self.c.LogAction(self.c.Tr.Actions.RevertCommit)
+ if err := self.git.Commit.RevertMerge(commit.Sha, parentNumber); err != nil {
+ return self.c.Error(err)
+ }
+ return self.afterRevertCommit()
+ },
+ }
+ }
+
+ return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.SelectParentCommitForMerge, Items: menuItems})
+}
+
+func (self *LocalCommitsController) afterRevertCommit() error {
+ self.context().MoveSelectedLine(1)
+ return self.c.Refresh(types.RefreshOptions{
+ Mode: types.BLOCK_UI, Scope: []types.RefreshableView{types.COMMITS, types.BRANCHES},
+ })
+}
+
+func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) error {
+ prompt := utils.ResolvePlaceholderString(
+ self.c.Tr.SureCreateFixupCommit,
+ map[string]string{
+ "commit": commit.Sha,
+ },
+ )
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.CreateFixupCommit,
+ Prompt: prompt,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit)
+ if err := self.git.Commit.CreateFixupCommit(commit.Sha); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+ },
+ })
+}
+
+func (self *LocalCommitsController) squashAllAboveFixupCommits(commit *models.Commit) error {
+ prompt := utils.ResolvePlaceholderString(
+ self.c.Tr.SureSquashAboveCommits,
+ map[string]string{"commit": commit.Sha},
+ )
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.SquashAboveCommits,
+ Prompt: prompt,
+ HandleConfirm: func() error {
+ return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits)
+ err := self.git.Rebase.SquashAllAboveFixupCommits(commit.Sha)
+ return self.helpers.MergeAndRebase.CheckMergeOrRebase(err)
+ })
+ },
+ })
+}
+
+func (self *LocalCommitsController) createTag(commit *models.Commit) error {
+ return self.helpers.Tags.CreateTagMenu(commit.Sha, func() {})
+}
+
+func (self *LocalCommitsController) openSearch() error {
+ // we usually lazyload these commits but now that we're searching we need to load them now
+ if self.context().GetLimitCommits() {
+ self.context().SetLimitCommits(false)
+ if err := self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS}}); err != nil {
+ return err
+ }
+ }
+
+ self.c.OpenSearch()
+
+ return nil
+}
+
+func (self *LocalCommitsController) gotoBottom() error {
+ // we usually lazyload these commits but now that we're jumping to the bottom we need to load them now
+ if self.context().GetLimitCommits() {
+ self.context().SetLimitCommits(false)
+ if err := self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}}); err != nil {
+ return err
+ }
+ }
+
+ self.context().SetSelectedLineIdx(self.context().Len() - 1)
+
+ return nil
+}
+
+func (self *LocalCommitsController) handleOpenLogMenu() error {
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: self.c.Tr.LogMenuTitle,
+ Items: []*types.MenuItem{
+ {
+ Label: self.c.Tr.ToggleShowGitGraphAll,
+ OnPress: func() error {
+ self.context().SetShowWholeGitGraph(!self.context().GetShowWholeGitGraph())
+
+ if self.context().GetShowWholeGitGraph() {
+ self.context().SetLimitCommits(false)
+ }
+
+ return self.c.WithWaitingStatus(self.c.Tr.LcLoadingCommits, func() error {
+ return self.c.Refresh(
+ types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}},
+ )
+ })
+ },
+ },
+ {
+ Label: self.c.Tr.ShowGitGraph,
+ OpensMenu: true,
+ OnPress: func() error {
+ onPress := func(value string) func() error {
+ return func() error {
+ self.c.UserConfig.Git.Log.ShowGraph = value
+ return nil
+ }
+ }
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: self.c.Tr.LogMenuTitle,
+ Items: []*types.MenuItem{
+ {
+ Label: "always",
+ OnPress: onPress("always"),
+ },
+ {
+ Label: "never",
+ OnPress: onPress("never"),
+ },
+ {
+ Label: "when maximised",
+ OnPress: onPress("when-maximised"),
+ },
+ },
+ })
+ },
+ },
+ {
+ Label: self.c.Tr.SortCommits,
+ OpensMenu: true,
+ OnPress: func() error {
+ onPress := func(value string) func() error {
+ return func() error {
+ self.c.UserConfig.Git.Log.Order = value
+ return self.c.WithWaitingStatus(self.c.Tr.LcLoadingCommits, func() error {
+ return self.c.Refresh(
+ types.RefreshOptions{
+ Mode: types.SYNC,
+ Scope: []types.RefreshableView{types.COMMITS},
+ },
+ )
+ })
+ }
+ }
+
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: self.c.Tr.LogMenuTitle,
+ Items: []*types.MenuItem{
+ {
+ Label: "topological (topo-order)",
+ OnPress: onPress("topo-order"),
+ },
+ {
+ Label: "date-order",
+ OnPress: onPress("date-order"),
+ },
+ {
+ Label: "author-date-order",
+ OnPress: onPress("author-date-order"),
+ },
+ },
+ })
+ },
+ },
+ },
+ })
+}
+
+func (self *LocalCommitsController) checkSelected(callback func(*models.Commit) error) func() error {
+ return func() error {
+ commit := self.context().GetSelected()
+ if commit == nil {
+ return nil
+ }
+
+ return callback(commit)
+ }
+}
+
+func (self *LocalCommitsController) Context() types.Context {
+ return self.context()
+}
+
+func (self *LocalCommitsController) context() *context.LocalCommitsContext {
+ return self.contexts.LocalCommits
+}
+
+func (self *LocalCommitsController) paste() error {
+ return self.helpers.CherryPick.Paste()
+}
diff --git a/pkg/gui/controllers/menu_controller.go b/pkg/gui/controllers/menu_controller.go
new file mode 100644
index 000000000..9501a0bf2
--- /dev/null
+++ b/pkg/gui/controllers/menu_controller.go
@@ -0,0 +1,65 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type MenuController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &MenuController{}
+
+func NewMenuController(
+ common *controllerCommon,
+) *MenuController {
+ return &MenuController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.Select),
+ Handler: self.press,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Confirm),
+ Handler: self.press,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.ConfirmAlt1),
+ Handler: self.press,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Return),
+ Handler: self.close,
+ },
+ }
+
+ return bindings
+}
+
+func (self *MenuController) GetOnClick() func() error {
+ return self.press
+}
+
+func (self *MenuController) press() error {
+ return self.context().OnMenuPress(self.context().GetSelected())
+}
+
+func (self *MenuController) close() error {
+ return self.c.PopContext()
+}
+
+func (self *MenuController) Context() types.Context {
+ return self.context()
+}
+
+func (self *MenuController) context() *context.MenuContext {
+ return self.contexts.Menu
+}
diff --git a/pkg/gui/controllers/merge_conflicts_controller.go b/pkg/gui/controllers/merge_conflicts_controller.go
new file mode 100644
index 000000000..86d18a6a8
--- /dev/null
+++ b/pkg/gui/controllers/merge_conflicts_controller.go
@@ -0,0 +1,317 @@
+package controllers
+
+import (
+ "io/ioutil"
+
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type MergeConflictsController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &MergeConflictsController{}
+
+func NewMergeConflictsController(
+ common *controllerCommon,
+) *MergeConflictsController {
+ return &MergeConflictsController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *MergeConflictsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.Edit),
+ Handler: self.HandleEditFile,
+ Description: self.c.Tr.LcEditFile,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.OpenFile),
+ Handler: self.HandleOpenFile,
+ Description: self.c.Tr.LcOpenFile,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.PrevBlock),
+ Handler: self.withRenderAndFocus(self.PrevConflict),
+ Description: self.c.Tr.PrevConflict,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.NextBlock),
+ Handler: self.withRenderAndFocus(self.NextConflict),
+ Description: self.c.Tr.NextConflict,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.PrevItem),
+ Handler: self.withRenderAndFocus(self.PrevConflictHunk),
+ Description: self.c.Tr.SelectPrevHunk,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.NextItem),
+ Handler: self.withRenderAndFocus(self.NextConflictHunk),
+ Description: self.c.Tr.SelectNextHunk,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.PrevBlockAlt),
+ Handler: self.withRenderAndFocus(self.PrevConflict),
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.NextBlockAlt),
+ Handler: self.withRenderAndFocus(self.NextConflict),
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.PrevItemAlt),
+ Handler: self.withRenderAndFocus(self.PrevConflictHunk),
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.NextItemAlt),
+ Handler: self.withRenderAndFocus(self.NextConflictHunk),
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.ScrollLeft),
+ Handler: self.withRenderAndFocus(self.HandleScrollLeft),
+ Description: self.c.Tr.LcScrollLeft,
+ Tag: "navigation",
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.ScrollRight),
+ Handler: self.withRenderAndFocus(self.HandleScrollRight),
+ Description: self.c.Tr.LcScrollRight,
+ Tag: "navigation",
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Undo),
+ Handler: self.withRenderAndFocus(self.HandleUndo),
+ Description: self.c.Tr.LcUndo,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Files.OpenMergeTool),
+ Handler: self.helpers.WorkingTree.OpenMergeTool,
+ Description: self.c.Tr.LcOpenMergeTool,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Select),
+ Handler: self.withRenderAndFocus(self.HandlePickHunk),
+ Description: self.c.Tr.PickHunk,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Main.PickBothHunks),
+ Handler: self.withRenderAndFocus(self.HandlePickAllHunks),
+ Description: self.c.Tr.PickAllHunks,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Return),
+ Handler: self.Escape,
+ Description: self.c.Tr.ReturnToFilesPanel,
+ },
+ }
+
+ return bindings
+}
+
+func (self *MergeConflictsController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
+ return []*gocui.ViewMouseBinding{
+ {
+ ViewName: self.context().GetViewName(),
+ Key: gocui.MouseWheelUp,
+ Handler: func(gocui.ViewMouseBindingOpts) error {
+ return self.HandleScrollUp()
+ },
+ },
+ {
+ ViewName: self.context().GetViewName(),
+ Key: gocui.MouseWheelDown,
+ Handler: func(gocui.ViewMouseBindingOpts) error {
+ return self.HandleScrollDown()
+ },
+ },
+ }
+}
+
+func (self *MergeConflictsController) HandleScrollUp() error {
+ self.context().SetUserScrolling(true)
+ self.context().GetViewTrait().ScrollUp(self.c.UserConfig.Gui.ScrollHeight)
+
+ return nil
+}
+
+func (self *MergeConflictsController) HandleScrollDown() error {
+ self.context().SetUserScrolling(true)
+ self.context().GetViewTrait().ScrollDown(self.c.UserConfig.Gui.ScrollHeight)
+
+ return nil
+}
+
+func (self *MergeConflictsController) Context() types.Context {
+ return self.context()
+}
+
+func (self *MergeConflictsController) context() *context.MergeConflictsContext {
+ return self.contexts.MergeConflicts
+}
+
+func (self *MergeConflictsController) Escape() error {
+ return self.c.PushContext(self.contexts.Files)
+}
+
+func (self *MergeConflictsController) HandleEditFile() error {
+ lineNumber := self.context().GetState().GetSelectedLine()
+ return self.helpers.Files.EditFileAtLine(self.context().GetState().GetPath(), lineNumber)
+}
+
+func (self *MergeConflictsController) HandleOpenFile() error {
+ lineNumber := self.context().GetState().GetSelectedLine()
+ return self.helpers.Files.OpenFileAtLine(self.context().GetState().GetPath(), lineNumber)
+}
+
+func (self *MergeConflictsController) HandleScrollLeft() error {
+ self.context().GetViewTrait().ScrollLeft()
+
+ return nil
+}
+
+func (self *MergeConflictsController) HandleScrollRight() error {
+ self.context().GetViewTrait().ScrollRight()
+
+ return nil
+}
+
+func (self *MergeConflictsController) HandleUndo() error {
+ state := self.context().GetState()
+
+ ok := state.Undo()
+ if !ok {
+ return nil
+ }
+
+ self.c.LogAction("Restoring file to previous state")
+ self.c.LogCommand("Undoing last conflict resolution", false)
+ if err := ioutil.WriteFile(state.GetPath(), []byte(state.GetContent()), 0o644); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (self *MergeConflictsController) PrevConflictHunk() error {
+ self.context().SetUserScrolling(false)
+ self.context().GetState().SelectPrevConflictHunk()
+
+ return nil
+}
+
+func (self *MergeConflictsController) NextConflictHunk() error {
+ self.context().SetUserScrolling(false)
+ self.context().GetState().SelectNextConflictHunk()
+
+ return nil
+}
+
+func (self *MergeConflictsController) NextConflict() error {
+ self.context().SetUserScrolling(false)
+ self.context().GetState().SelectNextConflict()
+
+ return nil
+}
+
+func (self *MergeConflictsController) PrevConflict() error {
+ self.context().SetUserScrolling(false)
+ self.context().GetState().SelectPrevConflict()
+
+ return nil
+}
+
+func (self *MergeConflictsController) HandlePickHunk() error {
+ return self.pickSelection(self.context().GetState().Selection())
+}
+
+func (self *MergeConflictsController) HandlePickAllHunks() error {
+ return self.pickSelection(mergeconflicts.ALL)
+}
+
+func (self *MergeConflictsController) pickSelection(selection mergeconflicts.Selection) error {
+ ok, err := self.resolveConflict(selection)
+ if err != nil {
+ return err
+ }
+
+ if !ok {
+ return nil
+ }
+
+ if self.context().GetState().AllConflictsResolved() {
+ return self.onLastConflictResolved()
+ }
+
+ return nil
+}
+
+func (self *MergeConflictsController) resolveConflict(selection mergeconflicts.Selection) (bool, error) {
+ self.context().SetUserScrolling(false)
+
+ state := self.context().GetState()
+
+ ok, content, err := state.ContentAfterConflictResolve(selection)
+ if err != nil {
+ return false, err
+ }
+
+ if !ok {
+ return false, nil
+ }
+
+ var logStr string
+ switch selection {
+ case mergeconflicts.TOP:
+ logStr = "Picking top hunk"
+ case mergeconflicts.MIDDLE:
+ logStr = "Picking middle hunk"
+ case mergeconflicts.BOTTOM:
+ logStr = "Picking bottom hunk"
+ case mergeconflicts.ALL:
+ logStr = "Picking all hunks"
+ }
+ self.c.LogAction("Resolve merge conflict")
+ self.c.LogCommand(logStr, false)
+ state.PushContent(content)
+ return true, ioutil.WriteFile(state.GetPath(), []byte(content), 0o644)
+}
+
+func (self *MergeConflictsController) onLastConflictResolved() error {
+ // as part of refreshing files, we handle the situation where a file has had
+ // its merge conflicts resolved.
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
+}
+
+func (self *MergeConflictsController) isFocused() bool {
+ return self.c.CurrentContext().GetKey() == self.context().GetKey()
+}
+
+func (self *MergeConflictsController) withRenderAndFocus(f func() error) func() error {
+ return self.withLock(func() error {
+ if err := f(); err != nil {
+ return err
+ }
+
+ return self.context().RenderAndFocus(self.isFocused())
+ })
+}
+
+func (self *MergeConflictsController) 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()
+ }
+}
diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go
new file mode 100644
index 000000000..798472f7f
--- /dev/null
+++ b/pkg/gui/controllers/patch_building_controller.go
@@ -0,0 +1,138 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/samber/lo"
+)
+
+type PatchBuildingController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &PatchBuildingController{}
+
+func NewPatchBuildingController(
+ common *controllerCommon,
+) *PatchBuildingController {
+ return &PatchBuildingController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *PatchBuildingController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ return []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.OpenFile),
+ Handler: self.OpenFile,
+ Description: self.c.Tr.LcOpenFile,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Edit),
+ Handler: self.EditFile,
+ Description: self.c.Tr.LcEditFile,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Select),
+ Handler: self.ToggleSelectionAndRefresh,
+ Description: self.c.Tr.ToggleSelectionForPatch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Return),
+ Handler: self.Escape,
+ Description: self.c.Tr.ExitCustomPatchBuilder,
+ },
+ }
+}
+
+func (self *PatchBuildingController) Context() types.Context {
+ return self.contexts.CustomPatchBuilder
+}
+
+func (self *PatchBuildingController) context() types.IPatchExplorerContext {
+ return self.contexts.CustomPatchBuilder
+}
+
+func (self *PatchBuildingController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
+ return []*gocui.ViewMouseBinding{}
+}
+
+func (self *PatchBuildingController) OpenFile() error {
+ self.context().GetMutex().Lock()
+ defer self.context().GetMutex().Unlock()
+
+ path := self.contexts.CommitFiles.GetSelectedPath()
+
+ if path == "" {
+ return nil
+ }
+
+ lineNumber := self.context().GetState().CurrentLineNumber()
+ return self.helpers.Files.OpenFileAtLine(path, lineNumber)
+}
+
+func (self *PatchBuildingController) EditFile() error {
+ self.context().GetMutex().Lock()
+ defer self.context().GetMutex().Unlock()
+
+ path := self.contexts.CommitFiles.GetSelectedPath()
+
+ if path == "" {
+ return nil
+ }
+
+ lineNumber := self.context().GetState().CurrentLineNumber()
+ return self.helpers.Files.EditFileAtLine(path, lineNumber)
+}
+
+func (self *PatchBuildingController) ToggleSelectionAndRefresh() error {
+ if err := self.toggleSelection(); err != nil {
+ return err
+ }
+
+ return self.c.Refresh(types.RefreshOptions{
+ Scope: []types.RefreshableView{types.PATCH_BUILDING, types.COMMIT_FILES},
+ })
+}
+
+func (self *PatchBuildingController) toggleSelection() error {
+ self.context().GetMutex().Lock()
+ defer self.context().GetMutex().Unlock()
+
+ toggleFunc := self.git.Patch.PatchManager.AddFileLineRange
+ filename := self.contexts.CommitFiles.GetSelectedPath()
+ if filename == "" {
+ return nil
+ }
+
+ state := self.context().GetState()
+
+ includedLineIndices, err := self.git.Patch.PatchManager.GetFileIncLineIndices(filename)
+ if err != nil {
+ return err
+ }
+ currentLineIsStaged := lo.Contains(includedLineIndices, state.GetSelectedLineIdx())
+ if currentLineIsStaged {
+ toggleFunc = self.git.Patch.PatchManager.RemoveFileLineRange
+ }
+
+ // add range of lines to those set for the file
+ firstLineIdx, lastLineIdx := state.SelectedRange()
+
+ if err := toggleFunc(filename, firstLineIdx, lastLineIdx); err != nil {
+ // might actually want to return an error here
+ self.c.Log.Error(err)
+ }
+
+ if state.SelectingRange() {
+ state.SetLineSelectMode()
+ }
+
+ return nil
+}
+
+func (self *PatchBuildingController) Escape() error {
+ return self.helpers.PatchBuilding.Escape()
+}
diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go
new file mode 100644
index 000000000..dac63a7b1
--- /dev/null
+++ b/pkg/gui/controllers/patch_explorer_controller.go
@@ -0,0 +1,289 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type PatchExplorerControllerFactory struct {
+ *controllerCommon
+}
+
+func NewPatchExplorerControllerFactory(c *controllerCommon) *PatchExplorerControllerFactory {
+ return &PatchExplorerControllerFactory{
+ controllerCommon: c,
+ }
+}
+
+func (self *PatchExplorerControllerFactory) Create(context types.IPatchExplorerContext) *PatchExplorerController {
+ return &PatchExplorerController{
+ baseController: baseController{},
+ controllerCommon: self.controllerCommon,
+ context: context,
+ }
+}
+
+type PatchExplorerController struct {
+ baseController
+ *controllerCommon
+
+ context types.IPatchExplorerContext
+}
+
+func (self *PatchExplorerController) Context() types.Context {
+ return self.context
+}
+
+func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ return []*types.Binding{
+ {
+ Tag: "navigation",
+ Key: opts.GetKey(opts.Config.Universal.PrevItemAlt),
+ Handler: self.withRenderAndFocus(self.HandlePrevLine),
+ },
+ {
+ Tag: "navigation",
+ Key: opts.GetKey(opts.Config.Universal.PrevItem),
+ Handler: self.withRenderAndFocus(self.HandlePrevLine),
+ },
+ {
+ Tag: "navigation",
+ Key: opts.GetKey(opts.Config.Universal.NextItemAlt),
+ Handler: self.withRenderAndFocus(self.HandleNextLine),
+ },
+ {
+ Tag: "navigation",
+ Key: opts.GetKey(opts.Config.Universal.NextItem),
+ Handler: self.withRenderAndFocus(self.HandleNextLine),
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.PrevBlock),
+ Handler: self.withRenderAndFocus(self.HandlePrevHunk),
+ Description: self.c.Tr.PrevHunk,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.PrevBlockAlt),
+ Handler: self.withRenderAndFocus(self.HandlePrevHunk),
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.NextBlock),
+ Handler: self.withRenderAndFocus(self.HandleNextHunk),
+ Description: self.c.Tr.NextHunk,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.NextBlockAlt),
+ Handler: self.withRenderAndFocus(self.HandleNextHunk),
+ },
+ {
+ Key: opts.GetKey(opts.Config.Main.ToggleDragSelect),
+ Handler: self.withRenderAndFocus(self.HandleToggleSelectRange),
+ Description: self.c.Tr.ToggleDragSelect,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Main.ToggleDragSelectAlt),
+ Handler: self.withRenderAndFocus(self.HandleToggleSelectRange),
+ Description: self.c.Tr.ToggleDragSelect,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Main.ToggleSelectHunk),
+ Handler: self.withRenderAndFocus(self.HandleToggleSelectHunk),
+ Description: self.c.Tr.ToggleSelectHunk,
+ },
+ {
+ Tag: "navigation",
+ Key: opts.GetKey(opts.Config.Universal.PrevPage),
+ Handler: self.withRenderAndFocus(self.HandlePrevPage),
+ Description: self.c.Tr.LcPrevPage,
+ },
+ {
+ Tag: "navigation",
+ Key: opts.GetKey(opts.Config.Universal.NextPage),
+ Handler: self.withRenderAndFocus(self.HandleNextPage),
+ Description: self.c.Tr.LcNextPage,
+ },
+ {
+ Tag: "navigation",
+ Key: opts.GetKey(opts.Config.Universal.GotoTop),
+ Handler: self.withRenderAndFocus(self.HandleGotoTop),
+ Description: self.c.Tr.LcGotoTop,
+ },
+ {
+ Tag: "navigation",
+ Key: opts.GetKey(opts.Config.Universal.GotoBottom),
+ Description: self.c.Tr.LcGotoBottom,
+ Handler: self.withRenderAndFocus(self.HandleGotoBottom),
+ },
+ {
+ Tag: "navigation",
+ Key: opts.GetKey(opts.Config.Universal.ScrollLeft),
+ Handler: self.withRenderAndFocus(self.HandleScrollLeft),
+ },
+ {
+ Tag: "navigation",
+ Key: opts.GetKey(opts.Config.Universal.ScrollRight),
+ Handler: self.withRenderAndFocus(self.HandleScrollRight),
+ },
+ {
+ Tag: "navigation",
+ Key: opts.GetKey(opts.Config.Universal.StartSearch),
+ Handler: func() error { self.c.OpenSearch(); return nil },
+ Description: self.c.Tr.LcStartSearch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.CopyToClipboard),
+ Handler: self.withLock(self.CopySelectedToClipboard),
+ Description: self.c.Tr.LcCopySelectedTexToClipboard,
+ },
+ }
+}
+
+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)()
+ }
+
+ return self.c.PushContext(self.context, types.OnFocusOpts{
+ ClickedWindowName: self.context.GetWindowName(),
+ ClickedViewLineIdx: opts.Y,
+ })
+ },
+ },
+ {
+ ViewName: self.context.GetViewName(),
+ Key: gocui.MouseLeft,
+ Modifier: gocui.ModMotion,
+ Handler: func(gocui.ViewMouseBindingOpts) error {
+ return self.withRenderAndFocus(self.HandleMouseDrag)()
+ },
+ },
+ }
+}
+
+func (self *PatchExplorerController) HandlePrevLine() error {
+ self.context.GetState().CycleSelection(false)
+
+ return nil
+}
+
+func (self *PatchExplorerController) HandleNextLine() error {
+ self.context.GetState().CycleSelection(true)
+
+ return nil
+}
+
+func (self *PatchExplorerController) HandlePrevHunk() error {
+ self.context.GetState().CycleHunk(false)
+
+ return nil
+}
+
+func (self *PatchExplorerController) HandleNextHunk() error {
+ self.context.GetState().CycleHunk(true)
+
+ return nil
+}
+
+func (self *PatchExplorerController) HandleToggleSelectRange() error {
+ self.context.GetState().ToggleSelectRange()
+
+ 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().SetLineSelectMode()
+ self.context.GetState().AdjustSelectedLineIdx(-self.context.GetViewTrait().PageDelta())
+
+ return nil
+}
+
+func (self *PatchExplorerController) HandleNextPage() error {
+ self.context.GetState().SetLineSelectMode()
+ 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) HandleMouseDrag() error {
+ self.context.GetState().SelectLine(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.os.CopyToClipboard(selected); err != nil {
+ return self.c.Error(err)
+ }
+
+ return nil
+}
+
+func (self *PatchExplorerController) isFocused() bool {
+ return self.c.CurrentContext().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
+ }
+
+ return self.context.RenderAndFocus(self.isFocused())
+ })
+}
+
+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()
+ }
+}
diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go
new file mode 100644
index 000000000..dcedde8c0
--- /dev/null
+++ b/pkg/gui/controllers/remote_branches_controller.go
@@ -0,0 +1,161 @@
+package controllers
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type RemoteBranchesController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &RemoteBranchesController{}
+
+func NewRemoteBranchesController(
+ common *controllerCommon,
+) *RemoteBranchesController {
+ return &RemoteBranchesController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ return []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.Select),
+ // gonna use the exact same handler as the 'n' keybinding because everybody wants this to happen when they checkout a remote branch
+ Handler: self.checkSelected(self.newLocalBranch),
+ Description: self.c.Tr.LcCheckout,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.New),
+ Handler: self.checkSelected(self.newLocalBranch),
+ Description: self.c.Tr.LcNewBranch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.MergeIntoCurrentBranch),
+ Handler: opts.Guards.OutsideFilterMode(self.checkSelected(self.merge)),
+ Description: self.c.Tr.LcMergeIntoCurrentBranch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.RebaseBranch),
+ Handler: opts.Guards.OutsideFilterMode(self.checkSelected(self.rebase)),
+ Description: self.c.Tr.LcRebaseBranch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Remove),
+ Handler: self.checkSelected(self.delete),
+ Description: self.c.Tr.LcDeleteBranch,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.SetUpstream),
+ Handler: self.checkSelected(self.setAsUpstream),
+ Description: self.c.Tr.LcSetAsUpstream,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Return),
+ Handler: self.escape,
+ Description: self.c.Tr.ReturnToRemotesList,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.ViewResetOptions),
+ Handler: self.checkSelected(self.createResetMenu),
+ Description: self.c.Tr.LcViewResetOptions,
+ OpensMenu: true,
+ },
+ }
+}
+
+func (self *RemoteBranchesController) Context() types.Context {
+ return self.context()
+}
+
+func (self *RemoteBranchesController) context() *context.RemoteBranchesContext {
+ return self.contexts.RemoteBranches
+}
+
+func (self *RemoteBranchesController) checkSelected(callback func(*models.RemoteBranch) error) func() error {
+ return func() error {
+ selectedItem := self.context().GetSelected()
+ if selectedItem == nil {
+ return nil
+ }
+
+ return callback(selectedItem)
+ }
+}
+
+func (self *RemoteBranchesController) escape() error {
+ return self.c.PushContext(self.contexts.Remotes)
+}
+
+func (self *RemoteBranchesController) delete(selectedBranch *models.RemoteBranch) error {
+ message := fmt.Sprintf("%s '%s'?", self.c.Tr.DeleteRemoteBranchMessage, selectedBranch.FullName())
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.DeleteRemoteBranch,
+ Prompt: message,
+ HandleConfirm: func() error {
+ return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.DeleteRemoteBranch)
+ err := self.git.Remote.DeleteRemoteBranch(selectedBranch.RemoteName, selectedBranch.Name)
+ if err != nil {
+ _ = self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
+ })
+ },
+ })
+}
+
+func (self *RemoteBranchesController) merge(selectedBranch *models.RemoteBranch) error {
+ return self.helpers.MergeAndRebase.MergeRefIntoCheckedOutBranch(selectedBranch.FullName())
+}
+
+func (self *RemoteBranchesController) rebase(selectedBranch *models.RemoteBranch) error {
+ return self.helpers.MergeAndRebase.RebaseOntoRef(selectedBranch.FullName())
+}
+
+func (self *RemoteBranchesController) createResetMenu(selectedBranch *models.RemoteBranch) error {
+ return self.helpers.Refs.CreateGitResetMenu(selectedBranch.FullName())
+}
+
+func (self *RemoteBranchesController) setAsUpstream(selectedBranch *models.RemoteBranch) error {
+ checkedOutBranch := self.helpers.Refs.GetCheckedOutRef()
+
+ message := utils.ResolvePlaceholderString(
+ self.c.Tr.SetUpstreamMessage,
+ map[string]string{
+ "checkedOut": checkedOutBranch.Name,
+ "selected": selectedBranch.FullName(),
+ },
+ )
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.SetUpstreamTitle,
+ Prompt: message,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.SetBranchUpstream)
+ if err := self.git.Branch.SetUpstream(selectedBranch.RemoteName, selectedBranch.Name, checkedOutBranch.Name); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
+ },
+ })
+}
+
+func (self *RemoteBranchesController) newLocalBranch(selectedBranch *models.RemoteBranch) error {
+ // will set to the remote's branch name without the remote name
+ nameSuggestion := strings.SplitAfterN(selectedBranch.RefName(), "/", 2)[1]
+
+ return self.helpers.Refs.NewBranch(selectedBranch.RefName(), selectedBranch.RefName(), nameSuggestion)
+}
diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go
new file mode 100644
index 000000000..03427b9b7
--- /dev/null
+++ b/pkg/gui/controllers/remotes_controller.go
@@ -0,0 +1,189 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type RemotesController struct {
+ baseController
+ *controllerCommon
+ context *context.RemotesContext
+
+ setRemoteBranches func([]*models.RemoteBranch)
+}
+
+var _ types.IController = &RemotesController{}
+
+func NewRemotesController(
+ common *controllerCommon,
+ setRemoteBranches func([]*models.RemoteBranch),
+) *RemotesController {
+ return &RemotesController{
+ baseController: baseController{},
+ controllerCommon: common,
+ context: common.contexts.Remotes,
+ setRemoteBranches: setRemoteBranches,
+ }
+}
+
+func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.GoInto),
+ Handler: self.checkSelected(self.enter),
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.FetchRemote),
+ Handler: self.checkSelected(self.fetch),
+ Description: self.c.Tr.LcFetchRemote,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.New),
+ Handler: self.add,
+ Description: self.c.Tr.LcAddNewRemote,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Remove),
+ Handler: self.checkSelected(self.remove),
+ Description: self.c.Tr.LcRemoveRemote,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Edit),
+ Handler: self.checkSelected(self.edit),
+ Description: self.c.Tr.LcEditRemote,
+ },
+ }
+
+ return bindings
+}
+
+func (self *RemotesController) GetOnClick() func() error {
+ return self.checkSelected(self.enter)
+}
+
+func (self *RemotesController) enter(remote *models.Remote) error {
+ // naive implementation: get the branches from the remote and render them to the list, change the context
+ self.setRemoteBranches(remote.Branches)
+
+ newSelectedLine := 0
+ if len(remote.Branches) == 0 {
+ newSelectedLine = -1
+ }
+ self.contexts.RemoteBranches.SetSelectedLineIdx(newSelectedLine)
+ self.contexts.RemoteBranches.SetTitleRef(remote.Name)
+
+ if err := self.c.PostRefreshUpdate(self.contexts.RemoteBranches); err != nil {
+ return err
+ }
+
+ return self.c.PushContext(self.contexts.RemoteBranches)
+}
+
+func (self *RemotesController) add() error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.LcNewRemoteName,
+ HandleConfirm: func(remoteName string) error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.LcNewRemoteUrl,
+ HandleConfirm: func(remoteUrl string) error {
+ self.c.LogAction(self.c.Tr.Actions.AddRemote)
+ if err := self.git.Remote.AddRemote(remoteName, remoteUrl); err != nil {
+ return err
+ }
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.REMOTES}})
+ },
+ })
+ },
+ })
+}
+
+func (self *RemotesController) remove(remote *models.Remote) error {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.LcRemoveRemote,
+ Prompt: self.c.Tr.LcRemoveRemotePrompt + " '" + remote.Name + "'?",
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.RemoveRemote)
+ if err := self.git.Remote.RemoveRemote(remote.Name); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
+ },
+ })
+}
+
+func (self *RemotesController) edit(remote *models.Remote) error {
+ editNameMessage := utils.ResolvePlaceholderString(
+ self.c.Tr.LcEditRemoteName,
+ map[string]string{
+ "remoteName": remote.Name,
+ },
+ )
+
+ return self.c.Prompt(types.PromptOpts{
+ Title: editNameMessage,
+ InitialContent: remote.Name,
+ HandleConfirm: func(updatedRemoteName string) error {
+ if updatedRemoteName != remote.Name {
+ self.c.LogAction(self.c.Tr.Actions.UpdateRemote)
+ if err := self.git.Remote.RenameRemote(remote.Name, updatedRemoteName); err != nil {
+ return self.c.Error(err)
+ }
+ }
+
+ editUrlMessage := utils.ResolvePlaceholderString(
+ self.c.Tr.LcEditRemoteUrl,
+ map[string]string{
+ "remoteName": updatedRemoteName,
+ },
+ )
+
+ urls := remote.Urls
+ url := ""
+ if len(urls) > 0 {
+ url = urls[0]
+ }
+
+ return self.c.Prompt(types.PromptOpts{
+ Title: editUrlMessage,
+ InitialContent: url,
+ HandleConfirm: func(updatedRemoteUrl string) error {
+ self.c.LogAction(self.c.Tr.Actions.UpdateRemote)
+ if err := self.git.Remote.UpdateRemoteUrl(updatedRemoteName, updatedRemoteUrl); err != nil {
+ return self.c.Error(err)
+ }
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
+ },
+ })
+ },
+ })
+}
+
+func (self *RemotesController) fetch(remote *models.Remote) error {
+ return self.c.WithWaitingStatus(self.c.Tr.FetchingRemoteStatus, func() error {
+ err := self.git.Sync.FetchRemote(remote.Name)
+ if err != nil {
+ _ = self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
+ })
+}
+
+func (self *RemotesController) checkSelected(callback func(*models.Remote) error) func() error {
+ return func() error {
+ file := self.context.GetSelected()
+ if file == nil {
+ return nil
+ }
+
+ return callback(file)
+ }
+}
+
+func (self *RemotesController) Context() types.Context {
+ return self.context
+}
diff --git a/pkg/gui/controllers/staging_controller.go b/pkg/gui/controllers/staging_controller.go
new file mode 100644
index 000000000..d41bb1ff5
--- /dev/null
+++ b/pkg/gui/controllers/staging_controller.go
@@ -0,0 +1,242 @@
+package controllers
+
+import (
+ "strings"
+
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/patch"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type StagingController struct {
+ baseController
+ *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(
+ common *controllerCommon,
+ context types.IPatchExplorerContext,
+ otherContext types.IPatchExplorerContext,
+ staged bool,
+) *StagingController {
+ return &StagingController{
+ baseController: baseController{},
+ controllerCommon: common,
+ context: context,
+ otherContext: otherContext,
+ staged: staged,
+ }
+}
+
+func (self *StagingController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ return []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.OpenFile),
+ Handler: self.OpenFile,
+ Description: self.c.Tr.LcOpenFile,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Edit),
+ Handler: self.EditFile,
+ Description: self.c.Tr.LcEditFile,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Return),
+ Handler: self.Escape,
+ Description: self.c.Tr.ReturnToFilesPanel,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.TogglePanel),
+ Handler: self.TogglePanel,
+ Description: self.c.Tr.ToggleStagingPanel,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Select),
+ Handler: self.ToggleStaged,
+ Description: self.c.Tr.StageSelection,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Remove),
+ Handler: self.ResetSelection,
+ Description: self.c.Tr.ResetSelection,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Main.EditSelectHunk),
+ Handler: self.EditHunkAndRefresh,
+ Description: self.c.Tr.EditHunk,
+ },
+ }
+}
+
+func (self *StagingController) Context() types.Context {
+ return self.context
+}
+
+func (self *StagingController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
+ return []*gocui.ViewMouseBinding{}
+}
+
+func (self *StagingController) OpenFile() error {
+ self.context.GetMutex().Lock()
+ defer self.context.GetMutex().Unlock()
+
+ path := self.FilePath()
+
+ if path == "" {
+ return nil
+ }
+
+ lineNumber := self.context.GetState().CurrentLineNumber()
+ return self.helpers.Files.OpenFileAtLine(path, lineNumber)
+}
+
+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()
+ return self.helpers.Files.EditFileAtLine(path, lineNumber)
+}
+
+func (self *StagingController) Escape() error {
+ return self.c.PushContext(self.contexts.Files)
+}
+
+func (self *StagingController) TogglePanel() error {
+ if self.otherContext.GetState() != nil {
+ return self.c.PushContext(self.otherContext)
+ }
+
+ return nil
+}
+
+func (self *StagingController) ToggleStaged() error {
+ return self.applySelectionAndRefresh(self.staged)
+}
+
+func (self *StagingController) ResetSelection() error {
+ reset := func() error { return self.applySelectionAndRefresh(true) }
+
+ if !self.staged && !self.c.UserConfig.Gui.SkipUnstageLineWarning {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.UnstageLinesTitle,
+ Prompt: self.c.Tr.UnstageLinesPrompt,
+ HandleConfirm: reset,
+ })
+ }
+
+ return reset()
+}
+
+func (self *StagingController) applySelectionAndRefresh(reverse bool) error {
+ if err := self.applySelection(reverse); err != nil {
+ return err
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
+}
+
+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.SelectedRange()
+ patch := patch.ModifiedPatchForRange(self.c.Log, path, state.GetDiff(), firstLineIdx, lastLineIdx, reverse, false)
+
+ if patch == "" {
+ return nil
+ }
+
+ // apply the patch then refresh this panel
+ // create a new temp file with the patch, then call git apply with that patch
+ applyFlags := []string{}
+ if !reverse || self.staged {
+ applyFlags = append(applyFlags, "cached")
+ }
+ self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
+ err := self.git.WorkingTree.ApplyPatch(patch, applyFlags...)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ if state.SelectingRange() {
+ state.SetLineSelectMode()
+ }
+
+ return nil
+}
+
+func (self *StagingController) EditHunkAndRefresh() error {
+ if err := self.editHunk(); err != nil {
+ return err
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
+}
+
+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
+ }
+
+ hunk := state.CurrentHunk()
+ patchText := patch.ModifiedPatchForRange(
+ self.c.Log, path, state.GetDiff(), hunk.FirstLineIdx, hunk.LastLineIdx(), self.staged, false,
+ )
+ patchFilepath, err := self.git.WorkingTree.SaveTemporaryPatch(patchText)
+ if err != nil {
+ return err
+ }
+
+ lineOffset := 3
+ lineIdxInHunk := state.GetSelectedLineIdx() - hunk.FirstLineIdx
+ if err := self.helpers.Files.EditFileAtLine(patchFilepath, lineIdxInHunk+lineOffset); err != nil {
+ return err
+ }
+
+ editedPatchText, err := self.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.ModifiedPatchForRange(
+ self.c.Log, path, editedPatchText, 0, lineCount, false, false,
+ )
+ if err := self.git.WorkingTree.ApplyPatch(newPatchText, "cached"); err != nil {
+ return self.c.Error(err)
+ }
+
+ return nil
+}
+
+func (self *StagingController) FilePath() string {
+ return self.contexts.Files.GetSelectedPath()
+}
diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go
new file mode 100644
index 000000000..6c8e7c349
--- /dev/null
+++ b/pkg/gui/controllers/stash_controller.go
@@ -0,0 +1,141 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type StashController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &StashController{}
+
+func NewStashController(
+ common *controllerCommon,
+) *StashController {
+ return &StashController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *StashController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.Select),
+ Handler: self.checkSelected(self.handleStashApply),
+ Description: self.c.Tr.LcApply,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Stash.PopStash),
+ Handler: self.checkSelected(self.handleStashPop),
+ Description: self.c.Tr.LcPop,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Remove),
+ Handler: self.checkSelected(self.handleStashDrop),
+ Description: self.c.Tr.LcDrop,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.New),
+ Handler: self.checkSelected(self.handleNewBranchOffStashEntry),
+ Description: self.c.Tr.LcNewBranch,
+ },
+ }
+
+ return bindings
+}
+
+func (self *StashController) checkSelected(callback func(*models.StashEntry) error) func() error {
+ return func() error {
+ item := self.context().GetSelected()
+ if item == nil {
+ return nil
+ }
+
+ return callback(item)
+ }
+}
+
+func (self *StashController) Context() types.Context {
+ return self.context()
+}
+
+func (self *StashController) context() *context.StashContext {
+ return self.contexts.Stash
+}
+
+func (self *StashController) handleStashApply(stashEntry *models.StashEntry) error {
+ apply := func() error {
+ self.c.LogAction(self.c.Tr.Actions.Stash)
+ err := self.git.Stash.Apply(stashEntry.Index)
+ _ = self.postStashRefresh()
+ if err != nil {
+ return self.c.Error(err)
+ }
+ return nil
+ }
+
+ if self.c.UserConfig.Gui.SkipStashWarning {
+ return apply()
+ }
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.StashApply,
+ Prompt: self.c.Tr.SureApplyStashEntry,
+ HandleConfirm: func() error {
+ return apply()
+ },
+ })
+}
+
+func (self *StashController) handleStashPop(stashEntry *models.StashEntry) error {
+ pop := func() error {
+ self.c.LogAction(self.c.Tr.Actions.Stash)
+ err := self.git.Stash.Pop(stashEntry.Index)
+ _ = self.postStashRefresh()
+ if err != nil {
+ return self.c.Error(err)
+ }
+ return nil
+ }
+
+ if self.c.UserConfig.Gui.SkipStashWarning {
+ return pop()
+ }
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.StashPop,
+ Prompt: self.c.Tr.SurePopStashEntry,
+ HandleConfirm: func() error {
+ return pop()
+ },
+ })
+}
+
+func (self *StashController) handleStashDrop(stashEntry *models.StashEntry) error {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.StashDrop,
+ Prompt: self.c.Tr.SureDropStashEntry,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.Stash)
+ err := self.git.Stash.Drop(stashEntry.Index)
+ _ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}})
+ if err != nil {
+ return self.c.Error(err)
+ }
+ return nil
+ },
+ })
+}
+
+func (self *StashController) postStashRefresh() error {
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}})
+}
+
+func (self *StashController) handleNewBranchOffStashEntry(stashEntry *models.StashEntry) error {
+ return self.helpers.Refs.NewBranch(stashEntry.RefName(), stashEntry.Description(), "")
+}
diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go
new file mode 100644
index 000000000..5bd7ce088
--- /dev/null
+++ b/pkg/gui/controllers/submodules_controller.go
@@ -0,0 +1,239 @@
+package controllers
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type SubmodulesController struct {
+ baseController
+ *controllerCommon
+
+ enterSubmodule func(submodule *models.SubmoduleConfig) error
+}
+
+var _ types.IController = &SubmodulesController{}
+
+func NewSubmodulesController(
+ controllerCommon *controllerCommon,
+ enterSubmodule func(submodule *models.SubmoduleConfig) error,
+) *SubmodulesController {
+ return &SubmodulesController{
+ baseController: baseController{},
+ controllerCommon: controllerCommon,
+ enterSubmodule: enterSubmodule,
+ }
+}
+
+func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ return []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.GoInto),
+ Handler: self.checkSelected(self.enter),
+ Description: self.c.Tr.LcEnterSubmodule,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Remove),
+ Handler: self.checkSelected(self.remove),
+ Description: self.c.Tr.LcRemoveSubmodule,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Submodules.Update),
+ Handler: self.checkSelected(self.update),
+ Description: self.c.Tr.LcSubmoduleUpdate,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.New),
+ Handler: self.add,
+ Description: self.c.Tr.LcAddSubmodule,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Edit),
+ Handler: self.checkSelected(self.editURL),
+ Description: self.c.Tr.LcEditSubmoduleUrl,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Submodules.Init),
+ Handler: self.checkSelected(self.init),
+ Description: self.c.Tr.LcInitSubmodule,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Submodules.BulkMenu),
+ Handler: self.openBulkActionsMenu,
+ Description: self.c.Tr.LcViewBulkSubmoduleOptions,
+ OpensMenu: true,
+ },
+ }
+}
+
+func (self *SubmodulesController) GetOnClick() func() error {
+ return self.checkSelected(self.enter)
+}
+
+func (self *SubmodulesController) enter(submodule *models.SubmoduleConfig) error {
+ return self.enterSubmodule(submodule)
+}
+
+func (self *SubmodulesController) add() error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.LcNewSubmoduleUrl,
+ HandleConfirm: func(submoduleUrl string) error {
+ nameSuggestion := filepath.Base(strings.TrimSuffix(submoduleUrl, filepath.Ext(submoduleUrl)))
+
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.LcNewSubmoduleName,
+ InitialContent: nameSuggestion,
+ HandleConfirm: func(submoduleName string) error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: self.c.Tr.LcNewSubmodulePath,
+ InitialContent: submoduleName,
+ HandleConfirm: func(submodulePath string) error {
+ return self.c.WithWaitingStatus(self.c.Tr.LcAddingSubmoduleStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.AddSubmodule)
+ err := self.git.Submodule.Add(submoduleName, submodulePath, submoduleUrl)
+ if err != nil {
+ _ = self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
+ })
+ },
+ })
+ },
+ })
+ },
+ })
+}
+
+func (self *SubmodulesController) editURL(submodule *models.SubmoduleConfig) error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: fmt.Sprintf(self.c.Tr.LcUpdateSubmoduleUrl, submodule.Name),
+ InitialContent: submodule.Url,
+ HandleConfirm: func(newUrl string) error {
+ return self.c.WithWaitingStatus(self.c.Tr.LcUpdatingSubmoduleUrlStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.UpdateSubmoduleUrl)
+ err := self.git.Submodule.UpdateUrl(submodule.Name, submodule.Path, newUrl)
+ if err != nil {
+ _ = self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
+ })
+ },
+ })
+}
+
+func (self *SubmodulesController) init(submodule *models.SubmoduleConfig) error {
+ return self.c.WithWaitingStatus(self.c.Tr.LcInitializingSubmoduleStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.InitialiseSubmodule)
+ err := self.git.Submodule.Init(submodule.Path)
+ if err != nil {
+ _ = self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
+ })
+}
+
+func (self *SubmodulesController) openBulkActionsMenu() error {
+ return self.c.Menu(types.CreateMenuOptions{
+ Title: self.c.Tr.LcBulkSubmoduleOptions,
+ Items: []*types.MenuItem{
+ {
+ LabelColumns: []string{self.c.Tr.LcBulkInitSubmodules, style.FgGreen.Sprint(self.git.Submodule.BulkInitCmdObj().ToString())},
+ OnPress: func() error {
+ return self.c.WithWaitingStatus(self.c.Tr.LcRunningCommand, func() error {
+ self.c.LogAction(self.c.Tr.Actions.BulkInitialiseSubmodules)
+ err := self.git.Submodule.BulkInitCmdObj().Run()
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
+ })
+ },
+ Key: 'i',
+ },
+ {
+ LabelColumns: []string{self.c.Tr.LcBulkUpdateSubmodules, style.FgYellow.Sprint(self.git.Submodule.BulkUpdateCmdObj().ToString())},
+ OnPress: func() error {
+ return self.c.WithWaitingStatus(self.c.Tr.LcRunningCommand, func() error {
+ self.c.LogAction(self.c.Tr.Actions.BulkUpdateSubmodules)
+ if err := self.git.Submodule.BulkUpdateCmdObj().Run(); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
+ })
+ },
+ Key: 'u',
+ },
+ {
+ LabelColumns: []string{self.c.Tr.LcBulkDeinitSubmodules, style.FgRed.Sprint(self.git.Submodule.BulkDeinitCmdObj().ToString())},
+ OnPress: func() error {
+ return self.c.WithWaitingStatus(self.c.Tr.LcRunningCommand, func() error {
+ self.c.LogAction(self.c.Tr.Actions.BulkDeinitialiseSubmodules)
+ if err := self.git.Submodule.BulkDeinitCmdObj().Run(); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
+ })
+ },
+ Key: 'd',
+ },
+ },
+ })
+}
+
+func (self *SubmodulesController) update(submodule *models.SubmoduleConfig) error {
+ return self.c.WithWaitingStatus(self.c.Tr.LcUpdatingSubmoduleStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.UpdateSubmodule)
+ err := self.git.Submodule.Update(submodule.Path)
+ if err != nil {
+ _ = self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
+ })
+}
+
+func (self *SubmodulesController) remove(submodule *models.SubmoduleConfig) error {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.RemoveSubmodule,
+ Prompt: fmt.Sprintf(self.c.Tr.RemoveSubmodulePrompt, submodule.Name),
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.RemoveSubmodule)
+ if err := self.git.Submodule.Delete(submodule); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES, types.FILES}})
+ },
+ })
+}
+
+func (self *SubmodulesController) checkSelected(callback func(*models.SubmoduleConfig) error) func() error {
+ return func() error {
+ submodule := self.context().GetSelected()
+ if submodule == nil {
+ return nil
+ }
+
+ return callback(submodule)
+ }
+}
+
+func (self *SubmodulesController) Context() types.Context {
+ return self.context()
+}
+
+func (self *SubmodulesController) context() *context.SubmodulesContext {
+ return self.contexts.Submodules
+}
diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go
new file mode 100644
index 000000000..275a5ebb2
--- /dev/null
+++ b/pkg/gui/controllers/switch_to_diff_files_controller.go
@@ -0,0 +1,74 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// This controller is for all contexts that contain commit files.
+
+var _ types.IController = &SwitchToDiffFilesController{}
+
+type CanSwitchToDiffFiles interface {
+ types.Context
+ CanRebase() bool
+ GetSelectedRef() types.Ref
+}
+
+type SwitchToDiffFilesController struct {
+ baseController
+ *controllerCommon
+ context CanSwitchToDiffFiles
+ viewFiles func(SwitchToCommitFilesContextOpts) error
+}
+
+func NewSwitchToDiffFilesController(
+ controllerCommon *controllerCommon,
+ viewFiles func(SwitchToCommitFilesContextOpts) error,
+ context CanSwitchToDiffFiles,
+) *SwitchToDiffFilesController {
+ return &SwitchToDiffFilesController{
+ baseController: baseController{},
+ controllerCommon: controllerCommon,
+ context: context,
+ viewFiles: viewFiles,
+ }
+}
+
+func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.GoInto),
+ Handler: self.checkSelected(self.enter),
+ Description: self.c.Tr.LcViewItemFiles,
+ },
+ }
+
+ return bindings
+}
+
+func (self *SwitchToDiffFilesController) GetOnClick() func() error {
+ return self.checkSelected(self.enter)
+}
+
+func (self *SwitchToDiffFilesController) checkSelected(callback func(types.Ref) error) func() error {
+ return func() error {
+ ref := self.context.GetSelectedRef()
+ if ref == nil {
+ return nil
+ }
+
+ return callback(ref)
+ }
+}
+
+func (self *SwitchToDiffFilesController) enter(ref types.Ref) error {
+ return self.viewFiles(SwitchToCommitFilesContextOpts{
+ Ref: ref,
+ CanRebase: self.context.CanRebase(),
+ Context: self.context,
+ })
+}
+
+func (self *SwitchToDiffFilesController) Context() types.Context {
+ return self.context
+}
diff --git a/pkg/gui/controllers/switch_to_sub_commits_controller.go b/pkg/gui/controllers/switch_to_sub_commits_controller.go
new file mode 100644
index 000000000..5d89ebad7
--- /dev/null
+++ b/pkg/gui/controllers/switch_to_sub_commits_controller.go
@@ -0,0 +1,90 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands/loaders"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+var _ types.IController = &SwitchToSubCommitsController{}
+
+type CanSwitchToSubCommits interface {
+ types.Context
+ GetSelectedRef() types.Ref
+}
+
+type SwitchToSubCommitsController struct {
+ baseController
+ *controllerCommon
+ context CanSwitchToSubCommits
+
+ setSubCommits func([]*models.Commit)
+}
+
+func NewSwitchToSubCommitsController(
+ controllerCommon *controllerCommon,
+ setSubCommits func([]*models.Commit),
+ context CanSwitchToSubCommits,
+) *SwitchToSubCommitsController {
+ return &SwitchToSubCommitsController{
+ baseController: baseController{},
+ controllerCommon: controllerCommon,
+ context: context,
+ setSubCommits: setSubCommits,
+ }
+}
+
+func (self *SwitchToSubCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Handler: self.viewCommits,
+ Key: opts.GetKey(opts.Config.Universal.GoInto),
+ Description: self.c.Tr.LcViewCommits,
+ },
+ }
+
+ return bindings
+}
+
+func (self *SwitchToSubCommitsController) GetOnClick() func() error {
+ return self.viewCommits
+}
+
+func (self *SwitchToSubCommitsController) viewCommits() error {
+ ref := self.context.GetSelectedRef()
+ if ref == nil {
+ return nil
+ }
+
+ // need to populate my sub commits
+ commits, err := self.git.Loaders.Commits.GetCommits(
+ loaders.GetCommitsOptions{
+ Limit: true,
+ FilterPath: self.modes.Filtering.GetPath(),
+ IncludeRebaseCommits: false,
+ RefName: ref.FullRefName(),
+ },
+ )
+ if err != nil {
+ return err
+ }
+
+ self.setSubCommits(commits)
+
+ self.contexts.SubCommits.SetSelectedLineIdx(0)
+ self.contexts.SubCommits.SetParentContext(self.context)
+ self.contexts.SubCommits.SetWindowName(self.context.GetWindowName())
+ self.contexts.SubCommits.SetTitleRef(ref.Description())
+ self.contexts.SubCommits.SetRefName(ref.RefName())
+
+ err = self.c.PostRefreshUpdate(self.contexts.SubCommits)
+ if err != nil {
+ return err
+ }
+
+ return self.c.PushContext(self.contexts.SubCommits)
+}
+
+func (self *SwitchToSubCommitsController) Context() types.Context {
+ return self.context
+}
diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go
new file mode 100644
index 000000000..9eb4ae16a
--- /dev/null
+++ b/pkg/gui/controllers/sync_controller.go
@@ -0,0 +1,215 @@
+package controllers
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/git_commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+type SyncController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &SyncController{}
+
+func NewSyncController(
+ common *controllerCommon,
+) *SyncController {
+ return &SyncController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *SyncController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.PushFiles),
+ Handler: opts.Guards.NoPopupPanel(self.HandlePush),
+ Description: self.c.Tr.LcPush,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.PullFiles),
+ Handler: opts.Guards.NoPopupPanel(self.HandlePull),
+ Description: self.c.Tr.LcPull,
+ },
+ }
+
+ return bindings
+}
+
+func (self *SyncController) Context() types.Context {
+ return nil
+}
+
+func (self *SyncController) HandlePush() error {
+ return self.branchCheckedOut(self.push)()
+}
+
+func (self *SyncController) HandlePull() error {
+ return self.branchCheckedOut(self.pull)()
+}
+
+func (self *SyncController) branchCheckedOut(f func(*models.Branch) error) func() error {
+ return func() error {
+ currentBranch := self.helpers.Refs.GetCheckedOutRef()
+ if currentBranch == nil {
+ // need to wait for branches to refresh
+ return nil
+ }
+
+ return f(currentBranch)
+ }
+}
+
+func (self *SyncController) push(currentBranch *models.Branch) error {
+ // if we have pullables we'll ask if the user wants to force push
+ if currentBranch.IsTrackingRemote() {
+ opts := pushOpts{}
+ if currentBranch.HasCommitsToPull() {
+ return self.requestToForcePush(opts)
+ } else {
+ return self.pushAux(opts)
+ }
+ } else {
+ if self.git.Config.GetPushToCurrent() {
+ return self.pushAux(pushOpts{setUpstream: true})
+ } else {
+ return self.helpers.Upstream.PromptForUpstreamWithInitialContent(currentBranch, func(upstream string) error {
+ upstreamRemote, upstreamBranch, err := self.helpers.Upstream.ParseUpstream(upstream)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.pushAux(pushOpts{
+ setUpstream: true,
+ upstreamRemote: upstreamRemote,
+ upstreamBranch: upstreamBranch,
+ })
+ })
+ }
+ }
+}
+
+func (self *SyncController) pull(currentBranch *models.Branch) error {
+ action := self.c.Tr.Actions.Pull
+
+ // if we have no upstream branch we need to set that first
+ if !currentBranch.IsTrackingRemote() {
+ return self.helpers.Upstream.PromptForUpstreamWithInitialContent(currentBranch, func(upstream string) error {
+ if err := self.setCurrentBranchUpstream(upstream); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.PullAux(PullFilesOptions{Action: action})
+ })
+ }
+
+ return self.PullAux(PullFilesOptions{Action: action})
+}
+
+func (self *SyncController) setCurrentBranchUpstream(upstream string) error {
+ upstreamRemote, upstreamBranch, err := self.helpers.Upstream.ParseUpstream(upstream)
+ if err != nil {
+ return err
+ }
+
+ if err := self.git.Branch.SetCurrentBranchUpstream(upstreamRemote, upstreamBranch); err != nil {
+ if strings.Contains(err.Error(), "does not exist") {
+ return fmt.Errorf(
+ "upstream branch %s/%s not found.\nIf you expect it to exist, you should fetch (with 'f').\nOtherwise, you should push (with 'shift+P')",
+ upstreamRemote, upstreamBranch,
+ )
+ }
+ return err
+ }
+ return nil
+}
+
+type PullFilesOptions struct {
+ UpstreamRemote string
+ UpstreamBranch string
+ FastForwardOnly bool
+ Action string
+}
+
+func (self *SyncController) PullAux(opts PullFilesOptions) error {
+ return self.c.WithLoaderPanel(self.c.Tr.PullWait, func() error {
+ return self.pullWithLock(opts)
+ })
+}
+
+func (self *SyncController) pullWithLock(opts PullFilesOptions) error {
+ self.c.LogAction(opts.Action)
+
+ err := self.git.Sync.Pull(
+ git_commands.PullOptions{
+ RemoteName: opts.UpstreamRemote,
+ BranchName: opts.UpstreamBranch,
+ FastForwardOnly: opts.FastForwardOnly,
+ },
+ )
+
+ return self.helpers.MergeAndRebase.CheckMergeOrRebase(err)
+}
+
+type pushOpts struct {
+ force bool
+ upstreamRemote string
+ upstreamBranch string
+ setUpstream bool
+}
+
+func (self *SyncController) pushAux(opts pushOpts) error {
+ return self.c.WithLoaderPanel(self.c.Tr.PushWait, func() error {
+ self.c.LogAction(self.c.Tr.Actions.Push)
+ err := self.git.Sync.Push(git_commands.PushOpts{
+ Force: opts.force,
+ UpstreamRemote: opts.upstreamRemote,
+ UpstreamBranch: opts.upstreamBranch,
+ SetUpstream: opts.setUpstream,
+ })
+ if err != nil {
+ if !opts.force && strings.Contains(err.Error(), "Updates were rejected") {
+ forcePushDisabled := self.c.UserConfig.Git.DisableForcePushing
+ if forcePushDisabled {
+ _ = self.c.ErrorMsg(self.c.Tr.UpdatesRejectedAndForcePushDisabled)
+ return nil
+ }
+ _ = self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.ForcePush,
+ Prompt: self.c.Tr.ForcePushPrompt,
+ HandleConfirm: func() error {
+ newOpts := opts
+ newOpts.force = true
+
+ return self.pushAux(newOpts)
+ },
+ })
+ return nil
+ }
+ _ = self.c.Error(err)
+ }
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+ })
+}
+
+func (self *SyncController) requestToForcePush(opts pushOpts) error {
+ forcePushDisabled := self.c.UserConfig.Git.DisableForcePushing
+ if forcePushDisabled {
+ return self.c.ErrorMsg(self.c.Tr.ForcePushDisabled)
+ }
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.ForcePush,
+ Prompt: self.c.Tr.ForcePushPrompt,
+ HandleConfirm: func() error {
+ opts.force = true
+ return self.pushAux(opts)
+ },
+ })
+}
diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go
new file mode 100644
index 000000000..f4b23374c
--- /dev/null
+++ b/pkg/gui/controllers/tags_controller.go
@@ -0,0 +1,140 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type TagsController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &TagsController{}
+
+func NewTagsController(
+ common *controllerCommon,
+) *TagsController {
+ return &TagsController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.Select),
+ Handler: self.withSelectedTag(self.checkout),
+ Description: self.c.Tr.LcCheckout,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Remove),
+ Handler: self.withSelectedTag(self.delete),
+ Description: self.c.Tr.LcDeleteTag,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Branches.PushTag),
+ Handler: self.withSelectedTag(self.push),
+ Description: self.c.Tr.LcPushTag,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.New),
+ Handler: self.create,
+ Description: self.c.Tr.LcCreateTag,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Commits.ViewResetOptions),
+ Handler: self.withSelectedTag(self.createResetMenu),
+ Description: self.c.Tr.LcViewResetOptions,
+ OpensMenu: true,
+ },
+ }
+
+ return bindings
+}
+
+func (self *TagsController) checkout(tag *models.Tag) error {
+ self.c.LogAction(self.c.Tr.Actions.CheckoutTag)
+ if err := self.helpers.Refs.CheckoutRef(tag.Name, types.CheckoutRefOptions{}); err != nil {
+ return err
+ }
+ return self.c.PushContext(self.contexts.Branches)
+}
+
+func (self *TagsController) delete(tag *models.Tag) error {
+ prompt := utils.ResolvePlaceholderString(
+ self.c.Tr.DeleteTagPrompt,
+ map[string]string{
+ "tagName": tag.Name,
+ },
+ )
+
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.DeleteTagTitle,
+ Prompt: prompt,
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.DeleteTag)
+ if err := self.git.Tag.Delete(tag.Name); err != nil {
+ return self.c.Error(err)
+ }
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
+ },
+ })
+}
+
+func (self *TagsController) push(tag *models.Tag) error {
+ title := utils.ResolvePlaceholderString(
+ self.c.Tr.PushTagTitle,
+ map[string]string{
+ "tagName": tag.Name,
+ },
+ )
+
+ return self.c.Prompt(types.PromptOpts{
+ Title: title,
+ InitialContent: "origin",
+ FindSuggestionsFunc: self.helpers.Suggestions.GetRemoteSuggestionsFunc(),
+ HandleConfirm: func(response string) error {
+ return self.c.WithWaitingStatus(self.c.Tr.PushingTagStatus, func() error {
+ self.c.LogAction(self.c.Tr.Actions.PushTag)
+ err := self.git.Tag.Push(response, tag.Name)
+ if err != nil {
+ _ = self.c.Error(err)
+ }
+
+ return nil
+ })
+ },
+ })
+}
+
+func (self *TagsController) createResetMenu(tag *models.Tag) error {
+ return self.helpers.Refs.CreateGitResetMenu(tag.Name)
+}
+
+func (self *TagsController) create() error {
+ // leaving commit SHA blank so that we're just creating the tag for the current commit
+ return self.helpers.Tags.CreateTagMenu("", func() { self.context().SetSelectedLineIdx(0) })
+}
+
+func (self *TagsController) withSelectedTag(f func(tag *models.Tag) error) func() error {
+ return func() error {
+ tag := self.context().GetSelected()
+ if tag == nil {
+ return nil
+ }
+
+ return f(tag)
+ }
+}
+
+func (self *TagsController) Context() types.Context {
+ return self.context()
+}
+
+func (self *TagsController) context() *context.TagsContext {
+ return self.contexts.Tags
+}
diff --git a/pkg/gui/controllers/types.go b/pkg/gui/controllers/types.go
new file mode 100644
index 000000000..f719b5de0
--- /dev/null
+++ b/pkg/gui/controllers/types.go
@@ -0,0 +1,18 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// all fields mandatory (except `CanRebase` because it's boolean)
+type SwitchToCommitFilesContextOpts struct {
+ // this is something like a commit or branch
+ Ref types.Ref
+
+ // from the local commits view we're allowed to do rebase stuff with any patch
+ // we generate from the diff files context, but we don't have that same ability
+ // with say the sub commits context or the reflog context.
+ CanRebase bool
+
+ Context types.Context
+}
diff --git a/pkg/gui/controllers/undo_controller.go b/pkg/gui/controllers/undo_controller.go
new file mode 100644
index 000000000..5e0bf5730
--- /dev/null
+++ b/pkg/gui/controllers/undo_controller.go
@@ -0,0 +1,274 @@
+package controllers
+
+import (
+ "fmt"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/types/enums"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+// Quick summary of how this all works:
+// when you want to undo or redo, we start from the top of the reflog and work
+// down until we've reached the last user-initiated reflog entry that hasn't already been undone
+// we then do the reverse of what that reflog describes.
+// When we do this, we create a new reflog entry, and tag it as either an undo or redo
+// Then, next time we want to undo, we'll use those entries to know which user-initiated
+// actions we can skip. E.g. if I do do three things, A, B, and C, and hit undo twice,
+// the reflog will read UUCBA, and when I read the first two undos, I know to skip the following
+// two user actions, meaning we end up undoing reflog entry C. Redoing works in a similar way.
+
+type UndoController struct {
+ baseController
+ *controllerCommon
+}
+
+var _ types.IController = &UndoController{}
+
+func NewUndoController(
+ common *controllerCommon,
+) *UndoController {
+ return &UndoController{
+ baseController: baseController{},
+ controllerCommon: common,
+ }
+}
+
+type ReflogActionKind int
+
+const (
+ CHECKOUT ReflogActionKind = iota
+ COMMIT
+ REBASE
+ CURRENT_REBASE
+)
+
+type reflogAction struct {
+ kind ReflogActionKind
+ from string
+ to string
+}
+
+func (self *UndoController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ bindings := []*types.Binding{
+ {
+ Key: opts.GetKey(opts.Config.Universal.Undo),
+ Handler: self.reflogUndo,
+ Description: self.c.Tr.LcUndoReflog,
+ Tooltip: self.c.Tr.UndoTooltip,
+ },
+ {
+ Key: opts.GetKey(opts.Config.Universal.Redo),
+ Handler: self.reflogRedo,
+ Description: self.c.Tr.LcRedoReflog,
+ Tooltip: self.c.Tr.RedoTooltip,
+ },
+ }
+
+ return bindings
+}
+
+func (self *UndoController) Context() types.Context {
+ return nil
+}
+
+func (self *UndoController) reflogUndo() error {
+ undoEnvVars := []string{"GIT_REFLOG_ACTION=[lazygit undo]"}
+ undoingStatus := self.c.Tr.UndoingStatus
+
+ if self.git.Status.WorkingTreeState() == enums.REBASE_MODE_REBASING {
+ return self.c.ErrorMsg(self.c.Tr.LcCantUndoWhileRebasing)
+ }
+
+ return self.parseReflogForActions(func(counter int, action reflogAction) (bool, error) {
+ if counter != 0 {
+ return false, nil
+ }
+
+ switch action.kind {
+ case COMMIT, REBASE:
+ return true, self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.Actions.Undo,
+ Prompt: fmt.Sprintf(self.c.Tr.HardResetAutostashPrompt, action.from),
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.Undo)
+ return self.hardResetWithAutoStash(action.from, hardResetOptions{
+ EnvVars: undoEnvVars,
+ WaitingStatus: undoingStatus,
+ })
+ },
+ })
+ case CHECKOUT:
+ return true, self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.Actions.Undo,
+ Prompt: fmt.Sprintf(self.c.Tr.CheckoutPrompt, action.from),
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.Undo)
+ return self.helpers.Refs.CheckoutRef(action.from, types.CheckoutRefOptions{
+ EnvVars: undoEnvVars,
+ WaitingStatus: undoingStatus,
+ })
+ },
+ })
+
+ case CURRENT_REBASE:
+ // do nothing
+ }
+
+ self.c.Log.Error("didn't match on the user action when trying to undo")
+ return true, nil
+ })
+}
+
+func (self *UndoController) reflogRedo() error {
+ redoEnvVars := []string{"GIT_REFLOG_ACTION=[lazygit redo]"}
+ redoingStatus := self.c.Tr.RedoingStatus
+
+ if self.git.Status.WorkingTreeState() == enums.REBASE_MODE_REBASING {
+ return self.c.ErrorMsg(self.c.Tr.LcCantRedoWhileRebasing)
+ }
+
+ return self.parseReflogForActions(func(counter int, action reflogAction) (bool, error) {
+ // if we're redoing and the counter is zero, we just return
+ if counter == 0 {
+ return true, nil
+ } else if counter > 1 {
+ return false, nil
+ }
+
+ switch action.kind {
+ case COMMIT, REBASE:
+ return true, self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.Actions.Redo,
+ Prompt: fmt.Sprintf(self.c.Tr.HardResetAutostashPrompt, action.to),
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.Redo)
+ return self.hardResetWithAutoStash(action.to, hardResetOptions{
+ EnvVars: redoEnvVars,
+ WaitingStatus: redoingStatus,
+ })
+ },
+ })
+
+ case CHECKOUT:
+ return true, self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.Actions.Redo,
+ Prompt: fmt.Sprintf(self.c.Tr.CheckoutPrompt, action.to),
+ HandleConfirm: func() error {
+ self.c.LogAction(self.c.Tr.Actions.Redo)
+ return self.helpers.Refs.CheckoutRef(action.to, types.CheckoutRefOptions{
+ EnvVars: redoEnvVars,
+ WaitingStatus: redoingStatus,
+ })
+ },
+ })
+ case CURRENT_REBASE:
+ // do nothing
+ }
+
+ self.c.Log.Error("didn't match on the user action when trying to redo")
+ return true, nil
+ })
+}
+
+// Here we're going through the reflog and maintaining a counter that represents how many
+// undos/redos/user actions we've seen. when we hit a user action we call the callback specifying
+// what the counter is up to and the nature of the action.
+// If we find ourselves mid-rebase, we just return because undo/redo mid rebase
+// requires knowledge of previous TODO file states, which you can't just get from the reflog.
+// Though we might support this later, hence the use of the CURRENT_REBASE action kind.
+func (self *UndoController) parseReflogForActions(onUserAction func(counter int, action reflogAction) (bool, error)) error {
+ counter := 0
+ reflogCommits := self.model.FilteredReflogCommits
+ rebaseFinishCommitSha := ""
+ var action *reflogAction
+ for reflogCommitIdx, reflogCommit := range reflogCommits {
+ action = nil
+
+ prevCommitSha := ""
+ if len(reflogCommits)-1 >= reflogCommitIdx+1 {
+ prevCommitSha = reflogCommits[reflogCommitIdx+1].Sha
+ }
+
+ if rebaseFinishCommitSha == "" {
+ if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^\[lazygit undo\]`); ok {
+ counter++
+ } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^\[lazygit redo\]`); ok {
+ counter--
+ } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase -i \(abort\)|^rebase -i \(finish\)`); ok {
+ rebaseFinishCommitSha = reflogCommit.Sha
+ } else if ok, match := utils.FindStringSubmatch(reflogCommit.Name, `^checkout: moving from ([\S]+) to ([\S]+)`); ok {
+ action = &reflogAction{kind: CHECKOUT, from: match[1], to: match[2]}
+ } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^commit|^reset: moving to|^pull`); ok {
+ action = &reflogAction{kind: COMMIT, from: prevCommitSha, to: reflogCommit.Sha}
+ } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase -i \(start\)`); ok {
+ // if we're here then we must be currently inside an interactive rebase
+ action = &reflogAction{kind: CURRENT_REBASE, from: prevCommitSha}
+ }
+ } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase -i \(start\)`); ok {
+ action = &reflogAction{kind: REBASE, from: prevCommitSha, to: rebaseFinishCommitSha}
+ rebaseFinishCommitSha = ""
+ }
+
+ if action != nil {
+ if action.kind != CURRENT_REBASE && action.from == action.to {
+ // if we're going from one place to the same place we'll ignore the action.
+ continue
+ }
+ ok, err := onUserAction(counter, *action)
+ if ok {
+ return err
+ }
+ counter--
+ }
+ }
+ return nil
+}
+
+type hardResetOptions struct {
+ WaitingStatus string
+ EnvVars []string
+}
+
+// only to be used in the undo flow for now (does an autostash)
+func (self *UndoController) hardResetWithAutoStash(commitSha string, options hardResetOptions) error {
+ reset := func() error {
+ if err := self.helpers.Refs.ResetToRef(commitSha, "hard", options.EnvVars); err != nil {
+ return self.c.Error(err)
+ }
+ return nil
+ }
+
+ // if we have any modified tracked files we need to ask the user if they want us to stash for them
+ dirtyWorkingTree := self.helpers.WorkingTree.IsWorkingTreeDirty()
+ if dirtyWorkingTree {
+ // offer to autostash changes
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: self.c.Tr.AutoStashTitle,
+ Prompt: self.c.Tr.AutoStashPrompt,
+ HandleConfirm: func() error {
+ return self.c.WithWaitingStatus(options.WaitingStatus, func() error {
+ if err := self.git.Stash.Save(self.c.Tr.StashPrefix + commitSha); err != nil {
+ return self.c.Error(err)
+ }
+ if err := reset(); err != nil {
+ return err
+ }
+
+ err := self.git.Stash.Pop(0)
+ if err := self.c.Refresh(types.RefreshOptions{}); err != nil {
+ return err
+ }
+ if err != nil {
+ return self.c.Error(err)
+ }
+ return nil
+ })
+ },
+ })
+ }
+
+ return self.c.WithWaitingStatus(options.WaitingStatus, func() error {
+ return reset()
+ })
+}
diff --git a/pkg/gui/controllers/vertical_scroll_controller.go b/pkg/gui/controllers/vertical_scroll_controller.go
new file mode 100644
index 000000000..3f3e9d177
--- /dev/null
+++ b/pkg/gui/controllers/vertical_scroll_controller.go
@@ -0,0 +1,70 @@
+package controllers
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// given we have no fields here, arguably we shouldn't even need this factory
+// struct, but we're maintaining consistency with the other files.
+type VerticalScrollControllerFactory struct {
+ controllerCommon *controllerCommon
+}
+
+func NewVerticalScrollControllerFactory(c *controllerCommon) *VerticalScrollControllerFactory {
+ return &VerticalScrollControllerFactory{controllerCommon: c}
+}
+
+func (self *VerticalScrollControllerFactory) Create(context types.Context) types.IController {
+ return &VerticalScrollController{
+ baseController: baseController{},
+ controllerCommon: self.controllerCommon,
+ context: context,
+ }
+}
+
+type VerticalScrollController struct {
+ baseController
+ *controllerCommon
+
+ context types.Context
+}
+
+func (self *VerticalScrollController) Context() types.Context {
+ return self.context
+}
+
+func (self *VerticalScrollController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
+ return []*types.Binding{}
+}
+
+func (self *VerticalScrollController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
+ return []*gocui.ViewMouseBinding{
+ {
+ ViewName: self.context.GetViewName(),
+ Key: gocui.MouseWheelUp,
+ Handler: func(gocui.ViewMouseBindingOpts) error {
+ return self.HandleScrollUp()
+ },
+ },
+ {
+ ViewName: self.context.GetViewName(),
+ Key: gocui.MouseWheelDown,
+ Handler: func(gocui.ViewMouseBindingOpts) error {
+ return self.HandleScrollDown()
+ },
+ },
+ }
+}
+
+func (self *VerticalScrollController) HandleScrollUp() error {
+ self.context.GetViewTrait().ScrollUp(self.c.UserConfig.Gui.ScrollHeight)
+
+ return nil
+}
+
+func (self *VerticalScrollController) HandleScrollDown() error {
+ self.context.GetViewTrait().ScrollDown(self.c.UserConfig.Gui.ScrollHeight)
+
+ return nil
+}
diff --git a/pkg/gui/controllers/workspace_reset_controller.go b/pkg/gui/controllers/workspace_reset_controller.go
new file mode 100644
index 000000000..f34739af7
--- /dev/null
+++ b/pkg/gui/controllers/workspace_reset_controller.go
@@ -0,0 +1,137 @@
+package controllers
+
+import (
+ "fmt"
+
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// this is in its own file given that the workspace controller file is already quite long
+
+func (self *FilesController) createResetMenu() error {
+ red := style.FgRed
+
+ nukeStr := "git reset --hard HEAD && git clean -fd"
+ if len(self.model.Submodules) > 0 {
+ nukeStr = fmt.Sprintf("%s (%s)", nukeStr, self.c.Tr.LcAndResetSubmodules)
+ }
+
+ menuItems := []*types.MenuItem{
+ {
+ LabelColumns: []string{
+ self.c.Tr.LcDiscardAllChangesToAllFiles,
+ red.Sprint(nukeStr),
+ },
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.NukeWorkingTree)
+ if err := self.git.WorkingTree.ResetAndClean(); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
+ },
+ Key: 'D',
+ Tooltip: self.c.Tr.NukeDescription,
+ },
+ {
+ LabelColumns: []string{
+ self.c.Tr.LcDiscardAnyUnstagedChanges,
+ red.Sprint("git checkout -- ."),
+ },
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.DiscardUnstagedFileChanges)
+ if err := self.git.WorkingTree.DiscardAnyUnstagedFileChanges(); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
+ },
+ Key: 'u',
+ },
+ {
+ LabelColumns: []string{
+ self.c.Tr.LcDiscardUntrackedFiles,
+ red.Sprint("git clean -fd"),
+ },
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.RemoveUntrackedFiles)
+ if err := self.git.WorkingTree.RemoveUntrackedFiles(); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
+ },
+ Key: 'c',
+ },
+ {
+ LabelColumns: []string{
+ self.c.Tr.LcDiscardStagedChanges,
+ red.Sprint("stash staged and drop stash"),
+ },
+ Tooltip: self.c.Tr.DiscardStagedChangesDescription,
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.RemoveStagedFiles)
+ if !self.helpers.WorkingTree.IsWorkingTreeDirty() {
+ return self.c.ErrorMsg(self.c.Tr.NoTrackedStagedFilesStash)
+ }
+ if err := self.git.Stash.SaveStagedChanges("[lazygit] tmp stash"); err != nil {
+ return self.c.Error(err)
+ }
+ if err := self.git.Stash.DropNewest(); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
+ },
+ Key: 'S',
+ },
+ {
+ LabelColumns: []string{
+ self.c.Tr.LcSoftReset,
+ red.Sprint("git reset --soft HEAD"),
+ },
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.SoftReset)
+ if err := self.git.WorkingTree.ResetSoft("HEAD"); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
+ },
+ Key: 's',
+ },
+ {
+ LabelColumns: []string{
+ "mixed reset",
+ red.Sprint("git reset --mixed HEAD"),
+ },
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.MixedReset)
+ if err := self.git.WorkingTree.ResetMixed("HEAD"); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
+ },
+ Key: 'm',
+ },
+ {
+ LabelColumns: []string{
+ self.c.Tr.LcHardReset,
+ red.Sprint("git reset --hard HEAD"),
+ },
+ OnPress: func() error {
+ self.c.LogAction(self.c.Tr.Actions.HardReset)
+ if err := self.git.WorkingTree.ResetHard("HEAD"); err != nil {
+ return self.c.Error(err)
+ }
+
+ return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
+ },
+ Key: 'h',
+ },
+ }
+
+ return self.c.Menu(types.CreateMenuOptions{Title: "", Items: menuItems})
+}
diff --git a/pkg/gui/credentials_panel.go b/pkg/gui/credentials_panel.go
deleted file mode 100644
index 984591a62..000000000
--- a/pkg/gui/credentials_panel.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package gui
-
-import (
- "strings"
-
- "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
- "github.com/jesseduffield/lazygit/pkg/utils"
-)
-
-type credentials chan string
-
-// promptUserForCredential wait for a username, password or passphrase input from the credentials popup
-func (gui *Gui) promptUserForCredential(passOrUname oscommands.CredentialType) string {
- gui.credentials = make(chan string)
- gui.OnUIThread(func() error {
- credentialsView := gui.Views.Credentials
- switch passOrUname {
- case oscommands.Username:
- credentialsView.Title = gui.Tr.CredentialsUsername
- credentialsView.Mask = 0
- case oscommands.Password:
- credentialsView.Title = gui.Tr.CredentialsPassword
- credentialsView.Mask = '*'
- case oscommands.Passphrase:
- credentialsView.Title = gui.Tr.CredentialsPassphrase
- credentialsView.Mask = '*'
- }
-
- if err := gui.pushContext(gui.State.Contexts.Credentials); err != nil {
- return err
- }
-
- gui.RenderCommitLength()
- return nil
- })
-
- // wait for username/passwords/passphrase input
- userInput := <-gui.credentials
- return userInput + "\n"
-}
-
-func (gui *Gui) handleSubmitCredential() error {
- credentialsView := gui.Views.Credentials
- message := strings.TrimSpace(credentialsView.TextArea.GetContent())
- gui.credentials <- message
- credentialsView.ClearTextArea()
- if err := gui.returnFromContext(); err != nil {
- return err
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
-}
-
-func (gui *Gui) handleCloseCredentialsView() error {
- gui.credentials <- ""
- return gui.returnFromContext()
-}
-
-func (gui *Gui) handleCredentialsViewFocused() error {
- keybindingConfig := gui.UserConfig.Keybinding
-
- message := utils.ResolvePlaceholderString(
- gui.Tr.CloseConfirm,
- map[string]string{
- "keyBindClose": gui.getKeyDisplay(keybindingConfig.Universal.Return),
- "keyBindConfirm": gui.getKeyDisplay(keybindingConfig.Universal.Confirm),
- },
- )
-
- return gui.renderString(gui.Views.Options, message)
-}
-
-// handleCredentialsPopup handles the views after executing a command that might ask for credentials
-func (gui *Gui) handleCredentialsPopup(cmdErr error) {
- if cmdErr != nil {
- errMessage := cmdErr.Error()
- if strings.Contains(errMessage, "Invalid username, password or passphrase") {
- errMessage = gui.Tr.PassUnameWrong
- }
- _ = gui.returnFromContext()
- // we are not logging this error because it may contain a password or a passphrase
- _ = gui.createErrorPanel(errMessage)
- } else {
- _ = gui.closeConfirmationPrompt(false)
- }
-}
diff --git a/pkg/gui/custom_commands.go b/pkg/gui/custom_commands.go
deleted file mode 100644
index 02293dd65..000000000
--- a/pkg/gui/custom_commands.go
+++ /dev/null
@@ -1,348 +0,0 @@
-package gui
-
-import (
- "bytes"
- "errors"
- "log"
- "regexp"
- "strconv"
- "strings"
- "text/template"
-
- "github.com/jesseduffield/gocui"
- "github.com/jesseduffield/lazygit/pkg/commands/models"
- "github.com/jesseduffield/lazygit/pkg/config"
- "github.com/jesseduffield/lazygit/pkg/gui/style"
- "github.com/jesseduffield/lazygit/pkg/utils"
-)
-
-type CustomCommandObjects struct {
- SelectedLocalCommit *models.Commit
- SelectedReflogCommit *models.Commit
- SelectedSubCommit *models.Commit
- SelectedFile *models.File
- SelectedPath string
- SelectedLocalBranch *models.Branch
- SelectedRemoteBranch *models.RemoteBranch
- SelectedRemote *models.Remote
- SelectedTag *models.Tag
- SelectedStashEntry *models.StashEntry
- SelectedCommitFile *models.CommitFile
- SelectedCommitFilePath string
- CheckedOutBranch *models.Branch
- PromptResponses []string
-}
-
-type commandMenuEntry struct {
- label string
- value string
-}
-
-func (gui *Gui) resolveTemplate(templateStr string, promptResponses []string) (string, error) {
- objects := CustomCommandObjects{
- SelectedFile: gui.getSelectedFile(),
- SelectedPath: gui.getSelectedPath(),
- SelectedLocalCommit: gui.getSelectedLocalCommit(),
- SelectedReflogCommit: gui.getSelectedReflogCommit(),
- SelectedLocalBranch: gui.getSelectedBranch(),
- SelectedRemoteBranch: gui.getSelectedRemoteBranch(),
- SelectedRemote: gui.getSelectedRemote(),
- SelectedTag: gui.getSelectedTag(),
- SelectedStashEntry: gui.getSelectedStashEntry(),
- SelectedCommitFile: gui.getSelectedCommitFile(),
- SelectedCommitFilePath: gui.getSelectedCommitFilePath(),
- SelectedSubCommit: gui.getSelectedSubCommit(),
- CheckedOutBranch: gui.currentBranch(),
- PromptResponses: promptResponses,
- }
-
- return utils.ResolveTemplate(templateStr, objects)
-}
-
-func (gui *Gui) inputPrompt(prompt config.CustomCommandPrompt, promptResponses []string, responseIdx int, wrappedF func() error) error {
- title, err := gui.resolveTemplate(prompt.Title, promptResponses)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- initialValue, err := gui.resolveTemplate(prompt.InitialValue, promptResponses)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.prompt(promptOpts{
- title: title,
- initialContent: initialValue,
- handleConfirm: func(str string) error {
- promptResponses[responseIdx] = str
- return wrappedF()
- },
- })
-}
-
-func (gui *Gui) menuPrompt(prompt config.CustomCommandPrompt, promptResponses []string, responseIdx int, wrappedF func() error) error {
- // need to make a menu here some how
- menuItems := make([]*menuItem, len(prompt.Options))
- for i, option := range prompt.Options {
- option := option
-
- nameTemplate := option.Name
- if nameTemplate == "" {
- // this allows you to only pass values rather than bother with names/descriptions
- nameTemplate = option.Value
- }
- name, err := gui.resolveTemplate(nameTemplate, promptResponses)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- description, err := gui.resolveTemplate(option.Description, promptResponses)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- value, err := gui.resolveTemplate(option.Value, promptResponses)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- menuItems[i] = &menuItem{
- displayStrings: []string{name, style.FgYellow.Sprint(description)},
- onPress: func() error {
- promptResponses[responseIdx] = value
- return wrappedF()
- },
- }
- }
-
- title, err := gui.resolveTemplate(prompt.Title, promptResponses)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.createMenu(title, menuItems, createMenuOptions{showCancel: true})
-}
-
-func (gui *Gui) GenerateMenuCandidates(commandOutput, filter, valueFormat, labelFormat string) ([]commandMenuEntry, error) {
- reg, err := regexp.Compile(filter)
- if err != nil {
- return nil, gui.surfaceError(errors.New("unable to parse filter regex, error: " + err.Error()))
- }
-
- buff := bytes.NewBuffer(nil)
-
- valueTemp, err := template.New("format").Parse(valueFormat)
- if err != nil {
- return nil, gui.surfaceError(errors.New("unable to parse value format, error: " + err.Error()))
- }
-
- colorFuncMap := style.TemplateFuncMapAddColors(template.FuncMap{})
-
- descTemp, err := template.New("format").Funcs(colorFuncMap).Parse(labelFormat)
- if err != nil {
- return nil, gui.surfaceError(errors.New("unable to parse label format, error: " + err.Error()))
- }
-
- candidates := []commandMenuEntry{}
- for _, str := range strings.Split(commandOutput, "\n") {
- if str == "" {
- continue
- }
-
- tmplData := map[string]string{}
- out := reg.FindAllStringSubmatch(str, -1)
- if len(out) > 0 {
- for groupIdx, group := range reg.SubexpNames() {
- // Record matched group with group ids
- matchName := "group_" + strconv.Itoa(groupIdx)
- tmplData[matchName] = out[0][groupIdx]
- // Record last named group non-empty matches as group matches
- if group != "" {
- tmplData[group] = out[0][groupIdx]
- }
- }
- }
-
- err = valueTemp.Execute(buff, tmplData)
- if err != nil {
- return candidates, gui.surfaceError(err)
- }
- entry := commandMenuEntry{
- value: strings.TrimSpace(buff.String()),
- }
-
- if labelFormat != "" {
- buff.Reset()
- err = descTemp.Execute(buff, tmplData)
- if err != nil {
- return candidates, gui.surfaceError(err)
- }
- entry.label = strings.TrimSpace(buff.String())
- } else {
- entry.label = entry.value
- }
-
- candidates = append(candidates, entry)
-
- buff.Reset()
- }
- return candidates, err
-}
-
-func (gui *Gui) menuPromptFromCommand(prompt config.CustomCommandPrompt, promptResponses []string, responseIdx int, wrappedF func() error) error {
- // Collect cmd to run from config
- cmdStr, err := gui.resolveTemplate(prompt.Command, promptResponses)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- // Collect Filter regexp
- filter, err := gui.resolveTemplate(prompt.Filter, promptResponses)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- // Run and save output
- message, err := gui.Git.Custom.RunWithOutput(cmdStr)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- // Need to make a menu out of what the cmd has displayed
- candidates, err := gui.GenerateMenuCandidates(message, filter, prompt.ValueFormat, prompt.LabelFormat)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- menuItems := make([]*menuItem, len(candidates))
- for i := range candidates {
- i := i
- menuItems[i] = &menuItem{
- displayStrings: []string{candidates[i].label},
- onPress: func() error {
- promptResponses[responseIdx] = candidates[i].value
- return wrappedF()
- },
- }
- }
-
- title, err := gui.resolveTemplate(prompt.Title, promptResponses)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.createMenu(title, menuItems, createMenuOptions{showCancel: true})
-}
-
-func (gui *Gui) handleCustomCommandKeybinding(customCommand config.CustomCommand) func() error {
- return func() error {
- promptResponses := make([]string, len(customCommand.Prompts))
-
- f := func() error {
- cmdStr, err := gui.resolveTemplate(customCommand.Command, promptResponses)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- if customCommand.Subprocess {
- return gui.runSubprocessWithSuspenseAndRefresh(gui.OSCommand.Cmd.NewShell(cmdStr))
- }
-
- loadingText := customCommand.LoadingText
- if loadingText == "" {
- loadingText = gui.Tr.LcRunningCustomCommandStatus
- }
- return gui.WithWaitingStatus(loadingText, func() error {
- gui.logAction(gui.Tr.Actions.CustomCommand)
- cmdObj := gui.OSCommand.Cmd.NewShell(cmdStr)
- if customCommand.Stream {
- cmdObj.StreamOutput()
- }
- err := cmdObj.Run()
- if err != nil {
- return gui.surfaceError(err)
- }
- return gui.refreshSidePanels(refreshOptions{})
- })
- }
-
- // if we have prompts we'll recursively wrap our confirm handlers with more prompts
- // until we reach the actual command
- for reverseIdx := range customCommand.Prompts {
- idx := len(customCommand.Prompts) - 1 - reverseIdx
-
- // going backwards so the outermost prompt is the first one
- prompt := customCommand.Prompts[idx]
-
- // need to do this because f's value will change with each iteration
- wrappedF := f
-
- switch prompt.Type {
- case "input":
- f = func() error {
- return gui.inputPrompt(prompt, promptResponses, idx, wrappedF)
- }
- case "menu":
- f = func() error {
- return gui.menuPrompt(prompt, promptResponses, idx, wrappedF)
- }
- case "menuFromCommand":
- f = func() error {
- return gui.menuPromptFromCommand(prompt, promptResponses, idx, wrappedF)
- }
- default:
- return gui.createErrorPanel("custom command prompt must have a type of 'input', 'menu' or 'menuFromCommand'")
- }
-
- }
-
- return f()
- }
-}
-
-func (gui *Gui) GetCustomCommandKeybindings() []*Binding {
- bindings := []*Binding{}
- customCommands := gui.UserConfig.CustomCommands
-
- for _, customCommand := range customCommands {
- var viewName string
- var contexts []string
- switch customCommand.Context {
- case "global":
- viewName = ""
- case "":
- log.Fatalf("Error parsing custom command keybindings: context not provided (use context: 'global' for the global context). Key: %s, Command: %s", customCommand.Key, customCommand.Command)
- default:
- context, ok := gui.contextForContextKey(ContextKey(customCommand.Context))
- // stupid golang making me build an array of strings for this.
- allContextKeyStrings := make([]string, len(allContextKeys))
- for i := range allContextKeys {
- allContextKeyStrings[i] = string(allContextKeys[i])
- }
- if !ok {
- log.Fatalf("Error when setting custom command keybindings: unknown context: %s. Key: %s, Command: %s.\nPermitted contexts: %s", customCommand.Context, customCommand.Key, customCommand.Command, strings.Join(allContextKeyStrings, ", "))
- }
- // here we assume that a given context will always belong to the same view.
- // Currently this is a safe bet but it's by no means guaranteed in the long term
- // and we might need to make some changes in the future to support it.
- viewName = context.GetViewName()
- contexts = []string{customCommand.Context}
- }
-
- description := customCommand.Description
- if description == "" {
- description = customCommand.Command
- }
-
- bindings = append(bindings, &Binding{
- ViewName: viewName,
- Contexts: contexts,
- Key: gui.getKey(customCommand.Key),
- Modifier: gocui.ModNone,
- Handler: gui.handleCustomCommandKeybinding(customCommand),
- Description: description,
- })
- }
-
- return bindings
-}
diff --git a/pkg/gui/custom_commands_test.go b/pkg/gui/custom_commands_test.go
deleted file mode 100644
index d31bcf291..000000000
--- a/pkg/gui/custom_commands_test.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package gui
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
-)
-
-func TestGuiGenerateMenuCandidates(t *testing.T) {
- type scenario struct {
- testName string
- cmdOut string
- filter string
- valueFormat string
- labelFormat string
- test func([]commandMenuEntry, error)
- }
-
- scenarios := []scenario{
- {
- "Extract remote branch name",
- "upstream/pr-1",
- "(?P[a-z_]+)/(?P.*)",
- "{{ .branch }}",
- "Remote: {{ .remote }}",
- func(actualEntry []commandMenuEntry, err error) {
- assert.NoError(t, err)
- assert.EqualValues(t, "pr-1", actualEntry[0].value)
- assert.EqualValues(t, "Remote: upstream", actualEntry[0].label)
- },
- },
- {
- "Multiple named groups with empty labelFormat",
- "upstream/pr-1",
- "(?P[a-z]*)/(?P.*)",
- "{{ .branch }}|{{ .remote }}",
- "",
- func(actualEntry []commandMenuEntry, err error) {
- assert.NoError(t, err)
- assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value)
- assert.EqualValues(t, "pr-1|upstream", actualEntry[0].label)
- },
- },
- {
- "Multiple named groups with group ids",
- "upstream/pr-1",
- "(?P[a-z]*)/(?P.*)",
- "{{ .group_2 }}|{{ .group_1 }}",
- "Remote: {{ .group_1 }}",
- func(actualEntry []commandMenuEntry, err error) {
- assert.NoError(t, err)
- assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value)
- assert.EqualValues(t, "Remote: upstream", actualEntry[0].label)
- },
- },
- }
-
- for _, s := range scenarios {
- s := s
- t.Run(s.testName, func(t *testing.T) {
- s.test(NewDummyGui().GenerateMenuCandidates(s.cmdOut, s.filter, s.valueFormat, s.labelFormat))
- })
- }
-}
diff --git a/pkg/gui/custom_patch_options_panel.go b/pkg/gui/custom_patch_options_panel.go
new file mode 100644
index 000000000..a508c7b44
--- /dev/null
+++ b/pkg/gui/custom_patch_options_panel.go
@@ -0,0 +1,194 @@
+package gui
+
+import (
+ "fmt"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/types/enums"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+func (gui *Gui) handleCreatePatchOptionsMenu() error {
+ if !gui.git.Patch.PatchManager.Active() {
+ return gui.c.ErrorMsg(gui.c.Tr.NoPatchError)
+ }
+
+ menuItems := []*types.MenuItem{
+ {
+ Label: "reset patch",
+ OnPress: gui.helpers.PatchBuilding.Reset,
+ Key: 'c',
+ },
+ {
+ Label: "apply patch",
+ OnPress: func() error { return gui.handleApplyPatch(false) },
+ Key: 'a',
+ },
+ {
+ Label: "apply patch in reverse",
+ OnPress: func() error { return gui.handleApplyPatch(true) },
+ Key: 'r',
+ },
+ }
+
+ if gui.git.Patch.PatchManager.CanRebase && gui.git.Status.WorkingTreeState() == enums.REBASE_MODE_NONE {
+ menuItems = append(menuItems, []*types.MenuItem{
+ {
+ Label: fmt.Sprintf("remove patch from original commit (%s)", gui.git.Patch.PatchManager.To),
+ OnPress: gui.handleDeletePatchFromCommit,
+ Key: 'd',
+ },
+ {
+ Label: "move patch out into index",
+ OnPress: gui.handleMovePatchIntoWorkingTree,
+ Key: 'i',
+ },
+ {
+ Label: "move patch into new commit",
+ OnPress: gui.handlePullPatchIntoNewCommit,
+ Key: 'n',
+ },
+ }...)
+
+ if gui.currentContext().GetKey() == gui.State.Contexts.LocalCommits.GetKey() {
+ selectedCommit := gui.getSelectedLocalCommit()
+ if selectedCommit != nil && gui.git.Patch.PatchManager.To != selectedCommit.Sha {
+ // adding this option to index 1
+ menuItems = append(
+ menuItems[:1],
+ append(
+ []*types.MenuItem{
+ {
+ Label: fmt.Sprintf("move patch to selected commit (%s)", selectedCommit.Sha),
+ OnPress: gui.handleMovePatchToSelectedCommit,
+ Key: 'm',
+ },
+ }, menuItems[1:]...,
+ )...,
+ )
+ }
+ }
+ }
+
+ return gui.c.Menu(types.CreateMenuOptions{Title: gui.c.Tr.PatchOptionsTitle, Items: menuItems})
+}
+
+func (gui *Gui) getPatchCommitIndex() int {
+ for index, commit := range gui.State.Model.Commits {
+ if commit.Sha == gui.git.Patch.PatchManager.To {
+ return index
+ }
+ }
+ return -1
+}
+
+func (gui *Gui) validateNormalWorkingTreeState() (bool, error) {
+ if gui.git.Status.WorkingTreeState() != enums.REBASE_MODE_NONE {
+ return false, gui.c.ErrorMsg(gui.c.Tr.CantPatchWhileRebasingError)
+ }
+ return true, nil
+}
+
+func (gui *Gui) returnFocusFromPatchExplorerIfNecessary() error {
+ if gui.currentContext().GetKey() == gui.State.Contexts.CustomPatchBuilder.GetKey() {
+ return gui.helpers.PatchBuilding.Escape()
+ }
+ return nil
+}
+
+func (gui *Gui) handleDeletePatchFromCommit() error {
+ if ok, err := gui.validateNormalWorkingTreeState(); !ok {
+ return err
+ }
+
+ if err := gui.returnFocusFromPatchExplorerIfNecessary(); err != nil {
+ return err
+ }
+
+ return gui.c.WithWaitingStatus(gui.c.Tr.RebasingStatus, func() error {
+ commitIndex := gui.getPatchCommitIndex()
+ gui.c.LogAction(gui.c.Tr.Actions.RemovePatchFromCommit)
+ err := gui.git.Patch.DeletePatchesFromCommit(gui.State.Model.Commits, commitIndex)
+ return gui.helpers.MergeAndRebase.CheckMergeOrRebase(err)
+ })
+}
+
+func (gui *Gui) handleMovePatchToSelectedCommit() error {
+ if ok, err := gui.validateNormalWorkingTreeState(); !ok {
+ return err
+ }
+
+ if err := gui.returnFocusFromPatchExplorerIfNecessary(); err != nil {
+ return err
+ }
+
+ return gui.c.WithWaitingStatus(gui.c.Tr.RebasingStatus, func() error {
+ commitIndex := gui.getPatchCommitIndex()
+ gui.c.LogAction(gui.c.Tr.Actions.MovePatchToSelectedCommit)
+ err := gui.git.Patch.MovePatchToSelectedCommit(gui.State.Model.Commits, commitIndex, gui.State.Contexts.LocalCommits.GetSelectedLineIdx())
+ return gui.helpers.MergeAndRebase.CheckMergeOrRebase(err)
+ })
+}
+
+func (gui *Gui) handleMovePatchIntoWorkingTree() error {
+ if ok, err := gui.validateNormalWorkingTreeState(); !ok {
+ return err
+ }
+
+ if err := gui.returnFocusFromPatchExplorerIfNecessary(); err != nil {
+ return err
+ }
+
+ pull := func(stash bool) error {
+ return gui.c.WithWaitingStatus(gui.c.Tr.RebasingStatus, func() error {
+ commitIndex := gui.getPatchCommitIndex()
+ gui.c.LogAction(gui.c.Tr.Actions.MovePatchIntoIndex)
+ err := gui.git.Patch.MovePatchIntoIndex(gui.State.Model.Commits, commitIndex, stash)
+ return gui.helpers.MergeAndRebase.CheckMergeOrRebase(err)
+ })
+ }
+
+ if gui.helpers.WorkingTree.IsWorkingTreeDirty() {
+ return gui.c.Confirm(types.ConfirmOpts{
+ Title: gui.c.Tr.MustStashTitle,
+ Prompt: gui.c.Tr.MustStashWarning,
+ HandleConfirm: func() error {
+ return pull(true)
+ },
+ })
+ } else {
+ return pull(false)
+ }
+}
+
+func (gui *Gui) handlePullPatchIntoNewCommit() error {
+ if ok, err := gui.validateNormalWorkingTreeState(); !ok {
+ return err
+ }
+
+ if err := gui.returnFocusFromPatchExplorerIfNecessary(); err != nil {
+ return err
+ }
+
+ return gui.c.WithWaitingStatus(gui.c.Tr.RebasingStatus, func() error {
+ commitIndex := gui.getPatchCommitIndex()
+ gui.c.LogAction(gui.c.Tr.Actions.MovePatchIntoNewCommit)
+ err := gui.git.Patch.PullPatchIntoNewCommit(gui.State.Model.Commits, commitIndex)
+ return gui.helpers.MergeAndRebase.CheckMergeOrRebase(err)
+ })
+}
+
+func (gui *Gui) handleApplyPatch(reverse bool) error {
+ if err := gui.returnFocusFromPatchExplorerIfNecessary(); err != nil {
+ return err
+ }
+
+ action := gui.c.Tr.Actions.ApplyPatch
+ if reverse {
+ action = "Apply patch in reverse"
+ }
+ gui.c.LogAction(action)
+ if err := gui.git.Patch.PatchManager.ApplyPatches(reverse); err != nil {
+ return gui.c.Error(err)
+ }
+ return gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
+}
diff --git a/pkg/gui/diff_context_size.go b/pkg/gui/diff_context_size.go
deleted file mode 100644
index 3b6f6b0a9..000000000
--- a/pkg/gui/diff_context_size.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package gui
-
-import (
- "errors"
-)
-
-var CONTEXT_KEYS_SHOWING_DIFFS = []ContextKey{
- FILES_CONTEXT_KEY,
- COMMIT_FILES_CONTEXT_KEY,
- STASH_CONTEXT_KEY,
- BRANCH_COMMITS_CONTEXT_KEY,
- SUB_COMMITS_CONTEXT_KEY,
- MAIN_STAGING_CONTEXT_KEY,
- MAIN_PATCH_BUILDING_CONTEXT_KEY,
-}
-
-func isShowingDiff(gui *Gui) bool {
- key := gui.currentStaticContext().GetKey()
-
- for _, contextKey := range CONTEXT_KEYS_SHOWING_DIFFS {
- if key == contextKey {
- return true
- }
- }
- return false
-}
-
-func (gui *Gui) IncreaseContextInDiffView() error {
- if isShowingDiff(gui) {
- if err := gui.CheckCanChangeContext(); err != nil {
- return gui.surfaceError(err)
- }
-
- gui.UserConfig.Git.DiffContextSize = gui.UserConfig.Git.DiffContextSize + 1
- return gui.handleDiffContextSizeChange()
- }
-
- return nil
-}
-
-func (gui *Gui) DecreaseContextInDiffView() error {
- old_size := gui.UserConfig.Git.DiffContextSize
-
- if isShowingDiff(gui) && old_size > 1 {
- if err := gui.CheckCanChangeContext(); err != nil {
- return gui.surfaceError(err)
- }
-
- gui.UserConfig.Git.DiffContextSize = old_size - 1
- return gui.handleDiffContextSizeChange()
- }
-
- return nil
-}
-
-func (gui *Gui) handleDiffContextSizeChange() error {
- currentContext := gui.currentStaticContext()
- switch currentContext.GetKey() {
- // we make an exception for our staging and patch building contexts because they actually need to refresh their state afterwards.
- case MAIN_PATCH_BUILDING_CONTEXT_KEY:
- return gui.handleRefreshPatchBuildingPanel(-1)
- case MAIN_STAGING_CONTEXT_KEY:
- return gui.handleRefreshStagingPanel(false, -1)
- default:
- return currentContext.HandleRenderToMain()
- }
-}
-
-func (gui *Gui) CheckCanChangeContext() error {
- if gui.Git.Patch.PatchManager.Active() {
- return errors.New(gui.Tr.CantChangeContextSizeError)
- }
-
- return nil
-}
diff --git a/pkg/gui/diff_context_size_test.go b/pkg/gui/diff_context_size_test.go
index b459e40d0..62a784380 100644
--- a/pkg/gui/diff_context_size_test.go
+++ b/pkg/gui/diff_context_size_test.go
@@ -1,190 +1,182 @@
package gui
-import (
- "testing"
+// const diffForTest = `diff --git a/pkg/gui/diff_context_size.go b/pkg/gui/diff_context_size.go
+// index 0da0a982..742b7dcf 100644
+// --- a/pkg/gui/diff_context_size.go
+// +++ b/pkg/gui/diff_context_size.go
+// @@ -9,12 +9,12 @@ func getRefreshFunction(gui *Gui) func()error {
+// }
+// } else if key == context.MAIN_STAGING_CONTEXT_KEY {
+// return func() error {
+// - selectedLine := gui.Views.Secondary.SelectedLineIdx()
+// + selectedLine := gui.State.Panels.LineByLine.GetSelectedLineIdx()
+// return gui.handleRefreshStagingPanel(false, selectedLine)
+// }
+// } else if key == context.MAIN_PATCH_BUILDING_CONTEXT_KEY {
+// `
- "github.com/jesseduffield/gocui"
- "github.com/jesseduffield/lazygit/pkg/commands/patch"
- "github.com/stretchr/testify/assert"
-)
+// func setupGuiForTest(gui *Gui) {
+// gui.g = &gocui.Gui{}
+// gui.Views.Main, _ = gui.prepareView("main")
+// gui.Views.Secondary, _ = gui.prepareView("secondary")
+// gui.Views.Options, _ = gui.prepareView("options")
+// gui.git.Patch.PatchManager = &patch.PatchManager{}
+// _, _ = gui.refreshLineByLinePanel(diffForTest, "", false, 11)
+// }
-const diffForTest = `diff --git a/pkg/gui/diff_context_size.go b/pkg/gui/diff_context_size.go
-index 0da0a982..742b7dcf 100644
---- a/pkg/gui/diff_context_size.go
-+++ b/pkg/gui/diff_context_size.go
-@@ -9,12 +9,12 @@ func getRefreshFunction(gui *Gui) func()error {
- }
- } else if key == MAIN_STAGING_CONTEXT_KEY {
- return func() error {
-- selectedLine := gui.Views.Secondary.SelectedLineIdx()
-+ selectedLine := gui.State.Panels.LineByLine.GetSelectedLineIdx()
- return gui.handleRefreshStagingPanel(false, selectedLine)
- }
- } else if key == MAIN_PATCH_BUILDING_CONTEXT_KEY {
-`
+// func TestIncreasesContextInDiffViewByOneInContextWithDiff(t *testing.T) {
+// contexts := []func(gui *Gui) types.Context{
+// func(gui *Gui) types.Context { return gui.State.Contexts.Files },
+// func(gui *Gui) types.Context { return gui.State.Contexts.BranchCommits },
+// func(gui *Gui) types.Context { return gui.State.Contexts.CommitFiles },
+// func(gui *Gui) types.Context { return gui.State.Contexts.Stash },
+// func(gui *Gui) types.Context { return gui.State.Contexts.Staging },
+// func(gui *Gui) types.Context { return gui.State.Contexts.PatchBuilding },
+// func(gui *Gui) types.Context { return gui.State.Contexts.SubCommits },
+// }
-func setupGuiForTest(gui *Gui) {
- gui.g = &gocui.Gui{}
- gui.Views.Main, _ = gui.prepareView("main")
- gui.Views.Secondary, _ = gui.prepareView("secondary")
- gui.Views.Options, _ = gui.prepareView("options")
- gui.Git.Patch.PatchManager = &patch.PatchManager{}
- _, _ = gui.refreshLineByLinePanel(diffForTest, "", false, 11)
-}
+// for _, c := range contexts {
+// gui := NewDummyGui()
+// context := c(gui)
+// setupGuiForTest(gui)
+// gui.c.UserConfig.Git.DiffContextSize = 1
+// _ = gui.c.PushContext(context)
-func TestIncreasesContextInDiffViewByOneInContextWithDiff(t *testing.T) {
- contexts := []func(gui *Gui) Context{
- func(gui *Gui) Context { return gui.State.Contexts.Files },
- func(gui *Gui) Context { return gui.State.Contexts.BranchCommits },
- func(gui *Gui) Context { return gui.State.Contexts.CommitFiles },
- func(gui *Gui) Context { return gui.State.Contexts.Stash },
- func(gui *Gui) Context { return gui.State.Contexts.Staging },
- func(gui *Gui) Context { return gui.State.Contexts.PatchBuilding },
- func(gui *Gui) Context { return gui.State.Contexts.SubCommits },
- }
+// _ = gui.IncreaseContextInDiffView()
- for _, c := range contexts {
- gui := NewDummyGui()
- context := c(gui)
- setupGuiForTest(gui)
- gui.UserConfig.Git.DiffContextSize = 1
- _ = gui.pushContext(context)
+// assert.Equal(t, 2, gui.c.UserConfig.Git.DiffContextSize, string(context.GetKey()))
+// }
+// }
- _ = gui.IncreaseContextInDiffView()
+// func TestDoesntIncreaseContextInDiffViewInContextWithoutDiff(t *testing.T) {
+// contexts := []func(gui *Gui) types.Context{
+// func(gui *Gui) types.Context { return gui.State.Contexts.Status },
+// func(gui *Gui) types.Context { return gui.State.Contexts.Submodules },
+// func(gui *Gui) types.Context { return gui.State.Contexts.Remotes },
+// func(gui *Gui) types.Context { return gui.State.Contexts.Normal },
+// func(gui *Gui) types.Context { return gui.State.Contexts.ReflogCommits },
+// func(gui *Gui) types.Context { return gui.State.Contexts.RemoteBranches },
+// func(gui *Gui) types.Context { return gui.State.Contexts.Tags },
+// // not testing this because it will kick straight back to the files context
+// // upon pushing the context
+// // func(gui *Gui) types.Context { return gui.State.Contexts.Merging },
+// func(gui *Gui) types.Context { return gui.State.Contexts.CommandLog },
+// }
- assert.Equal(t, 2, gui.UserConfig.Git.DiffContextSize, string(context.GetKey()))
- }
-}
+// for _, c := range contexts {
+// gui := NewDummyGui()
+// context := c(gui)
+// setupGuiForTest(gui)
+// gui.c.UserConfig.Git.DiffContextSize = 1
+// _ = gui.c.PushContext(context)
-func TestDoesntIncreaseContextInDiffViewInContextWithoutDiff(t *testing.T) {
- contexts := []func(gui *Gui) Context{
- func(gui *Gui) Context { return gui.State.Contexts.Status },
- func(gui *Gui) Context { return gui.State.Contexts.Submodules },
- func(gui *Gui) Context { return gui.State.Contexts.Remotes },
- func(gui *Gui) Context { return gui.State.Contexts.Normal },
- func(gui *Gui) Context { return gui.State.Contexts.ReflogCommits },
- func(gui *Gui) Context { return gui.State.Contexts.RemoteBranches },
- func(gui *Gui) Context { return gui.State.Contexts.Tags },
- // not testing this because it will kick straight back to the files context
- // upon pushing the context
- // func(gui *Gui) Context { return gui.State.Contexts.Merging },
- func(gui *Gui) Context { return gui.State.Contexts.CommandLog },
- }
+// _ = gui.IncreaseContextInDiffView()
- for _, c := range contexts {
- gui := NewDummyGui()
- context := c(gui)
- setupGuiForTest(gui)
- gui.UserConfig.Git.DiffContextSize = 1
- _ = gui.pushContext(context)
+// assert.Equal(t, 1, gui.c.UserConfig.Git.DiffContextSize, string(context.GetKey()))
+// }
+// }
- _ = gui.IncreaseContextInDiffView()
+// func TestDecreasesContextInDiffViewByOneInContextWithDiff(t *testing.T) {
+// contexts := []func(gui *Gui) types.Context{
+// func(gui *Gui) types.Context { return gui.State.Contexts.Files },
+// func(gui *Gui) types.Context { return gui.State.Contexts.BranchCommits },
+// func(gui *Gui) types.Context { return gui.State.Contexts.CommitFiles },
+// func(gui *Gui) types.Context { return gui.State.Contexts.Stash },
+// func(gui *Gui) types.Context { return gui.State.Contexts.Staging },
+// func(gui *Gui) types.Context { return gui.State.Contexts.PatchBuilding },
+// func(gui *Gui) types.Context { return gui.State.Contexts.SubCommits },
+// }
- assert.Equal(t, 1, gui.UserConfig.Git.DiffContextSize, string(context.GetKey()))
- }
-}
+// for _, c := range contexts {
+// gui := NewDummyGui()
+// context := c(gui)
+// setupGuiForTest(gui)
+// gui.c.UserConfig.Git.DiffContextSize = 2
+// _ = gui.c.PushContext(context)
-func TestDecreasesContextInDiffViewByOneInContextWithDiff(t *testing.T) {
- contexts := []func(gui *Gui) Context{
- func(gui *Gui) Context { return gui.State.Contexts.Files },
- func(gui *Gui) Context { return gui.State.Contexts.BranchCommits },
- func(gui *Gui) Context { return gui.State.Contexts.CommitFiles },
- func(gui *Gui) Context { return gui.State.Contexts.Stash },
- func(gui *Gui) Context { return gui.State.Contexts.Staging },
- func(gui *Gui) Context { return gui.State.Contexts.PatchBuilding },
- func(gui *Gui) Context { return gui.State.Contexts.SubCommits },
- }
+// _ = gui.DecreaseContextInDiffView()
- for _, c := range contexts {
- gui := NewDummyGui()
- context := c(gui)
- setupGuiForTest(gui)
- gui.UserConfig.Git.DiffContextSize = 2
- _ = gui.pushContext(context)
+// assert.Equal(t, 1, gui.c.UserConfig.Git.DiffContextSize, string(context.GetKey()))
+// }
+// }
- _ = gui.DecreaseContextInDiffView()
+// func TestDoesntDecreaseContextInDiffViewInContextWithoutDiff(t *testing.T) {
+// contexts := []func(gui *Gui) types.Context{
+// func(gui *Gui) types.Context { return gui.State.Contexts.Status },
+// func(gui *Gui) types.Context { return gui.State.Contexts.Submodules },
+// func(gui *Gui) types.Context { return gui.State.Contexts.Remotes },
+// func(gui *Gui) types.Context { return gui.State.Contexts.Normal },
+// func(gui *Gui) types.Context { return gui.State.Contexts.ReflogCommits },
+// func(gui *Gui) types.Context { return gui.State.Contexts.RemoteBranches },
+// func(gui *Gui) types.Context { return gui.State.Contexts.Tags },
+// // not testing this because it will kick straight back to the files context
+// // upon pushing the context
+// // func(gui *Gui) types.Context { return gui.State.Contexts.Merging },
+// func(gui *Gui) types.Context { return gui.State.Contexts.CommandLog },
+// }
- assert.Equal(t, 1, gui.UserConfig.Git.DiffContextSize, string(context.GetKey()))
- }
-}
+// for _, c := range contexts {
+// gui := NewDummyGui()
+// context := c(gui)
+// setupGuiForTest(gui)
+// gui.c.UserConfig.Git.DiffContextSize = 2
+// _ = gui.c.PushContext(context)
-func TestDoesntDecreaseContextInDiffViewInContextWithoutDiff(t *testing.T) {
- contexts := []func(gui *Gui) Context{
- func(gui *Gui) Context { return gui.State.Contexts.Status },
- func(gui *Gui) Context { return gui.State.Contexts.Submodules },
- func(gui *Gui) Context { return gui.State.Contexts.Remotes },
- func(gui *Gui) Context { return gui.State.Contexts.Normal },
- func(gui *Gui) Context { return gui.State.Contexts.ReflogCommits },
- func(gui *Gui) Context { return gui.State.Contexts.RemoteBranches },
- func(gui *Gui) Context { return gui.State.Contexts.Tags },
- // not testing this because it will kick straight back to the files context
- // upon pushing the context
- // func(gui *Gui) Context { return gui.State.Contexts.Merging },
- func(gui *Gui) Context { return gui.State.Contexts.CommandLog },
- }
+// _ = gui.DecreaseContextInDiffView()
- for _, c := range contexts {
- gui := NewDummyGui()
- context := c(gui)
- setupGuiForTest(gui)
- gui.UserConfig.Git.DiffContextSize = 2
- _ = gui.pushContext(context)
+// assert.Equal(t, 2, gui.c.UserConfig.Git.DiffContextSize, string(context.GetKey()))
+// }
+// }
- _ = gui.DecreaseContextInDiffView()
+// func TestDoesntIncreaseContextInDiffViewInContextWhenInPatchBuildingMode(t *testing.T) {
+// gui := NewDummyGui()
+// setupGuiForTest(gui)
+// gui.c.UserConfig.Git.DiffContextSize = 2
+// _ = gui.c.PushContext(gui.State.Contexts.CommitFiles)
+// gui.git.Patch.PatchManager.Start("from", "to", false, false)
- assert.Equal(t, 2, gui.UserConfig.Git.DiffContextSize, string(context.GetKey()))
- }
-}
+// errorCount := 0
+// gui.PopupHandler = &popup.TestPopupHandler{
+// OnErrorMsg: func(message string) error {
+// assert.Equal(t, gui.c.Tr.CantChangeContextSizeError, message)
+// errorCount += 1
+// return nil
+// },
+// }
-func TestDoesntIncreaseContextInDiffViewInContextWhenInPatchBuildingMode(t *testing.T) {
- gui := NewDummyGui()
- setupGuiForTest(gui)
- gui.UserConfig.Git.DiffContextSize = 2
- _ = gui.pushContext(gui.State.Contexts.CommitFiles)
- gui.Git.Patch.PatchManager.Start("from", "to", false, false)
+// _ = gui.IncreaseContextInDiffView()
- errorCount := 0
- gui.PopupHandler = &TestPopupHandler{
- onError: func(message string) error {
- assert.Equal(t, gui.Tr.CantChangeContextSizeError, message)
- errorCount += 1
- return nil
- },
- }
+// assert.Equal(t, 1, errorCount)
+// assert.Equal(t, 2, gui.c.UserConfig.Git.DiffContextSize)
+// }
- _ = gui.IncreaseContextInDiffView()
+// func TestDoesntDecreaseContextInDiffViewInContextWhenInPatchBuildingMode(t *testing.T) {
+// gui := NewDummyGui()
+// setupGuiForTest(gui)
+// gui.c.UserConfig.Git.DiffContextSize = 2
+// _ = gui.c.PushContext(gui.State.Contexts.CommitFiles)
+// gui.git.Patch.PatchManager.Start("from", "to", false, false)
- assert.Equal(t, 1, errorCount)
- assert.Equal(t, 2, gui.UserConfig.Git.DiffContextSize)
-}
+// errorCount := 0
+// gui.PopupHandler = &popup.TestPopupHandler{
+// OnErrorMsg: func(message string) error {
+// assert.Equal(t, gui.c.Tr.CantChangeContextSizeError, message)
+// errorCount += 1
+// return nil
+// },
+// }
-func TestDoesntDecreaseContextInDiffViewInContextWhenInPatchBuildingMode(t *testing.T) {
- gui := NewDummyGui()
- setupGuiForTest(gui)
- gui.UserConfig.Git.DiffContextSize = 2
- _ = gui.pushContext(gui.State.Contexts.CommitFiles)
- gui.Git.Patch.PatchManager.Start("from", "to", false, false)
+// _ = gui.DecreaseContextInDiffView()
- errorCount := 0
- gui.PopupHandler = &TestPopupHandler{
- onError: func(message string) error {
- assert.Equal(t, gui.Tr.CantChangeContextSizeError, message)
- errorCount += 1
- return nil
- },
- }
+// assert.Equal(t, 2, gui.c.UserConfig.Git.DiffContextSize)
+// }
- _ = gui.DecreaseContextInDiffView()
+// func TestDecreasesContextInDiffViewNoFurtherThanOne(t *testing.T) {
+// gui := NewDummyGui()
+// setupGuiForTest(gui)
+// gui.c.UserConfig.Git.DiffContextSize = 1
- assert.Equal(t, 2, gui.UserConfig.Git.DiffContextSize)
-}
+// _ = gui.DecreaseContextInDiffView()
-func TestDecreasesContextInDiffViewNoFurtherThanOne(t *testing.T) {
- gui := NewDummyGui()
- setupGuiForTest(gui)
- gui.UserConfig.Git.DiffContextSize = 1
-
- _ = gui.DecreaseContextInDiffView()
-
- assert.Equal(t, 1, gui.UserConfig.Git.DiffContextSize)
-}
+// assert.Equal(t, 1, gui.c.UserConfig.Git.DiffContextSize)
+// }
diff --git a/pkg/gui/diffing.go b/pkg/gui/diffing.go
index 2721a1880..f5fbde2a2 100644
--- a/pkg/gui/diffing.go
+++ b/pkg/gui/diffing.go
@@ -4,24 +4,27 @@ import (
"fmt"
"strings"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/modes/diffing"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
func (gui *Gui) exitDiffMode() error {
gui.State.Modes.Diffing = diffing.New()
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
+ return gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
}
func (gui *Gui) renderDiff() error {
- cmdObj := gui.OSCommand.Cmd.New(
+ cmdObj := gui.os.Cmd.New(
fmt.Sprintf("git diff --submodule --no-ext-diff --color %s", gui.diffStr()),
)
- task := NewRunPtyTask(cmdObj.GetCmd())
+ task := types.NewRunPtyTask(cmdObj.GetCmd())
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "Diff",
- task: task,
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: "Diff",
+ Task: task,
},
})
}
@@ -31,17 +34,21 @@ func (gui *Gui) renderDiff() error {
// which becomes an option when you bring up the diff menu, but when you're just
// flicking through branches it will be using the local branch name.
func (gui *Gui) currentDiffTerminals() []string {
- switch gui.currentContext().GetKey() {
- case "":
+ c := gui.currentSideContext()
+
+ if c.GetKey() == "" {
return nil
- case FILES_CONTEXT_KEY, SUBMODULES_CONTEXT_KEY:
+ }
+
+ switch v := c.(type) {
+ case *context.WorkingTreeContext, *context.SubmodulesContext:
// TODO: should we just return nil here?
return []string{""}
- case COMMIT_FILES_CONTEXT_KEY:
- return []string{gui.State.Panels.CommitFiles.refName}
- case LOCAL_BRANCHES_CONTEXT_KEY:
+ case *context.CommitFilesContext:
+ return []string{v.GetRef().RefName()}
+ case *context.BranchesContext:
// for our local branches we want to include both the branch and its upstream
- branch := gui.getSelectedBranch()
+ branch := gui.State.Contexts.Branches.GetSelected()
if branch != nil {
names := []string{branch.ID()}
if branch.IsTrackingRemote() {
@@ -50,17 +57,13 @@ func (gui *Gui) currentDiffTerminals() []string {
return names
}
return nil
- default:
- context := gui.currentSideListContext()
- if context == nil {
- return nil
- }
- item, ok := context.GetSelectedItem()
- if !ok {
- return nil
- }
- return []string{item.ID()}
+ case types.IListContext:
+ itemId := v.GetSelectedItemId()
+
+ return []string{itemId}
}
+
+ return nil
}
func (gui *Gui) currentDiffTerminal() string {
@@ -73,7 +76,7 @@ func (gui *Gui) currentDiffTerminal() string {
func (gui *Gui) currentlySelectedFilename() string {
switch gui.currentContext().GetKey() {
- case FILES_CONTEXT_KEY, COMMIT_FILES_CONTEXT_KEY:
+ case context.FILES_CONTEXT_KEY, context.COMMIT_FILES_CONTEXT_KEY:
return gui.getSideContextSelectedItemId()
default:
return ""
@@ -105,31 +108,31 @@ func (gui *Gui) diffStr() string {
func (gui *Gui) handleCreateDiffingMenuPanel() error {
names := gui.currentDiffTerminals()
- menuItems := []*menuItem{}
+ menuItems := []*types.MenuItem{}
for _, name := range names {
name := name
- menuItems = append(menuItems, []*menuItem{
+ menuItems = append(menuItems, []*types.MenuItem{
{
- displayString: fmt.Sprintf("%s %s", gui.Tr.LcDiff, name),
- onPress: func() error {
+ Label: fmt.Sprintf("%s %s", gui.c.Tr.LcDiff, name),
+ OnPress: func() error {
gui.State.Modes.Diffing.Ref = name
// can scope this down based on current view but too lazy right now
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
+ return gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
},
},
}...)
}
- menuItems = append(menuItems, []*menuItem{
+ menuItems = append(menuItems, []*types.MenuItem{
{
- displayString: gui.Tr.LcEnterRefToDiff,
- onPress: func() error {
- return gui.prompt(promptOpts{
- title: gui.Tr.LcEnteRefName,
- findSuggestionsFunc: gui.getRefsSuggestionsFunc(),
- handleConfirm: func(response string) error {
+ Label: gui.c.Tr.LcEnterRefToDiff,
+ OnPress: func() error {
+ return gui.c.Prompt(types.PromptOpts{
+ Title: gui.c.Tr.LcEnteRefName,
+ FindSuggestionsFunc: gui.helpers.Suggestions.GetRefsSuggestionsFunc(),
+ HandleConfirm: func(response string) error {
gui.State.Modes.Diffing.Ref = strings.TrimSpace(response)
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
+ return gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
},
})
},
@@ -137,23 +140,23 @@ func (gui *Gui) handleCreateDiffingMenuPanel() error {
}...)
if gui.State.Modes.Diffing.Active() {
- menuItems = append(menuItems, []*menuItem{
+ menuItems = append(menuItems, []*types.MenuItem{
{
- displayString: gui.Tr.LcSwapDiff,
- onPress: func() error {
+ Label: gui.c.Tr.LcSwapDiff,
+ OnPress: func() error {
gui.State.Modes.Diffing.Reverse = !gui.State.Modes.Diffing.Reverse
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
+ return gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
},
},
{
- displayString: gui.Tr.LcExitDiffMode,
- onPress: func() error {
+ Label: gui.c.Tr.LcExitDiffMode,
+ OnPress: func() error {
gui.State.Modes.Diffing = diffing.New()
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
+ return gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
},
},
}...)
}
- return gui.createMenu(gui.Tr.DiffingMenuTitle, menuItems, createMenuOptions{showCancel: true})
+ return gui.c.Menu(types.CreateMenuOptions{Title: gui.c.Tr.DiffingMenuTitle, Items: menuItems})
}
diff --git a/pkg/gui/discard_changes_menu_panel.go b/pkg/gui/discard_changes_menu_panel.go
deleted file mode 100644
index 673f057e8..000000000
--- a/pkg/gui/discard_changes_menu_panel.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package gui
-
-func (gui *Gui) handleCreateDiscardMenu() error {
- node := gui.getSelectedFileNode()
- if node == nil {
- return nil
- }
-
- var menuItems []*menuItem
- if node.File == nil {
- menuItems = []*menuItem{
- {
- displayString: gui.Tr.LcDiscardAllChanges,
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.DiscardAllChangesInDirectory)
- if err := gui.Git.WorkingTree.DiscardAllDirChanges(node); err != nil {
- return gui.surfaceError(err)
- }
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
- },
- },
- }
-
- if node.GetHasStagedChanges() && node.GetHasUnstagedChanges() {
- menuItems = append(menuItems, &menuItem{
- displayString: gui.Tr.LcDiscardUnstagedChanges,
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.DiscardUnstagedChangesInDirectory)
- if err := gui.Git.WorkingTree.DiscardUnstagedDirChanges(node); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
- },
- })
- }
- } else {
- file := node.File
-
- submodules := gui.State.Submodules
- if file.IsSubmodule(submodules) {
- submodule := file.SubmoduleConfig(submodules)
-
- menuItems = []*menuItem{
- {
- displayString: gui.Tr.LcSubmoduleStashAndReset,
- onPress: func() error {
- return gui.handleResetSubmodule(submodule)
- },
- },
- }
- } else {
- menuItems = []*menuItem{
- {
- displayString: gui.Tr.LcDiscardAllChanges,
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.DiscardAllChangesInFile)
- if err := gui.Git.WorkingTree.DiscardAllFileChanges(file); err != nil {
- return gui.surfaceError(err)
- }
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
- },
- },
- }
-
- if file.HasStagedChanges && file.HasUnstagedChanges {
- menuItems = append(menuItems, &menuItem{
- displayString: gui.Tr.LcDiscardUnstagedChanges,
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.DiscardAllUnstagedChangesInFile)
- if err := gui.Git.WorkingTree.DiscardUnstagedFileChanges(file); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
- },
- })
- }
- }
- }
-
- return gui.createMenu(node.GetPath(), menuItems, createMenuOptions{showCancel: true})
-}
diff --git a/pkg/gui/dummies.go b/pkg/gui/dummies.go
index 587460ccd..52112e122 100644
--- a/pkg/gui/dummies.go
+++ b/pkg/gui/dummies.go
@@ -17,6 +17,6 @@ func NewDummyUpdater() *updates.Updater {
func NewDummyGui() *Gui {
newAppConfig := config.NewDummyAppConfig()
- dummyGui, _ := NewGui(utils.NewDummyCommon(), newAppConfig, git_config.NewFakeGitConfig(nil), NewDummyUpdater(), "", false)
+ dummyGui, _ := NewGui(utils.NewDummyCommon(), newAppConfig, git_config.NewFakeGitConfig(nil), NewDummyUpdater(), false, "")
return dummyGui
}
diff --git a/pkg/gui/editors.go b/pkg/gui/editors.go
index 054a36094..6f40c6d58 100644
--- a/pkg/gui/editors.go
+++ b/pkg/gui/editors.go
@@ -4,10 +4,11 @@ import (
"unicode"
"github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
)
func (gui *Gui) handleEditorKeypress(textArea *gocui.TextArea, key gocui.Key, ch rune, mod gocui.Modifier, allowMultiline bool) bool {
- newlineKey, ok := gui.getKey(gui.UserConfig.Keybinding.Universal.AppendNewline).(gocui.Key)
+ newlineKey, ok := keybindings.GetKey(gui.c.UserConfig.Keybinding.Universal.AppendNewline).(gocui.Key)
if !ok {
newlineKey = gocui.KeyAltEnter
}
@@ -62,7 +63,7 @@ func (gui *Gui) commitMessageEditor(v *gocui.View, key gocui.Key, ch rune, mod g
// considered out of bounds to add a newline, meaning we can avoid unnecessary scrolling.
err := gui.resizePopupPanel(v, v.TextArea.GetContent())
if err != nil {
- gui.Log.Error(err)
+ gui.c.Log.Error(err)
}
v.RenderTextArea()
gui.RenderCommitLength()
diff --git a/pkg/gui/extras_panel.go b/pkg/gui/extras_panel.go
index 7d68bb1ec..c36f12a66 100644
--- a/pkg/gui/extras_panel.go
+++ b/pkg/gui/extras_panel.go
@@ -3,60 +3,64 @@ package gui
import (
"io"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
func (gui *Gui) handleCreateExtrasMenuPanel() error {
- menuItems := []*menuItem{
- {
- displayString: gui.Tr.ToggleShowCommandLog,
- onPress: func() error {
- currentContext := gui.currentStaticContext()
- if gui.ShowExtrasWindow && currentContext.GetKey() == COMMAND_LOG_CONTEXT_KEY {
- if err := gui.returnFromContext(); err != nil {
- return err
+ return gui.c.Menu(types.CreateMenuOptions{
+ Title: gui.c.Tr.CommandLog,
+ Items: []*types.MenuItem{
+ {
+ Label: gui.c.Tr.ToggleShowCommandLog,
+ OnPress: func() error {
+ currentContext := gui.currentStaticContext()
+ if gui.ShowExtrasWindow && currentContext.GetKey() == context.COMMAND_LOG_CONTEXT_KEY {
+ if err := gui.c.PopContext(); err != nil {
+ return err
+ }
}
- }
- show := !gui.ShowExtrasWindow
- gui.ShowExtrasWindow = show
- gui.Config.GetAppState().HideCommandLog = !show
- _ = gui.Config.SaveAppState()
- return nil
+ show := !gui.ShowExtrasWindow
+ gui.ShowExtrasWindow = show
+ gui.c.GetAppState().HideCommandLog = !show
+ _ = gui.c.SaveAppState()
+ return nil
+ },
+ },
+ {
+ Label: gui.c.Tr.FocusCommandLog,
+ OnPress: gui.handleFocusCommandLog,
},
},
- {
- displayString: gui.Tr.FocusCommandLog,
- onPress: gui.handleFocusCommandLog,
- },
- }
-
- return gui.createMenu(gui.Tr.CommandLog, menuItems, createMenuOptions{showCancel: true})
+ })
}
func (gui *Gui) handleFocusCommandLog() error {
gui.ShowExtrasWindow = true
+ // TODO: is this necessary? Can't I just call 'return from context'?
gui.State.Contexts.CommandLog.SetParentContext(gui.currentSideContext())
- return gui.pushContext(gui.State.Contexts.CommandLog)
+ return gui.c.PushContext(gui.State.Contexts.CommandLog)
}
func (gui *Gui) scrollUpExtra() error {
gui.Views.Extras.Autoscroll = false
- return gui.scrollUpView(gui.Views.Extras)
+ gui.scrollUpView(gui.Views.Extras)
+
+ return nil
}
func (gui *Gui) scrollDownExtra() error {
gui.Views.Extras.Autoscroll = false
- if err := gui.scrollDownView(gui.Views.Extras); err != nil {
- return err
- }
+ gui.scrollDownView(gui.Views.Extras)
return nil
}
func (gui *Gui) getCmdWriter() io.Writer {
- return &prefixWriter{writer: gui.Views.Extras, prefix: style.FgMagenta.Sprintf("\n\n%s\n", gui.Tr.GitOutput)}
+ return &prefixWriter{writer: gui.Views.Extras, prefix: style.FgMagenta.Sprintf("\n\n%s\n", gui.c.Tr.GitOutput)}
}
// Ensures that the first write is preceded by writing a prefix.
diff --git a/pkg/gui/file_watching.go b/pkg/gui/file_watching.go
index f5749a97d..01a2d0b88 100644
--- a/pkg/gui/file_watching.go
+++ b/pkg/gui/file_watching.go
@@ -6,6 +6,7 @@ import (
"github.com/fsnotify/fsnotify"
"github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sirupsen/logrus"
)
@@ -117,13 +118,13 @@ func (gui *Gui) watchFilesForChanges() {
}
// only refresh if we're not already
if !gui.State.IsRefreshingFiles {
- _ = gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
+ _ = gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
}
// watch for errors
case err := <-gui.fileWatcher.Watcher.Errors:
if err != nil {
- gui.Log.Error(err)
+ gui.c.Log.Error(err)
}
}
}
diff --git a/pkg/gui/files_panel.go b/pkg/gui/files_panel.go
index fa8cfa79c..128ebfe72 100644
--- a/pkg/gui/files_panel.go
+++ b/pkg/gui/files_panel.go
@@ -1,29 +1,14 @@
package gui
import (
- "fmt"
- "regexp"
- "strings"
-
- "github.com/jesseduffield/lazygit/pkg/commands/git_commands"
- "github.com/jesseduffield/lazygit/pkg/commands/loaders"
+ "github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/models"
- "github.com/jesseduffield/lazygit/pkg/commands/types/enums"
- "github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
- "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
- "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
-// list panel functions
-
func (gui *Gui) getSelectedFileNode() *filetree.FileNode {
- selectedLine := gui.State.Panels.Files.SelectedLineIdx
- if selectedLine == -1 {
- return nil
- }
-
- return gui.State.FileTreeViewModel.GetItemAtIndex(selectedLine)
+ return gui.State.Contexts.Files.GetSelected()
}
func (gui *Gui) getSelectedFile() *models.File {
@@ -34,1032 +19,76 @@ func (gui *Gui) getSelectedFile() *models.File {
return node.File
}
-func (gui *Gui) getSelectedPath() string {
- node := gui.getSelectedFileNode()
- if node == nil {
- return ""
- }
-
- return node.GetPath()
-}
-
func (gui *Gui) filesRenderToMain() error {
node := gui.getSelectedFileNode()
if node == nil {
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "",
- task: NewRenderStringTask(gui.Tr.NoChangedFiles),
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: gui.c.Tr.DiffTitle,
+ Task: types.NewRenderStringTask(gui.c.Tr.NoChangedFiles),
},
})
}
if node.File != nil && node.File.HasInlineMergeConflicts {
- ok, err := gui.setConflictsAndRenderWithLock(node.GetPath(), false)
- if err != nil {
- return err
- }
- if ok {
- return nil
- }
- }
-
- gui.resetMergeStateWithLock()
-
- cmdObj := gui.Git.WorkingTree.WorktreeFileDiffCmdObj(node, false, !node.GetHasUnstagedChanges() && node.GetHasStagedChanges(), gui.State.IgnoreWhitespaceInDiffView)
-
- refreshOpts := refreshMainOpts{main: &viewUpdateOpts{
- title: gui.Tr.UnstagedChanges,
- task: NewRunPtyTask(cmdObj.GetCmd()),
- }}
-
- if node.GetHasUnstagedChanges() {
- if node.GetHasStagedChanges() {
- cmdObj := gui.Git.WorkingTree.WorktreeFileDiffCmdObj(node, false, true, gui.State.IgnoreWhitespaceInDiffView)
-
- refreshOpts.secondary = &viewUpdateOpts{
- title: gui.Tr.StagedChanges,
- task: NewRunPtyTask(cmdObj.GetCmd()),
- }
- }
- } else {
- refreshOpts.main.title = gui.Tr.StagedChanges
- }
-
- return gui.refreshMainViews(refreshOpts)
-}
-
-func (gui *Gui) refreshFilesAndSubmodules() error {
- gui.Mutexes.RefreshingFilesMutex.Lock()
- gui.State.IsRefreshingFiles = true
- defer func() {
- gui.State.IsRefreshingFiles = false
- gui.Mutexes.RefreshingFilesMutex.Unlock()
- }()
-
- prevSelectedPath := gui.getSelectedPath()
-
- if err := gui.refreshStateSubmoduleConfigs(); err != nil {
- return err
- }
-
- if err := gui.refreshMergeState(); err != nil {
- return err
- }
-
- if err := gui.refreshStateFiles(); err != nil {
- return err
- }
-
- gui.OnUIThread(func() error {
- if err := gui.postRefreshUpdate(gui.State.Contexts.Submodules); err != nil {
- gui.Log.Error(err)
- }
-
- if ContextKey(gui.Views.Files.Context) == FILES_CONTEXT_KEY {
- // doing this a little custom (as opposed to using gui.postRefreshUpdate) because we handle selecting the file explicitly below
- if err := gui.State.Contexts.Files.HandleRender(); err != nil {
- return err
- }
- }
-
- if gui.currentContext().GetKey() == FILES_CONTEXT_KEY {
- currentSelectedPath := gui.getSelectedPath()
- alreadySelected := prevSelectedPath != "" && currentSelectedPath == prevSelectedPath
- if !alreadySelected {
- gui.takeOverMergeConflictScrolling()
- }
-
- gui.Views.Files.FocusPoint(0, gui.State.Panels.Files.SelectedLineIdx)
- return gui.filesRenderToMain()
- }
-
- return nil
- })
-
- return nil
-}
-
-// specific functions
-
-func (gui *Gui) stagedFiles() []*models.File {
- files := gui.State.FileTreeViewModel.GetAllFiles()
- result := make([]*models.File, 0)
- for _, file := range files {
- if file.HasStagedChanges {
- result = append(result, file)
- }
- }
- return result
-}
-
-func (gui *Gui) trackedFiles() []*models.File {
- files := gui.State.FileTreeViewModel.GetAllFiles()
- result := make([]*models.File, 0, len(files))
- for _, file := range files {
- if file.Tracked {
- result = append(result, file)
- }
- }
- return result
-}
-
-func (gui *Gui) handleEnterFile() error {
- return gui.enterFile(OnFocusOpts{ClickedViewName: "", ClickedViewLineIdx: -1})
-}
-
-func (gui *Gui) enterFile(opts OnFocusOpts) error {
- node := gui.getSelectedFileNode()
- if node == nil {
- return nil
- }
-
- if node.File == nil {
- return gui.handleToggleDirCollapsed()
- }
-
- file := node.File
-
- submoduleConfigs := gui.State.Submodules
- if file.IsSubmodule(submoduleConfigs) {
- submoduleConfig := file.SubmoduleConfig(submoduleConfigs)
- return gui.enterSubmodule(submoduleConfig)
- }
-
- if file.HasInlineMergeConflicts {
- return gui.switchToMerge()
- }
- if file.HasMergeConflicts {
- return gui.createErrorPanel(gui.Tr.FileStagingRequirements)
- }
-
- return gui.pushContext(gui.State.Contexts.Staging, opts)
-}
-
-func (gui *Gui) handleFilePress() error {
- node := gui.getSelectedFileNode()
- if node == nil {
- return nil
- }
-
- if node.IsLeaf() {
- file := node.File
-
- if file.HasInlineMergeConflicts {
- return gui.switchToMerge()
- }
-
- if file.HasUnstagedChanges {
- gui.logAction(gui.Tr.Actions.StageFile)
- if err := gui.Git.WorkingTree.StageFile(file.Name); err != nil {
- return gui.surfaceError(err)
- }
- } else {
- gui.logAction(gui.Tr.Actions.UnstageFile)
- if err := gui.Git.WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
- return gui.surfaceError(err)
- }
- }
- } else {
- // if any files within have inline merge conflicts we can't stage or unstage,
- // or it'll end up with those >>>>>> lines actually staged
- if node.GetHasInlineMergeConflicts() {
- return gui.createErrorPanel(gui.Tr.ErrStageDirWithInlineMergeConflicts)
- }
-
- if node.GetHasUnstagedChanges() {
- gui.logAction(gui.Tr.Actions.StageFile)
- if err := gui.Git.WorkingTree.StageFile(node.Path); err != nil {
- return gui.surfaceError(err)
- }
- } else {
- // pretty sure it doesn't matter that we're always passing true here
- gui.logAction(gui.Tr.Actions.UnstageFile)
- if err := gui.Git.WorkingTree.UnStageFile([]string{node.Path}, true); err != nil {
- return gui.surfaceError(err)
- }
- }
- }
-
- if err := gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}}); err != nil {
- return err
- }
-
- return gui.State.Contexts.Files.HandleFocus()
-}
-
-func (gui *Gui) allFilesStaged() bool {
- for _, file := range gui.State.FileTreeViewModel.GetAllFiles() {
- if file.HasUnstagedChanges {
- return false
- }
- }
- return true
-}
-
-func (gui *Gui) onFocusFile() error {
- gui.takeOverMergeConflictScrolling()
- return nil
-}
-
-func (gui *Gui) handleStageAll() error {
- var err error
- if gui.allFilesStaged() {
- gui.logAction(gui.Tr.Actions.UnstageAllFiles)
- err = gui.Git.WorkingTree.UnstageAll()
- } else {
- gui.logAction(gui.Tr.Actions.StageAllFiles)
- err = gui.Git.WorkingTree.StageAll()
- }
- if err != nil {
- _ = gui.surfaceError(err)
- }
-
- if err := gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}}); err != nil {
- return err
- }
-
- return gui.State.Contexts.Files.HandleFocus()
-}
-
-func (gui *Gui) handleIgnoreFile() error {
- node := gui.getSelectedFileNode()
- if node == nil {
- return nil
- }
-
- if node.GetPath() == ".gitignore" {
- return gui.createErrorPanel("Cannot ignore .gitignore")
- }
-
- unstageFiles := func() error {
- return node.ForEachFile(func(file *models.File) error {
- if file.HasStagedChanges {
- if err := gui.Git.WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
- return err
- }
- }
-
- return nil
- })
- }
-
- if node.GetIsTracked() {
- return gui.ask(askOpts{
- title: gui.Tr.IgnoreTracked,
- prompt: gui.Tr.IgnoreTrackedPrompt,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.IgnoreFile)
- // not 100% sure if this is necessary but I'll assume it is
- if err := unstageFiles(); err != nil {
- return err
- }
-
- if err := gui.Git.WorkingTree.RemoveTrackedFiles(node.GetPath()); err != nil {
- return err
- }
-
- if err := gui.Git.WorkingTree.Ignore(node.GetPath()); err != nil {
- return err
- }
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}})
- },
- })
- }
-
- gui.logAction(gui.Tr.Actions.IgnoreFile)
-
- if err := unstageFiles(); err != nil {
- return err
- }
-
- if err := gui.Git.WorkingTree.Ignore(node.GetPath()); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}})
-}
-
-func (gui *Gui) handleWIPCommitPress() error {
- skipHookPrefix := gui.UserConfig.Git.SkipHookPrefix
- if skipHookPrefix == "" {
- return gui.createErrorPanel(gui.Tr.SkipHookPrefixNotConfigured)
- }
-
- textArea := gui.Views.CommitMessage.TextArea
- textArea.Clear()
- textArea.TypeString(skipHookPrefix)
- gui.Views.CommitMessage.RenderTextArea()
-
- return gui.handleCommitPress()
-}
-
-func (gui *Gui) commitPrefixConfigForRepo() *config.CommitPrefixConfig {
- cfg, ok := gui.UserConfig.Git.CommitPrefixes[utils.GetCurrentRepoName()]
- if !ok {
- return nil
- }
-
- return &cfg
-}
-
-func (gui *Gui) prepareFilesForCommit() error {
- noStagedFiles := len(gui.stagedFiles()) == 0
- if noStagedFiles && gui.UserConfig.Gui.SkipNoStagedFilesWarning {
- gui.logAction(gui.Tr.Actions.StageAllFiles)
- err := gui.Git.WorkingTree.StageAll()
+ hasConflicts, err := gui.helpers.MergeConflicts.SetMergeState(node.GetPath())
if err != nil {
return err
}
- return gui.refreshFilesAndSubmodules()
- }
-
- return nil
-}
-
-func (gui *Gui) handleCommitPress() error {
- if err := gui.prepareFilesForCommit(); err != nil {
- return gui.surfaceError(err)
- }
-
- if gui.State.FileTreeViewModel.GetItemsLength() == 0 {
- return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
- }
-
- if len(gui.stagedFiles()) == 0 {
- return gui.promptToStageAllAndRetry(gui.handleCommitPress)
- }
-
- if len(gui.State.failedCommitMessage) > 0 {
- gui.Views.CommitMessage.ClearTextArea()
- gui.Views.CommitMessage.TextArea.TypeString(gui.State.failedCommitMessage)
- gui.Views.CommitMessage.RenderTextArea()
- } else {
- commitPrefixConfig := gui.commitPrefixConfigForRepo()
- if commitPrefixConfig != nil {
- prefixPattern := commitPrefixConfig.Pattern
- prefixReplace := commitPrefixConfig.Replace
- rgx, err := regexp.Compile(prefixPattern)
- if err != nil {
- return gui.createErrorPanel(fmt.Sprintf("%s: %s", gui.Tr.LcCommitPrefixPatternError, err.Error()))
- }
- prefix := rgx.ReplaceAllString(gui.getCheckedOutBranch().Name, prefixReplace)
- gui.Views.CommitMessage.ClearTextArea()
- gui.Views.CommitMessage.TextArea.TypeString(prefix)
- gui.Views.CommitMessage.RenderTextArea()
+ if hasConflicts {
+ return gui.refreshMergePanel(false)
}
}
- if err := gui.pushContext(gui.State.Contexts.CommitMessage); err != nil {
- return err
+ gui.helpers.MergeConflicts.ResetMergeState()
+
+ pair := gui.c.MainViewPairs().Normal
+ if node.File != nil {
+ pair = gui.c.MainViewPairs().Staging
}
- gui.RenderCommitLength()
- return nil
-}
+ split := gui.c.UserConfig.Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
+ mainShowsStaged := !split && node.GetHasStagedChanges()
-func (gui *Gui) promptToStageAllAndRetry(retry func() error) error {
- return gui.ask(askOpts{
- title: gui.Tr.NoFilesStagedTitle,
- prompt: gui.Tr.NoFilesStagedPrompt,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.StageAllFiles)
- if err := gui.Git.WorkingTree.StageAll(); err != nil {
- return gui.surfaceError(err)
- }
- if err := gui.refreshFilesAndSubmodules(); err != nil {
- return gui.surfaceError(err)
- }
-
- return retry()
- },
- })
-}
-
-func (gui *Gui) handleAmendCommitPress() error {
- if gui.State.FileTreeViewModel.GetItemsLength() == 0 {
- return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
+ cmdObj := gui.git.WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, gui.IgnoreWhitespaceInDiffView)
+ title := gui.c.Tr.UnstagedChanges
+ if mainShowsStaged {
+ title = gui.c.Tr.StagedChanges
}
-
- if len(gui.stagedFiles()) == 0 {
- return gui.promptToStageAllAndRetry(gui.handleAmendCommitPress)
- }
-
- if len(gui.State.Commits) == 0 {
- return gui.createErrorPanel(gui.Tr.NoCommitToAmend)
- }
-
- return gui.ask(askOpts{
- title: strings.Title(gui.Tr.AmendLastCommit),
- prompt: gui.Tr.SureToAmend,
- handleConfirm: func() error {
- cmdObj := gui.Git.Commit.AmendHeadCmdObj()
- gui.logAction(gui.Tr.Actions.AmendCommit)
- return gui.withGpgHandling(cmdObj, gui.Tr.AmendingStatus, nil)
- },
- })
-}
-
-// handleCommitEditorPress - handle when the user wants to commit changes via
-// their editor rather than via the popup panel
-func (gui *Gui) handleCommitEditorPress() error {
- if gui.State.FileTreeViewModel.GetItemsLength() == 0 {
- return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
- }
-
- if len(gui.stagedFiles()) == 0 {
- return gui.promptToStageAllAndRetry(gui.handleCommitEditorPress)
- }
-
- gui.logAction(gui.Tr.Actions.Commit)
- return gui.runSubprocessWithSuspenseAndRefresh(
- gui.Git.Commit.CommitEditorCmdObj(),
- )
-}
-
-func (gui *Gui) handleStatusFilterPressed() error {
- menuItems := []*menuItem{
- {
- displayString: gui.Tr.FilterStagedFiles,
- onPress: func() error {
- return gui.setStatusFiltering(filetree.DisplayStaged)
- },
- },
- {
- displayString: gui.Tr.FilterUnstagedFiles,
- onPress: func() error {
- return gui.setStatusFiltering(filetree.DisplayUnstaged)
- },
- },
- {
- displayString: gui.Tr.ResetCommitFilterState,
- onPress: func() error {
- return gui.setStatusFiltering(filetree.DisplayAll)
- },
+ refreshOpts := types.RefreshMainOpts{
+ Pair: pair,
+ Main: &types.ViewUpdateOpts{
+ Task: types.NewRunPtyTask(cmdObj.GetCmd()),
+ Title: title,
},
}
- return gui.createMenu(gui.Tr.FilteringMenuTitle, menuItems, createMenuOptions{showCancel: true})
-}
+ if split {
+ cmdObj := gui.git.WorkingTree.WorktreeFileDiffCmdObj(node, false, true, gui.IgnoreWhitespaceInDiffView)
-func (gui *Gui) setStatusFiltering(filter filetree.FileTreeDisplayFilter) error {
- state := gui.State
- state.FileTreeViewModel.SetFilter(filter)
- return gui.handleRefreshFiles()
-}
-
-func (gui *Gui) editFile(filename string) error {
- return gui.editFileAtLine(filename, 1)
-}
-
-func (gui *Gui) editFileAtLine(filename string, lineNumber int) error {
- cmdStr, err := gui.Git.File.GetEditCmdStr(filename, lineNumber)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- gui.logAction(gui.Tr.Actions.EditFile)
- return gui.runSubprocessWithSuspenseAndRefresh(
- gui.OSCommand.Cmd.NewShell(cmdStr),
- )
-}
-
-func (gui *Gui) handleFileEdit() error {
- node := gui.getSelectedFileNode()
- if node == nil {
- return nil
- }
-
- if node.File == nil {
- return gui.createErrorPanel(gui.Tr.ErrCannotEditDirectory)
- }
-
- return gui.editFile(node.GetPath())
-}
-
-func (gui *Gui) handleFileOpen() error {
- node := gui.getSelectedFileNode()
- if node == nil {
- return nil
- }
-
- return gui.openFile(node.GetPath())
-}
-
-func (gui *Gui) handleRefreshFiles() error {
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}})
-}
-
-func (gui *Gui) refreshStateFiles() error {
- state := gui.State
-
- // keep track of where the cursor is currently and the current file names
- // when we refresh, go looking for a matching name
- // move the cursor to there.
-
- selectedNode := gui.getSelectedFileNode()
-
- prevNodes := gui.State.FileTreeViewModel.GetAllItems()
- prevSelectedLineIdx := gui.State.Panels.Files.SelectedLineIdx
-
- // If git thinks any of our files have inline merge conflicts, but they actually don't,
- // we stage them.
- // Note that if files with merge conflicts have both arisen and have been resolved
- // between refreshes, we won't stage them here. This is super unlikely though,
- // and this approach spares us from having to call `git status` twice in a row.
- // Although this also means that at startup we won't be staging anything until
- // we call git status again.
- pathsToStage := []string{}
- prevConflictFileCount := 0
- for _, file := range state.FileTreeViewModel.GetAllFiles() {
- if file.HasMergeConflicts {
- prevConflictFileCount++
+ title := gui.c.Tr.StagedChanges
+ if mainShowsStaged {
+ title = gui.c.Tr.UnstagedChanges
}
- if file.HasInlineMergeConflicts {
- hasConflicts, err := mergeconflicts.FileHasConflictMarkers(file.Name)
- if err != nil {
- gui.Log.Error(err)
- } else if !hasConflicts {
- pathsToStage = append(pathsToStage, file.Name)
- }
+
+ refreshOpts.Secondary = &types.ViewUpdateOpts{
+ Title: title,
+ Task: types.NewRunPtyTask(cmdObj.GetCmd()),
}
}
- if len(pathsToStage) > 0 {
- gui.logAction(gui.Tr.Actions.StageResolvedFiles)
- if err := gui.Git.WorkingTree.StageFiles(pathsToStage); err != nil {
- return gui.surfaceError(err)
- }
- }
-
- files := gui.Git.Loaders.Files.
- GetStatusFiles(loaders.GetStatusFileOptions{})
-
- conflictFileCount := 0
- for _, file := range files {
- if file.HasMergeConflicts {
- conflictFileCount++
- }
- }
-
- if gui.Git.Status.WorkingTreeState() != enums.REBASE_MODE_NONE && conflictFileCount == 0 && prevConflictFileCount > 0 {
- gui.OnUIThread(func() error { return gui.promptToContinueRebase() })
- }
-
- // for when you stage the old file of a rename and the new file is in a collapsed dir
- state.FileTreeViewModel.RWMutex.Lock()
- for _, file := range files {
- if selectedNode != nil && selectedNode.Path != "" && file.PreviousName == selectedNode.Path {
- state.FileTreeViewModel.ExpandToPath(file.Name)
- }
- }
-
- // only taking over the filter if it hasn't already been set by the user.
- // Though this does make it impossible for the user to actually say they want to display all if
- // conflicts are currently being shown. Hmm. Worth it I reckon. If we need to add some
- // extra state here to see if the user's set the filter themselves we can do that, but
- // I'd prefer to maintain as little state as possible.
- if conflictFileCount > 0 {
- if state.FileTreeViewModel.GetFilter() == filetree.DisplayAll {
- state.FileTreeViewModel.SetFilter(filetree.DisplayConflicted)
- }
- } else if state.FileTreeViewModel.GetFilter() == filetree.DisplayConflicted {
- state.FileTreeViewModel.SetFilter(filetree.DisplayAll)
- }
-
- state.FileTreeViewModel.SetFiles(files)
- state.FileTreeViewModel.RWMutex.Unlock()
-
- if err := gui.fileWatcher.addFilesToFileWatcher(files); err != nil {
- return err
- }
-
- if selectedNode != nil {
- newIdx := gui.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], state.FileTreeViewModel.GetAllItems())
- if newIdx != -1 && newIdx != prevSelectedLineIdx {
- newNode := state.FileTreeViewModel.GetItemAtIndex(newIdx)
- // when not in tree mode, we show merge conflict files at the top, so you
- // can work through them one by one without having to sift through a large
- // set of files. If you have just fixed the merge conflicts of a file, we
- // actually don't want to jump to that file's new position, because that
- // file will now be ages away amidst the other files without merge
- // conflicts: the user in this case would rather work on the next file
- // with merge conflicts, which will have moved up to fill the gap left by
- // the last file, meaning the cursor doesn't need to move at all.
- leaveCursor := !state.FileTreeViewModel.InTreeMode() && newNode != nil &&
- selectedNode.File != nil && selectedNode.File.HasMergeConflicts &&
- newNode.File != nil && !newNode.File.HasMergeConflicts
-
- if !leaveCursor {
- state.Panels.Files.SelectedLineIdx = newIdx
- }
- }
- }
-
- gui.refreshSelectedLine(state.Panels.Files, state.FileTreeViewModel.GetItemsLength())
- return nil
+ return gui.c.RenderToMainViews(refreshOpts)
}
-// promptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress
-func (gui *Gui) promptToContinueRebase() error {
- gui.takeOverMergeConflictScrolling()
-
- return gui.ask(askOpts{
- title: "continue",
- prompt: gui.Tr.ConflictsResolved,
- handleConfirm: func() error {
- return gui.genericMergeCommand(REBASE_OPTION_CONTINUE)
- },
- })
-}
-
-// Let's try to find our file again and move the cursor to that.
-// If we can't find our file, it was probably just removed by the user. In that
-// case, we go looking for where the next file has been moved to. Given that the
-// user could have removed a whole directory, we continue iterating through the old
-// nodes until we find one that exists in the new set of nodes, then move the cursor
-// to that.
-// prevNodes starts from our previously selected node because we don't need to consider anything above that
-func (gui *Gui) findNewSelectedIdx(prevNodes []*filetree.FileNode, currNodes []*filetree.FileNode) int {
- getPaths := func(node *filetree.FileNode) []string {
- if node == nil {
- return nil
- }
- if node.File != nil && node.File.IsRename() {
- return node.File.Names()
- } else {
- return []string{node.Path}
- }
- }
-
- for _, prevNode := range prevNodes {
- selectedPaths := getPaths(prevNode)
-
- for idx, node := range currNodes {
- paths := getPaths(node)
-
- // If you started off with a rename selected, and now it's broken in two, we want you to jump to the new file, not the old file.
- // This is because the new should be in the same position as the rename was meaning less cursor jumping
- foundOldFileInRename := prevNode.File != nil && prevNode.File.IsRename() && node.Path == prevNode.File.PreviousName
- foundNode := utils.StringArraysOverlap(paths, selectedPaths) && !foundOldFileInRename
- if foundNode {
- return idx
- }
- }
- }
-
- return -1
-}
-
-func (gui *Gui) handlePullFiles() error {
- if gui.popupPanelFocused() {
- return nil
- }
-
- action := gui.Tr.Actions.Pull
-
- currentBranch := gui.currentBranch()
- if currentBranch == nil {
- // need to wait for branches to refresh
- return nil
- }
-
- // if we have no upstream branch we need to set that first
- if !currentBranch.IsTrackingRemote() {
- suggestedRemote := getSuggestedRemote(gui.State.Remotes)
-
- return gui.prompt(promptOpts{
- title: gui.Tr.EnterUpstream,
- initialContent: suggestedRemote + " " + currentBranch.Name,
- findSuggestionsFunc: gui.getRemoteBranchesSuggestionsFunc(" "),
- handleConfirm: func(upstream string) error {
- var upstreamBranch, upstreamRemote string
- split := strings.Split(upstream, " ")
- if len(split) != 2 {
- return gui.createErrorPanel(gui.Tr.InvalidUpstream)
- }
-
- upstreamRemote = split[0]
- upstreamBranch = split[1]
-
- if err := gui.Git.Branch.SetCurrentBranchUpstream(upstreamRemote, upstreamBranch); err != nil {
- errorMessage := err.Error()
- if strings.Contains(errorMessage, "does not exist") {
- errorMessage = fmt.Sprintf("upstream branch %s not found.\nIf you expect it to exist, you should fetch (with 'f').\nOtherwise, you should push (with 'shift+P')", upstream)
- }
- return gui.createErrorPanel(errorMessage)
- }
- return gui.pullFiles(PullFilesOptions{UpstreamRemote: upstreamRemote, UpstreamBranch: upstreamBranch, action: action})
- },
- })
- }
-
- return gui.pullFiles(PullFilesOptions{UpstreamRemote: currentBranch.UpstreamRemote, UpstreamBranch: currentBranch.UpstreamBranch, action: action})
-}
-
-type PullFilesOptions struct {
- UpstreamRemote string
- UpstreamBranch string
- FastForwardOnly bool
- action string
-}
-
-func (gui *Gui) pullFiles(opts PullFilesOptions) error {
- if err := gui.createLoaderPanel(gui.Tr.PullWait); err != nil {
- return err
- }
-
- // TODO: this doesn't look like a good idea. Why the goroutine?
- go utils.Safe(func() { _ = gui.pullWithLock(opts) })
-
- return nil
-}
-
-func (gui *Gui) pullWithLock(opts PullFilesOptions) error {
- gui.Mutexes.FetchMutex.Lock()
- defer gui.Mutexes.FetchMutex.Unlock()
-
- gui.logAction(opts.action)
-
- err := gui.Git.Sync.Pull(
- git_commands.PullOptions{
- RemoteName: opts.UpstreamRemote,
- BranchName: opts.UpstreamBranch,
- FastForwardOnly: opts.FastForwardOnly,
- },
- )
- if err == nil {
- _ = gui.closeConfirmationPrompt(false)
- }
- return gui.handleGenericMergeCommandResult(err)
-}
-
-type pushOpts struct {
- force bool
- upstreamRemote string
- upstreamBranch string
- setUpstream bool
-}
-
-func (gui *Gui) push(opts pushOpts) error {
- if err := gui.createLoaderPanel(gui.Tr.PushWait); err != nil {
- return err
- }
- go utils.Safe(func() {
- gui.logAction(gui.Tr.Actions.Push)
- err := gui.Git.Sync.Push(git_commands.PushOpts{
- Force: opts.force,
- UpstreamRemote: opts.upstreamRemote,
- UpstreamBranch: opts.upstreamBranch,
- SetUpstream: opts.setUpstream,
- })
-
- if err != nil && !opts.force && strings.Contains(err.Error(), "Updates were rejected") {
- forcePushDisabled := gui.UserConfig.Git.DisableForcePushing
- if forcePushDisabled {
- _ = gui.createErrorPanel(gui.Tr.UpdatesRejectedAndForcePushDisabled)
- return
- }
- _ = gui.ask(askOpts{
- title: gui.Tr.ForcePush,
- prompt: gui.Tr.ForcePushPrompt,
- handleConfirm: func() error {
- newOpts := opts
- newOpts.force = true
-
- return gui.push(newOpts)
- },
- })
- return
- }
- gui.handleCredentialsPopup(err)
- _ = gui.refreshSidePanels(refreshOptions{mode: ASYNC})
- })
- return nil
-}
-
-func (gui *Gui) pushFiles() error {
- if gui.popupPanelFocused() {
- return nil
- }
-
- // if we have pullables we'll ask if the user wants to force push
- currentBranch := gui.currentBranch()
- if currentBranch == nil {
- // need to wait for branches to refresh
- return nil
- }
-
- if currentBranch.IsTrackingRemote() {
- opts := pushOpts{
- force: false,
- upstreamRemote: currentBranch.UpstreamRemote,
- upstreamBranch: currentBranch.UpstreamBranch,
- }
- if currentBranch.HasCommitsToPull() {
- opts.force = true
- return gui.requestToForcePush(opts)
- } else {
- return gui.push(opts)
- }
- } else {
- suggestedRemote := getSuggestedRemote(gui.State.Remotes)
-
- if gui.Git.Config.GetPushToCurrent() {
- return gui.push(pushOpts{setUpstream: true})
- } else {
- return gui.prompt(promptOpts{
- title: gui.Tr.EnterUpstream,
- initialContent: suggestedRemote + " " + currentBranch.Name,
- findSuggestionsFunc: gui.getRemoteBranchesSuggestionsFunc(" "),
- handleConfirm: func(upstream string) error {
- var upstreamBranch, upstreamRemote string
- split := strings.Split(upstream, " ")
- if len(split) == 2 {
- upstreamRemote = split[0]
- upstreamBranch = split[1]
- } else {
- upstreamRemote = upstream
- upstreamBranch = ""
- }
-
- return gui.push(pushOpts{
- force: false,
- upstreamRemote: upstreamRemote,
- upstreamBranch: upstreamBranch,
- setUpstream: true,
- })
- },
- })
- }
+func (gui *Gui) getSetTextareaTextFn(getView func() *gocui.View) func(string) {
+ return func(text string) {
+ // using a getView function so that we don't need to worry about when the view is created
+ view := getView()
+ view.ClearTextArea()
+ view.TextArea.TypeString(text)
+ view.RenderTextArea()
}
}
-
-func getSuggestedRemote(remotes []*models.Remote) string {
- if len(remotes) == 0 {
- return "origin"
- }
-
- for _, remote := range remotes {
- if remote.Name == "origin" {
- return remote.Name
- }
- }
-
- return remotes[0].Name
-}
-
-func (gui *Gui) requestToForcePush(opts pushOpts) error {
- forcePushDisabled := gui.UserConfig.Git.DisableForcePushing
- if forcePushDisabled {
- return gui.createErrorPanel(gui.Tr.ForcePushDisabled)
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.ForcePush,
- prompt: gui.Tr.ForcePushPrompt,
- handleConfirm: func() error {
- return gui.push(opts)
- },
- })
-}
-
-func (gui *Gui) switchToMerge() error {
- file := gui.getSelectedFile()
- if file == nil {
- return nil
- }
-
- gui.takeOverMergeConflictScrolling()
-
- if gui.State.Panels.Merging.GetPath() != file.Name {
- hasConflicts, err := gui.setMergeStateWithLock(file.Name)
- if err != nil {
- return err
- }
- if !hasConflicts {
- return nil
- }
- }
-
- return gui.pushContext(gui.State.Contexts.Merging)
-}
-
-func (gui *Gui) openFile(filename string) error {
- gui.logAction(gui.Tr.Actions.OpenFile)
- if err := gui.OSCommand.OpenFile(filename); err != nil {
- return gui.surfaceError(err)
- }
- return nil
-}
-
-func (gui *Gui) handleCustomCommand() error {
- return gui.prompt(promptOpts{
- title: gui.Tr.CustomCommand,
- findSuggestionsFunc: gui.getCustomCommandsHistorySuggestionsFunc(),
- handleConfirm: func(command string) error {
- gui.Config.GetAppState().CustomCommandsHistory = utils.Limit(
- utils.Uniq(
- append(gui.Config.GetAppState().CustomCommandsHistory, command),
- ),
- 1000,
- )
-
- err := gui.Config.SaveAppState()
- if err != nil {
- gui.Log.Error(err)
- }
-
- gui.logAction(gui.Tr.Actions.CustomCommand)
- return gui.runSubprocessWithSuspenseAndRefresh(
- gui.OSCommand.Cmd.NewShell(command),
- )
- },
- })
-}
-
-func (gui *Gui) handleCreateStashMenu() error {
- menuItems := []*menuItem{
- {
- displayString: gui.Tr.LcStashAllChanges,
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.StashAllChanges)
- return gui.handleStashSave(gui.Git.Stash.Save)
- },
- },
- {
- displayString: gui.Tr.LcStashStagedChanges,
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.StashStagedChanges)
- return gui.handleStashSave(gui.Git.Stash.SaveStagedChanges)
- },
- },
- }
-
- return gui.createMenu(gui.Tr.LcStashOptions, menuItems, createMenuOptions{showCancel: true})
-}
-
-func (gui *Gui) handleStashChanges() error {
- return gui.handleStashSave(gui.Git.Stash.Save)
-}
-
-func (gui *Gui) handleCreateResetToUpstreamMenu() error {
- return gui.createResetMenu("@{upstream}")
-}
-
-func (gui *Gui) handleToggleDirCollapsed() error {
- node := gui.getSelectedFileNode()
- if node == nil {
- return nil
- }
-
- gui.State.FileTreeViewModel.ToggleCollapsed(node.GetPath())
-
- if err := gui.postRefreshUpdate(gui.State.Contexts.Files); err != nil {
- gui.Log.Error(err)
- }
-
- return nil
-}
-
-func (gui *Gui) handleToggleFileTreeView() error {
- // get path of currently selected file
- path := gui.getSelectedPath()
-
- gui.State.FileTreeViewModel.ToggleShowTree()
-
- // find that same node in the new format and move the cursor to it
- if path != "" {
- gui.State.FileTreeViewModel.ExpandToPath(path)
- index, found := gui.State.FileTreeViewModel.GetIndexForPath(path)
- if found {
- gui.filesListContext().GetPanelState().SetSelectedLineIdx(index)
- }
- }
-
- if ContextKey(gui.Views.Files.Context) == FILES_CONTEXT_KEY {
- if err := gui.State.Contexts.Files.HandleRender(); err != nil {
- return err
- }
- if err := gui.State.Contexts.Files.HandleFocus(); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func (gui *Gui) handleOpenMergeTool() error {
- return gui.ask(askOpts{
- title: gui.Tr.MergeToolTitle,
- prompt: gui.Tr.MergeToolPrompt,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.OpenMergeTool)
- return gui.runSubprocessWithSuspenseAndRefresh(
- gui.Git.WorkingTree.OpenMergeToolCmdObj(),
- )
- },
- })
-}
diff --git a/pkg/gui/files_panel_test.go b/pkg/gui/files_panel_test.go
deleted file mode 100644
index 8946898e5..000000000
--- a/pkg/gui/files_panel_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package gui
-
-import (
- "testing"
-
- "github.com/jesseduffield/lazygit/pkg/commands/models"
- "github.com/stretchr/testify/assert"
-)
-
-func TestGetSuggestedRemote(t *testing.T) {
- cases := []struct {
- remotes []*models.Remote
- expected string
- }{
- {mkRemoteList(), "origin"},
- {mkRemoteList("upstream", "origin", "foo"), "origin"},
- {mkRemoteList("upstream", "foo", "bar"), "upstream"},
- }
-
- for _, c := range cases {
- result := getSuggestedRemote(c.remotes)
- assert.EqualValues(t, c.expected, result)
- }
-}
-
-func mkRemoteList(names ...string) []*models.Remote {
- result := make([]*models.Remote, 0, len(names))
-
- for _, name := range names {
- result = append(result, &models.Remote{Name: name})
- }
-
- return result
-}
diff --git a/pkg/gui/filetree/build_tree.go b/pkg/gui/filetree/build_tree.go
index 36034d02d..c7c465e28 100644
--- a/pkg/gui/filetree/build_tree.go
+++ b/pkg/gui/filetree/build_tree.go
@@ -7,10 +7,10 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
)
-func BuildTreeFromFiles(files []*models.File) *FileNode {
- root := &FileNode{}
+func BuildTreeFromFiles(files []*models.File) *Node[models.File] {
+ root := &Node[models.File]{}
- var curr *FileNode
+ var curr *Node[models.File]
for _, file := range files {
splitPath := split(file.Name)
curr = root
@@ -30,7 +30,7 @@ func BuildTreeFromFiles(files []*models.File) *FileNode {
}
}
- newChild := &FileNode{
+ newChild := &Node[models.File]{
Path: path,
File: setFile,
}
@@ -46,17 +46,17 @@ func BuildTreeFromFiles(files []*models.File) *FileNode {
return root
}
-func BuildFlatTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode {
+func BuildFlatTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFile] {
rootAux := BuildTreeFromCommitFiles(files)
sortedFiles := rootAux.GetLeaves()
- return &CommitFileNode{Children: sortedFiles}
+ return &Node[models.CommitFile]{Children: sortedFiles}
}
-func BuildTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode {
- root := &CommitFileNode{}
+func BuildTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFile] {
+ root := &Node[models.CommitFile]{}
- var curr *CommitFileNode
+ var curr *Node[models.CommitFile]
for _, file := range files {
splitPath := split(file.Name)
curr = root
@@ -77,7 +77,7 @@ func BuildTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode {
}
}
- newChild := &CommitFileNode{
+ newChild := &Node[models.CommitFile]{
Path: path,
File: setFile,
}
@@ -93,7 +93,7 @@ func BuildTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode {
return root
}
-func BuildFlatTreeFromFiles(files []*models.File) *FileNode {
+func BuildFlatTreeFromFiles(files []*models.File) *Node[models.File] {
rootAux := BuildTreeFromFiles(files)
sortedFiles := rootAux.GetLeaves()
@@ -128,7 +128,7 @@ func BuildFlatTreeFromFiles(files []*models.File) *FileNode {
return false
})
- return &FileNode{Children: sortedFiles}
+ return &Node[models.File]{Children: sortedFiles}
}
func split(str string) []string {
diff --git a/pkg/gui/filetree/build_tree_test.go b/pkg/gui/filetree/build_tree_test.go
index c486ddfa5..ac36be9af 100644
--- a/pkg/gui/filetree/build_tree_test.go
+++ b/pkg/gui/filetree/build_tree_test.go
@@ -11,14 +11,14 @@ func TestBuildTreeFromFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.File
- expected *FileNode
+ expected *Node[models.File]
}{
{
name: "no files",
files: []*models.File{},
- expected: &FileNode{
+ expected: &Node[models.File]{
Path: "",
- Children: []*FileNode{},
+ Children: nil,
},
},
{
@@ -31,12 +31,12 @@ func TestBuildTreeFromFiles(t *testing.T) {
Name: "dir1/b",
},
},
- expected: &FileNode{
+ expected: &Node[models.File]{
Path: "",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
Path: "dir1",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "dir1/a"},
Path: "dir1/a",
@@ -60,12 +60,12 @@ func TestBuildTreeFromFiles(t *testing.T) {
Name: "dir2/dir4/b",
},
},
- expected: &FileNode{
+ expected: &Node[models.File]{
Path: "",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
Path: "dir1/dir3",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "dir1/dir3/a"},
Path: "dir1/dir3/a",
@@ -75,7 +75,7 @@ func TestBuildTreeFromFiles(t *testing.T) {
},
{
Path: "dir2/dir4",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "dir2/dir4/b"},
Path: "dir2/dir4/b",
@@ -96,9 +96,9 @@ func TestBuildTreeFromFiles(t *testing.T) {
Name: "a",
},
},
- expected: &FileNode{
+ expected: &Node[models.File]{
Path: "",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "a"},
Path: "a",
@@ -124,11 +124,11 @@ func TestBuildTreeFromFiles(t *testing.T) {
Name: "a",
},
},
- expected: &FileNode{
+ expected: &Node[models.File]{
Path: "",
// it is a little strange that we're not bubbling up our merge conflict
// here but we are technically still in in tree mode and that's the rule
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "a"},
Path: "a",
@@ -159,14 +159,14 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.File
- expected *FileNode
+ expected *Node[models.File]
}{
{
name: "no files",
files: []*models.File{},
- expected: &FileNode{
+ expected: &Node[models.File]{
Path: "",
- Children: []*FileNode{},
+ Children: []*Node[models.File]{},
},
},
{
@@ -179,9 +179,9 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
Name: "dir1/b",
},
},
- expected: &FileNode{
+ expected: &Node[models.File]{
Path: "",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "dir1/a"},
Path: "dir1/a",
@@ -205,9 +205,9 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
Name: "dir2/b",
},
},
- expected: &FileNode{
+ expected: &Node[models.File]{
Path: "",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "dir1/a"},
Path: "dir1/a",
@@ -231,9 +231,9 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
Name: "a",
},
},
- expected: &FileNode{
+ expected: &Node[models.File]{
Path: "",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "a"},
Path: "a",
@@ -273,9 +273,9 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
Tracked: true,
},
},
- expected: &FileNode{
+ expected: &Node[models.File]{
Path: "",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "c1", HasMergeConflicts: true},
Path: "c1",
@@ -318,14 +318,14 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.CommitFile
- expected *CommitFileNode
+ expected *Node[models.CommitFile]
}{
{
name: "no files",
files: []*models.CommitFile{},
- expected: &CommitFileNode{
+ expected: &Node[models.CommitFile]{
Path: "",
- Children: []*CommitFileNode{},
+ Children: nil,
},
},
{
@@ -338,12 +338,12 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
Name: "dir1/b",
},
},
- expected: &CommitFileNode{
+ expected: &Node[models.CommitFile]{
Path: "",
- Children: []*CommitFileNode{
+ Children: []*Node[models.CommitFile]{
{
Path: "dir1",
- Children: []*CommitFileNode{
+ Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Name: "dir1/a"},
Path: "dir1/a",
@@ -367,12 +367,12 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
Name: "dir2/dir4/b",
},
},
- expected: &CommitFileNode{
+ expected: &Node[models.CommitFile]{
Path: "",
- Children: []*CommitFileNode{
+ Children: []*Node[models.CommitFile]{
{
Path: "dir1/dir3",
- Children: []*CommitFileNode{
+ Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Name: "dir1/dir3/a"},
Path: "dir1/dir3/a",
@@ -382,7 +382,7 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
},
{
Path: "dir2/dir4",
- Children: []*CommitFileNode{
+ Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Name: "dir2/dir4/b"},
Path: "dir2/dir4/b",
@@ -403,9 +403,9 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
Name: "a",
},
},
- expected: &CommitFileNode{
+ expected: &Node[models.CommitFile]{
Path: "",
- Children: []*CommitFileNode{
+ Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Name: "a"},
Path: "a",
@@ -432,14 +432,14 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.CommitFile
- expected *CommitFileNode
+ expected *Node[models.CommitFile]
}{
{
name: "no files",
files: []*models.CommitFile{},
- expected: &CommitFileNode{
+ expected: &Node[models.CommitFile]{
Path: "",
- Children: []*CommitFileNode{},
+ Children: []*Node[models.CommitFile]{},
},
},
{
@@ -452,9 +452,9 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
Name: "dir1/b",
},
},
- expected: &CommitFileNode{
+ expected: &Node[models.CommitFile]{
Path: "",
- Children: []*CommitFileNode{
+ Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Name: "dir1/a"},
Path: "dir1/a",
@@ -478,9 +478,9 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
Name: "dir2/b",
},
},
- expected: &CommitFileNode{
+ expected: &Node[models.CommitFile]{
Path: "",
- Children: []*CommitFileNode{
+ Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Name: "dir1/a"},
Path: "dir1/a",
@@ -504,9 +504,9 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
Name: "a",
},
},
- expected: &CommitFileNode{
+ expected: &Node[models.CommitFile]{
Path: "",
- Children: []*CommitFileNode{
+ Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Name: "a"},
Path: "a",
diff --git a/pkg/gui/filetree/collapsed_paths.go b/pkg/gui/filetree/collapsed_paths.go
index 02c0b4303..903999b37 100644
--- a/pkg/gui/filetree/collapsed_paths.go
+++ b/pkg/gui/filetree/collapsed_paths.go
@@ -1,20 +1,38 @@
package filetree
-type CollapsedPaths map[string]bool
+import "github.com/jesseduffield/generics/set"
-func (cp CollapsedPaths) ExpandToPath(path string) {
+type CollapsedPaths struct {
+ collapsedPaths *set.Set[string]
+}
+
+func NewCollapsedPaths() *CollapsedPaths {
+ return &CollapsedPaths{
+ collapsedPaths: set.New[string](),
+ }
+}
+
+func (self *CollapsedPaths) ExpandToPath(path string) {
// need every directory along the way
splitPath := split(path)
for i := range splitPath {
dir := join(splitPath[0 : i+1])
- cp[dir] = false
+ self.collapsedPaths.Remove(dir)
}
}
-func (cp CollapsedPaths) IsCollapsed(path string) bool {
- return cp[path]
+func (self *CollapsedPaths) IsCollapsed(path string) bool {
+ return self.collapsedPaths.Includes(path)
}
-func (cp CollapsedPaths) ToggleCollapsed(path string) {
- cp[path] = !cp[path]
+func (self *CollapsedPaths) Collapse(path string) {
+ self.collapsedPaths.Add(path)
+}
+
+func (self *CollapsedPaths) ToggleCollapsed(path string) {
+ if self.collapsedPaths.Includes(path) {
+ self.collapsedPaths.Remove(path)
+ } else {
+ self.collapsedPaths.Add(path)
+ }
}
diff --git a/pkg/gui/filetree/commit_file_node.go b/pkg/gui/filetree/commit_file_node.go
index 14960ee30..be9868daa 100644
--- a/pkg/gui/filetree/commit_file_node.go
+++ b/pkg/gui/filetree/commit_file_node.go
@@ -1,171 +1,25 @@
package filetree
-import (
- "github.com/jesseduffield/lazygit/pkg/commands/models"
-)
+import "github.com/jesseduffield/lazygit/pkg/commands/models"
+// CommitFileNode wraps a node and provides some commit-file-specific methods for it.
type CommitFileNode struct {
- Children []*CommitFileNode
- File *models.CommitFile
- Path string // e.g. '/path/to/mydir'
- CompressionLevel int // equal to the number of forward slashes you'll see in the path when it's rendered in tree mode
+ *Node[models.CommitFile]
}
-var _ INode = &CommitFileNode{}
-
-// methods satisfying ListItem interface
-
-func (s *CommitFileNode) ID() string {
- return s.GetPath()
-}
-
-func (s *CommitFileNode) Description() string {
- return s.GetPath()
-}
-
-// methods satisfying INode interface
-
-func (s *CommitFileNode) IsNil() bool {
- return s == nil
-}
-
-func (s *CommitFileNode) IsLeaf() bool {
- return s.File != nil
-}
-
-func (s *CommitFileNode) GetPath() string {
- return s.Path
-}
-
-func (s *CommitFileNode) GetChildren() []INode {
- result := make([]INode, len(s.Children))
- for i, child := range s.Children {
- result[i] = child
- }
-
- return result
-}
-
-func (s *CommitFileNode) SetChildren(children []INode) {
- castChildren := make([]*CommitFileNode, len(children))
- for i, child := range children {
- castChildren[i] = child.(*CommitFileNode)
- }
-
- s.Children = castChildren
-}
-
-func (s *CommitFileNode) GetCompressionLevel() int {
- return s.CompressionLevel
-}
-
-func (s *CommitFileNode) SetCompressionLevel(level int) {
- s.CompressionLevel = level
-}
-
-// methods utilising generic functions for INodes
-
-func (s *CommitFileNode) Sort() {
- sortNode(s)
-}
-
-func (s *CommitFileNode) ForEachFile(cb func(*models.CommitFile) error) error {
- return forEachLeaf(s, func(n INode) error {
- castNode := n.(*CommitFileNode)
- return cb(castNode.File)
- })
-}
-
-func (s *CommitFileNode) Any(test func(node *CommitFileNode) bool) bool {
- return any(s, func(n INode) bool {
- castNode := n.(*CommitFileNode)
- return test(castNode)
- })
-}
-
-func (s *CommitFileNode) Every(test func(node *CommitFileNode) bool) bool {
- return every(s, func(n INode) bool {
- castNode := n.(*CommitFileNode)
- return test(castNode)
- })
-}
-
-func (s *CommitFileNode) EveryFile(test func(file *models.CommitFile) bool) bool {
- return every(s, func(n INode) bool {
- castNode := n.(*CommitFileNode)
-
- return castNode.File == nil || test(castNode.File)
- })
-}
-
-func (n *CommitFileNode) Flatten(collapsedPaths map[string]bool) []*CommitFileNode {
- results := flatten(n, collapsedPaths)
- nodes := make([]*CommitFileNode, len(results))
- for i, result := range results {
- nodes[i] = result.(*CommitFileNode)
- }
-
- return nodes
-}
-
-func (node *CommitFileNode) GetNodeAtIndex(index int, collapsedPaths map[string]bool) *CommitFileNode {
+func NewCommitFileNode(node *Node[models.CommitFile]) *CommitFileNode {
if node == nil {
return nil
}
- result := getNodeAtIndex(node, index, collapsedPaths)
- if result == nil {
- // not sure how this can be nil: we probably are missing a mutex somewhere
+ return &CommitFileNode{Node: node}
+}
+
+// returns the underlying node, without any commit-file-specific methods attached
+func (self *CommitFileNode) Raw() *Node[models.CommitFile] {
+ if self == nil {
return nil
}
- return result.(*CommitFileNode)
-}
-
-func (node *CommitFileNode) GetIndexForPath(path string, collapsedPaths map[string]bool) (int, bool) {
- return getIndexForPath(node, path, collapsedPaths)
-}
-
-func (node *CommitFileNode) Size(collapsedPaths map[string]bool) int {
- if node == nil {
- return 0
- }
-
- return size(node, collapsedPaths)
-}
-
-func (s *CommitFileNode) Compress() {
- // with these functions I try to only have type conversion code on the actual struct,
- // but comparing interface values to nil is fraught with danger so I'm duplicating
- // that code here.
- if s == nil {
- return
- }
-
- compressAux(s)
-}
-
-func (s *CommitFileNode) GetLeaves() []*CommitFileNode {
- leaves := getLeaves(s)
- castLeaves := make([]*CommitFileNode, len(leaves))
- for i := range leaves {
- castLeaves[i] = leaves[i].(*CommitFileNode)
- }
-
- return castLeaves
-}
-
-// extra methods
-
-func (s *CommitFileNode) AnyFile(test func(file *models.CommitFile) bool) bool {
- return s.Any(func(node *CommitFileNode) bool {
- return node.IsLeaf() && test(node.File)
- })
-}
-
-func (s *CommitFileNode) NameAtDepth(depth int) string {
- splitName := split(s.Path)
- name := join(splitName[depth:])
-
- return name
+ return self.Node
}
diff --git a/pkg/gui/filetree/commit_file_tree.go b/pkg/gui/filetree/commit_file_tree.go
new file mode 100644
index 000000000..862db26f1
--- /dev/null
+++ b/pkg/gui/filetree/commit_file_tree.go
@@ -0,0 +1,112 @@
+package filetree
+
+import (
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/sirupsen/logrus"
+)
+
+type ICommitFileTree interface {
+ ITree[models.CommitFile]
+
+ Get(index int) *CommitFileNode
+ GetFile(path string) *models.CommitFile
+ GetAllItems() []*CommitFileNode
+ GetAllFiles() []*models.CommitFile
+ GetRoot() *CommitFileNode
+}
+
+type CommitFileTree struct {
+ getFiles func() []*models.CommitFile
+ tree *Node[models.CommitFile]
+ showTree bool
+ log *logrus.Entry
+ collapsedPaths *CollapsedPaths
+}
+
+var _ ICommitFileTree = &CommitFileTree{}
+
+func NewCommitFileTree(getFiles func() []*models.CommitFile, log *logrus.Entry, showTree bool) *CommitFileTree {
+ return &CommitFileTree{
+ getFiles: getFiles,
+ log: log,
+ showTree: showTree,
+ collapsedPaths: NewCollapsedPaths(),
+ }
+}
+
+func (self *CommitFileTree) ExpandToPath(path string) {
+ self.collapsedPaths.ExpandToPath(path)
+}
+
+func (self *CommitFileTree) ToggleShowTree() {
+ self.showTree = !self.showTree
+ self.SetTree()
+}
+
+func (self *CommitFileTree) Get(index int) *CommitFileNode {
+ // need to traverse the three depth first until we get to the index.
+ return NewCommitFileNode(self.tree.GetNodeAtIndex(index+1, self.collapsedPaths)) // ignoring root
+}
+
+func (self *CommitFileTree) GetIndexForPath(path string) (int, bool) {
+ index, found := self.tree.GetIndexForPath(path, self.collapsedPaths)
+ return index - 1, found
+}
+
+func (self *CommitFileTree) GetAllItems() []*CommitFileNode {
+ if self.tree == nil {
+ return nil
+ }
+
+ // ignoring root
+ return slices.Map(self.tree.Flatten(self.collapsedPaths)[1:], func(node *Node[models.CommitFile]) *CommitFileNode {
+ return NewCommitFileNode(node)
+ })
+}
+
+func (self *CommitFileTree) Len() int {
+ return self.tree.Size(self.collapsedPaths) - 1 // ignoring root
+}
+
+func (self *CommitFileTree) GetAllFiles() []*models.CommitFile {
+ return self.getFiles()
+}
+
+func (self *CommitFileTree) SetTree() {
+ if self.showTree {
+ self.tree = BuildTreeFromCommitFiles(self.getFiles())
+ } else {
+ self.tree = BuildFlatTreeFromCommitFiles(self.getFiles())
+ }
+}
+
+func (self *CommitFileTree) IsCollapsed(path string) bool {
+ return self.collapsedPaths.IsCollapsed(path)
+}
+
+func (self *CommitFileTree) ToggleCollapsed(path string) {
+ self.collapsedPaths.ToggleCollapsed(path)
+}
+
+func (self *CommitFileTree) GetRoot() *CommitFileNode {
+ return NewCommitFileNode(self.tree)
+}
+
+func (self *CommitFileTree) CollapsedPaths() *CollapsedPaths {
+ return self.collapsedPaths
+}
+
+func (self *CommitFileTree) GetFile(path string) *models.CommitFile {
+ for _, file := range self.getFiles() {
+ if file.Name == path {
+ return file
+ }
+ }
+
+ return nil
+}
+
+func (self *CommitFileTree) InTreeMode() bool {
+ return self.showTree
+}
diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go
index 301396462..a022bc25e 100644
--- a/pkg/gui/filetree/commit_file_tree_view_model.go
+++ b/pkg/gui/filetree/commit_file_tree_view_model.go
@@ -1,101 +1,111 @@
package filetree
import (
+ "sync"
+
"github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context/traits"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/sirupsen/logrus"
)
+type ICommitFileTreeViewModel interface {
+ ICommitFileTree
+ types.IListCursor
+
+ GetRef() types.Ref
+ SetRef(types.Ref)
+ GetCanRebase() bool
+ SetCanRebase(bool)
+}
+
type CommitFileTreeViewModel struct {
- files []*models.CommitFile
- tree *CommitFileNode
- showTree bool
- log *logrus.Entry
- collapsedPaths CollapsedPaths
- // parent is the identifier of the parent object e.g. a commit SHA if this commit file is for a commit, or a stash entry ref like 'stash@{1}'
- parent string
+ sync.RWMutex
+ ICommitFileTree
+ types.IListCursor
+
+ // this is e.g. the commit for which we're viewing the files
+ ref types.Ref
+
+ // we set this to true when you're viewing the files within the checked-out branch's commits.
+ // If you're viewing the files of some random other branch we can't do any rebase stuff.
+ canRebase bool
}
-func (self *CommitFileTreeViewModel) GetParent() string {
- return self.parent
-}
+var _ ICommitFileTreeViewModel = &CommitFileTreeViewModel{}
-func (self *CommitFileTreeViewModel) SetParent(parent string) {
- self.parent = parent
-}
-
-func NewCommitFileTreeViewModel(files []*models.CommitFile, log *logrus.Entry, showTree bool) *CommitFileTreeViewModel {
- viewModel := &CommitFileTreeViewModel{
- log: log,
- showTree: showTree,
- collapsedPaths: CollapsedPaths{},
+func NewCommitFileTreeViewModel(getFiles func() []*models.CommitFile, log *logrus.Entry, showTree bool) *CommitFileTreeViewModel {
+ fileTree := NewCommitFileTree(getFiles, log, showTree)
+ listCursor := traits.NewListCursor(fileTree)
+ return &CommitFileTreeViewModel{
+ ICommitFileTree: fileTree,
+ IListCursor: listCursor,
+ ref: nil,
+ canRebase: false,
}
-
- viewModel.SetFiles(files)
-
- return viewModel
}
-func (self *CommitFileTreeViewModel) ExpandToPath(path string) {
- self.collapsedPaths.ExpandToPath(path)
+func (self *CommitFileTreeViewModel) GetRef() types.Ref {
+ return self.ref
}
-func (self *CommitFileTreeViewModel) ToggleShowTree() {
- self.showTree = !self.showTree
- self.SetTree()
+func (self *CommitFileTreeViewModel) SetRef(ref types.Ref) {
+ self.ref = ref
}
-func (self *CommitFileTreeViewModel) GetItemAtIndex(index int) *CommitFileNode {
- // need to traverse the three depth first until we get to the index.
- return self.tree.GetNodeAtIndex(index+1, self.collapsedPaths) // ignoring root
+func (self *CommitFileTreeViewModel) GetCanRebase() bool {
+ return self.canRebase
}
-func (self *CommitFileTreeViewModel) GetIndexForPath(path string) (int, bool) {
- index, found := self.tree.GetIndexForPath(path, self.collapsedPaths)
- return index - 1, found
+func (self *CommitFileTreeViewModel) SetCanRebase(canRebase bool) {
+ self.canRebase = canRebase
}
-func (self *CommitFileTreeViewModel) GetAllItems() []*CommitFileNode {
- if self.tree == nil {
+func (self *CommitFileTreeViewModel) GetSelected() *CommitFileNode {
+ if self.Len() == 0 {
return nil
}
- return self.tree.Flatten(self.collapsedPaths)[1:] // ignoring root
+ return self.Get(self.GetSelectedLineIdx())
}
-func (self *CommitFileTreeViewModel) GetItemsLength() int {
- return self.tree.Size(self.collapsedPaths) - 1 // ignoring root
+func (self *CommitFileTreeViewModel) GetSelectedFile() *models.CommitFile {
+ node := self.GetSelected()
+ if node == nil {
+ return nil
+ }
+
+ return node.File
}
-func (self *CommitFileTreeViewModel) GetAllFiles() []*models.CommitFile {
- return self.files
+func (self *CommitFileTreeViewModel) GetSelectedPath() string {
+ node := self.GetSelected()
+ if node == nil {
+ return ""
+ }
+
+ return node.GetPath()
}
-func (self *CommitFileTreeViewModel) SetFiles(files []*models.CommitFile) {
- self.files = files
+// duplicated from file_tree_view_model.go. Generics will help here
+func (self *CommitFileTreeViewModel) ToggleShowTree() {
+ selectedNode := self.GetSelected()
- self.SetTree()
-}
+ self.ICommitFileTree.ToggleShowTree()
-func (self *CommitFileTreeViewModel) SetTree() {
- if self.showTree {
- self.tree = BuildTreeFromCommitFiles(self.files)
- } else {
- self.tree = BuildFlatTreeFromCommitFiles(self.files)
+ if selectedNode == nil {
+ return
+ }
+ path := selectedNode.Path
+
+ if self.InTreeMode() {
+ self.ExpandToPath(path)
+ } else if len(selectedNode.Children) > 0 {
+ path = selectedNode.GetLeaves()[0].Path
+ }
+
+ index, found := self.GetIndexForPath(path)
+ if found {
+ self.SetSelectedLineIdx(index)
}
}
-
-func (self *CommitFileTreeViewModel) IsCollapsed(path string) bool {
- return self.collapsedPaths.IsCollapsed(path)
-}
-
-func (self *CommitFileTreeViewModel) ToggleCollapsed(path string) {
- self.collapsedPaths.ToggleCollapsed(path)
-}
-
-func (self *CommitFileTreeViewModel) Tree() INode {
- return self.tree
-}
-
-func (self *CommitFileTreeViewModel) CollapsedPaths() CollapsedPaths {
- return self.collapsedPaths
-}
diff --git a/pkg/gui/filetree/file_node.go b/pkg/gui/filetree/file_node.go
index f332f0a76..2ff707113 100644
--- a/pkg/gui/filetree/file_node.go
+++ b/pkg/gui/filetree/file_node.go
@@ -1,198 +1,51 @@
package filetree
-import (
- "github.com/jesseduffield/lazygit/pkg/commands/models"
-)
+import "github.com/jesseduffield/lazygit/pkg/commands/models"
+// FileNode wraps a node and provides some file-specific methods for it.
type FileNode struct {
- Children []*FileNode
- File *models.File
- Path string // e.g. '/path/to/mydir'
- CompressionLevel int // equal to the number of forward slashes you'll see in the path when it's rendered in tree mode
+ *Node[models.File]
}
-var _ INode = &FileNode{}
+var _ models.IFile = &FileNode{}
-// methods satisfying ListItem interface
-
-func (s *FileNode) ID() string {
- return s.GetPath()
-}
-
-func (s *FileNode) Description() string {
- return s.GetPath()
-}
-
-// methods satisfying INode interface
-
-// interfaces values whose concrete value is nil are not themselves nil
-// hence the existence of this method
-func (s *FileNode) IsNil() bool {
- return s == nil
-}
-
-func (s *FileNode) IsLeaf() bool {
- return s.File != nil
-}
-
-func (s *FileNode) GetPath() string {
- return s.Path
-}
-
-func (s *FileNode) GetChildren() []INode {
- result := make([]INode, len(s.Children))
- for i, child := range s.Children {
- result[i] = child
- }
-
- return result
-}
-
-func (s *FileNode) SetChildren(children []INode) {
- castChildren := make([]*FileNode, len(children))
- for i, child := range children {
- castChildren[i] = child.(*FileNode)
- }
-
- s.Children = castChildren
-}
-
-func (s *FileNode) GetCompressionLevel() int {
- return s.CompressionLevel
-}
-
-func (s *FileNode) SetCompressionLevel(level int) {
- s.CompressionLevel = level
-}
-
-// methods utilising generic functions for INodes
-
-func (s *FileNode) Sort() {
- sortNode(s)
-}
-
-func (s *FileNode) ForEachFile(cb func(*models.File) error) error {
- return forEachLeaf(s, func(n INode) error {
- castNode := n.(*FileNode)
- return cb(castNode.File)
- })
-}
-
-func (s *FileNode) Any(test func(node *FileNode) bool) bool {
- return any(s, func(n INode) bool {
- castNode := n.(*FileNode)
- return test(castNode)
- })
-}
-
-func (n *FileNode) Flatten(collapsedPaths map[string]bool) []*FileNode {
- results := flatten(n, collapsedPaths)
- nodes := make([]*FileNode, len(results))
- for i, result := range results {
- nodes[i] = result.(*FileNode)
- }
-
- return nodes
-}
-
-func (node *FileNode) GetNodeAtIndex(index int, collapsedPaths map[string]bool) *FileNode {
+func NewFileNode(node *Node[models.File]) *FileNode {
if node == nil {
return nil
}
- result := getNodeAtIndex(node, index, collapsedPaths)
- if result == nil {
- // not sure how this can be nil: we probably are missing a mutex somewhere
+ return &FileNode{Node: node}
+}
+
+// returns the underlying node, without any file-specific methods attached
+func (self *FileNode) Raw() *Node[models.File] {
+ if self == nil {
return nil
}
- return result.(*FileNode)
+ return self.Node
}
-func (node *FileNode) GetIndexForPath(path string, collapsedPaths map[string]bool) (int, bool) {
- return getIndexForPath(node, path, collapsedPaths)
+func (self *FileNode) GetHasUnstagedChanges() bool {
+ return self.SomeFile(func(file *models.File) bool { return file.HasUnstagedChanges })
}
-func (node *FileNode) Size(collapsedPaths map[string]bool) int {
- if node == nil {
- return 0
+func (self *FileNode) GetHasStagedChanges() bool {
+ return self.SomeFile(func(file *models.File) bool { return file.HasStagedChanges })
+}
+
+func (self *FileNode) GetHasInlineMergeConflicts() bool {
+ return self.SomeFile(func(file *models.File) bool { return file.HasInlineMergeConflicts })
+}
+
+func (self *FileNode) GetIsTracked() bool {
+ return self.SomeFile(func(file *models.File) bool { return file.Tracked })
+}
+
+func (self *FileNode) GetPreviousPath() string {
+ if self.File == nil {
+ return ""
}
- return size(node, collapsedPaths)
-}
-
-func (s *FileNode) Compress() {
- // with these functions I try to only have type conversion code on the actual struct,
- // but comparing interface values to nil is fraught with danger so I'm duplicating
- // that code here.
- if s == nil {
- return
- }
-
- compressAux(s)
-}
-
-func (node *FileNode) GetFilePathsMatching(test func(*models.File) bool) []string {
- return getPathsMatching(node, func(n INode) bool {
- castNode := n.(*FileNode)
- if castNode.File == nil {
- return false
- }
- return test(castNode.File)
- })
-}
-
-func (s *FileNode) GetLeaves() []*FileNode {
- leaves := getLeaves(s)
- castLeaves := make([]*FileNode, len(leaves))
- for i := range leaves {
- castLeaves[i] = leaves[i].(*FileNode)
- }
-
- return castLeaves
-}
-
-// extra methods
-
-func (s *FileNode) GetHasUnstagedChanges() bool {
- return s.AnyFile(func(file *models.File) bool { return file.HasUnstagedChanges })
-}
-
-func (s *FileNode) GetHasStagedChanges() bool {
- return s.AnyFile(func(file *models.File) bool { return file.HasStagedChanges })
-}
-
-func (s *FileNode) GetHasInlineMergeConflicts() bool {
- return s.AnyFile(func(file *models.File) bool { return file.HasInlineMergeConflicts })
-}
-
-func (s *FileNode) GetIsTracked() bool {
- return s.AnyFile(func(file *models.File) bool { return file.Tracked })
-}
-
-func (s *FileNode) AnyFile(test func(file *models.File) bool) bool {
- return s.Any(func(node *FileNode) bool {
- return node.IsLeaf() && test(node.File)
- })
-}
-
-func (s *FileNode) NameAtDepth(depth int) string {
- splitName := split(s.Path)
- name := join(splitName[depth:])
-
- if s.File != nil && s.File.IsRename() {
- splitPrevName := split(s.File.PreviousName)
-
- prevName := s.File.PreviousName
- // if the file has just been renamed inside the same directory, we can shave off
- // the prefix for the previous path too. Otherwise we'll keep it unchanged
- sameParentDir := len(splitName) == len(splitPrevName) && join(splitName[0:depth]) == join(splitPrevName[0:depth])
- if sameParentDir {
- prevName = join(splitPrevName[depth:])
- }
-
- return prevName + " → " + name
- }
-
- return name
+ return self.File.PreviousName
}
diff --git a/pkg/gui/filetree/file_node_test.go b/pkg/gui/filetree/file_node_test.go
index 8961015ac..a3b2b9aee 100644
--- a/pkg/gui/filetree/file_node_test.go
+++ b/pkg/gui/filetree/file_node_test.go
@@ -10,8 +10,8 @@ import (
func TestCompress(t *testing.T) {
scenarios := []struct {
name string
- root *FileNode
- expected *FileNode
+ root *Node[models.File]
+ expected *Node[models.File]
}{
{
name: "nil node",
@@ -20,27 +20,27 @@ func TestCompress(t *testing.T) {
},
{
name: "leaf node",
- root: &FileNode{
+ root: &Node[models.File]{
Path: "",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{File: &models.File{Name: "test", ShortStatus: " M", HasStagedChanges: true}, Path: "test"},
},
},
- expected: &FileNode{
+ expected: &Node[models.File]{
Path: "",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{File: &models.File{Name: "test", ShortStatus: " M", HasStagedChanges: true}, Path: "test"},
},
},
},
{
name: "big example",
- root: &FileNode{
+ root: &Node[models.File]{
Path: "",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
Path: "dir1",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "file2", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir1/file2",
@@ -49,7 +49,7 @@ func TestCompress(t *testing.T) {
},
{
Path: "dir2",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "file3", ShortStatus: " M", HasStagedChanges: true},
Path: "dir2/file3",
@@ -62,10 +62,10 @@ func TestCompress(t *testing.T) {
},
{
Path: "dir3",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
Path: "dir3/dir3-1",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "file5", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir3/dir3-1/file5",
@@ -80,12 +80,12 @@ func TestCompress(t *testing.T) {
},
},
},
- expected: &FileNode{
+ expected: &Node[models.File]{
Path: "",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
Path: "dir1",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "file2", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir1/file2",
@@ -94,7 +94,7 @@ func TestCompress(t *testing.T) {
},
{
Path: "dir2",
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "file3", ShortStatus: " M", HasStagedChanges: true},
Path: "dir2/file3",
@@ -108,7 +108,7 @@ func TestCompress(t *testing.T) {
{
Path: "dir3/dir3-1",
CompressionLevel: 1,
- Children: []*FileNode{
+ Children: []*Node[models.File]{
{
File: &models.File{Name: "file5", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir3/dir3-1/file5",
@@ -136,19 +136,19 @@ func TestCompress(t *testing.T) {
func TestGetFile(t *testing.T) {
scenarios := []struct {
name string
- viewModel *FileTreeViewModel
+ viewModel *FileTree
path string
expected *models.File
}{
{
name: "valid case",
- viewModel: NewFileTreeViewModel([]*models.File{{Name: "blah/one"}, {Name: "blah/two"}}, nil, false),
+ viewModel: NewFileTree(func() []*models.File { return []*models.File{{Name: "blah/one"}, {Name: "blah/two"}} }, nil, false),
path: "blah/two",
expected: &models.File{Name: "blah/two"},
},
{
name: "not found",
- viewModel: NewFileTreeViewModel([]*models.File{{Name: "blah/one"}, {Name: "blah/two"}}, nil, false),
+ viewModel: NewFileTree(func() []*models.File { return []*models.File{{Name: "blah/one"}, {Name: "blah/two"}} }, nil, false),
path: "blah/three",
expected: nil,
},
diff --git a/pkg/gui/filetree/file_tree.go b/pkg/gui/filetree/file_tree.go
new file mode 100644
index 000000000..950bf24be
--- /dev/null
+++ b/pkg/gui/filetree/file_tree.go
@@ -0,0 +1,177 @@
+package filetree
+
+import (
+ "fmt"
+
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/sirupsen/logrus"
+)
+
+type FileTreeDisplayFilter int
+
+const (
+ DisplayAll FileTreeDisplayFilter = iota
+ DisplayStaged
+ DisplayUnstaged
+ // this shows files with merge conflicts
+ DisplayConflicted
+)
+
+type ITree[T any] interface {
+ InTreeMode() bool
+ ExpandToPath(path string)
+ ToggleShowTree()
+ GetIndexForPath(path string) (int, bool)
+ Len() int
+ SetTree()
+ IsCollapsed(path string) bool
+ ToggleCollapsed(path string)
+ CollapsedPaths() *CollapsedPaths
+}
+
+type IFileTree interface {
+ ITree[models.File]
+
+ FilterFiles(test func(*models.File) bool) []*models.File
+ SetFilter(filter FileTreeDisplayFilter)
+ Get(index int) *FileNode
+ GetFile(path string) *models.File
+ GetAllItems() []*FileNode
+ GetAllFiles() []*models.File
+ GetFilter() FileTreeDisplayFilter
+ GetRoot() *FileNode
+}
+
+type FileTree struct {
+ getFiles func() []*models.File
+ tree *Node[models.File]
+ showTree bool
+ log *logrus.Entry
+ filter FileTreeDisplayFilter
+ collapsedPaths *CollapsedPaths
+}
+
+var _ IFileTree = &FileTree{}
+
+func NewFileTree(getFiles func() []*models.File, log *logrus.Entry, showTree bool) *FileTree {
+ return &FileTree{
+ getFiles: getFiles,
+ log: log,
+ showTree: showTree,
+ filter: DisplayAll,
+ collapsedPaths: NewCollapsedPaths(),
+ }
+}
+
+func (self *FileTree) InTreeMode() bool {
+ return self.showTree
+}
+
+func (self *FileTree) ExpandToPath(path string) {
+ self.collapsedPaths.ExpandToPath(path)
+}
+
+func (self *FileTree) getFilesForDisplay() []*models.File {
+ switch self.filter {
+ case DisplayAll:
+ return self.getFiles()
+ case DisplayStaged:
+ return self.FilterFiles(func(file *models.File) bool { return file.HasStagedChanges })
+ case DisplayUnstaged:
+ return self.FilterFiles(func(file *models.File) bool { return file.HasUnstagedChanges })
+ case DisplayConflicted:
+ return self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts })
+ default:
+ panic(fmt.Sprintf("Unexpected files display filter: %d", self.filter))
+ }
+}
+
+func (self *FileTree) FilterFiles(test func(*models.File) bool) []*models.File {
+ return slices.Filter(self.getFiles(), test)
+}
+
+func (self *FileTree) SetFilter(filter FileTreeDisplayFilter) {
+ self.filter = filter
+ self.SetTree()
+}
+
+func (self *FileTree) ToggleShowTree() {
+ self.showTree = !self.showTree
+ self.SetTree()
+}
+
+func (self *FileTree) Get(index int) *FileNode {
+ // need to traverse the three depth first until we get to the index.
+ return NewFileNode(self.tree.GetNodeAtIndex(index+1, self.collapsedPaths)) // ignoring root
+}
+
+func (self *FileTree) GetFile(path string) *models.File {
+ for _, file := range self.getFiles() {
+ if file.Name == path {
+ return file
+ }
+ }
+
+ return nil
+}
+
+func (self *FileTree) GetIndexForPath(path string) (int, bool) {
+ index, found := self.tree.GetIndexForPath(path, self.collapsedPaths)
+ return index - 1, found
+}
+
+// note: this gets all items when the filter is taken into consideration. There may
+// be hidden files that aren't included here. Files off the screen however will
+// be included
+func (self *FileTree) GetAllItems() []*FileNode {
+ if self.tree == nil {
+ return nil
+ }
+
+ // ignoring root
+ return slices.Map(self.tree.Flatten(self.collapsedPaths)[1:], func(node *Node[models.File]) *FileNode {
+ return NewFileNode(node)
+ })
+}
+
+func (self *FileTree) Len() int {
+ return self.tree.Size(self.collapsedPaths) - 1 // ignoring root
+}
+
+func (self *FileTree) GetAllFiles() []*models.File {
+ return self.getFiles()
+}
+
+func (self *FileTree) SetTree() {
+ filesForDisplay := self.getFilesForDisplay()
+ if self.showTree {
+ self.tree = BuildTreeFromFiles(filesForDisplay)
+ } else {
+ self.tree = BuildFlatTreeFromFiles(filesForDisplay)
+ }
+}
+
+func (self *FileTree) IsCollapsed(path string) bool {
+ return self.collapsedPaths.IsCollapsed(path)
+}
+
+func (self *FileTree) ToggleCollapsed(path string) {
+ self.collapsedPaths.ToggleCollapsed(path)
+}
+
+func (self *FileTree) Tree() *FileNode {
+ return NewFileNode(self.tree)
+}
+
+func (self *FileTree) GetRoot() *FileNode {
+ return NewFileNode(self.tree)
+}
+
+func (self *FileTree) CollapsedPaths() *CollapsedPaths {
+ return self.collapsedPaths
+}
+
+func (self *FileTree) GetFilter() FileTreeDisplayFilter {
+ return self.filter
+}
diff --git a/pkg/gui/filetree/file_tree_test.go b/pkg/gui/filetree/file_tree_test.go
new file mode 100644
index 000000000..32c110425
--- /dev/null
+++ b/pkg/gui/filetree/file_tree_test.go
@@ -0,0 +1,81 @@
+package filetree
+
+import (
+ "testing"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFilterAction(t *testing.T) {
+ scenarios := []struct {
+ name string
+ filter FileTreeDisplayFilter
+ files []*models.File
+ expected []*models.File
+ }{
+ {
+ name: "filter files with unstaged changes",
+ filter: DisplayUnstaged,
+ files: []*models.File{
+ {Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
+ {Name: "dir2/file5", ShortStatus: "M ", HasStagedChanges: true},
+ {Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
+ },
+ expected: []*models.File{
+ {Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
+ {Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
+ },
+ },
+ {
+ name: "filter files with staged changes",
+ filter: DisplayStaged,
+ files: []*models.File{
+ {Name: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
+ {Name: "dir2/file5", ShortStatus: "M ", HasStagedChanges: false},
+ {Name: "file1", ShortStatus: "M ", HasStagedChanges: true},
+ },
+ expected: []*models.File{
+ {Name: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
+ {Name: "file1", ShortStatus: "M ", HasStagedChanges: true},
+ },
+ },
+ {
+ name: "filter all files",
+ filter: DisplayAll,
+ files: []*models.File{
+ {Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
+ {Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
+ {Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
+ },
+ expected: []*models.File{
+ {Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
+ {Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
+ {Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
+ },
+ },
+ {
+ name: "filter conflicted files",
+ filter: DisplayConflicted,
+ files: []*models.File{
+ {Name: "dir2/dir2/file4", ShortStatus: "DU", HasMergeConflicts: true},
+ {Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
+ {Name: "dir2/file6", ShortStatus: " M", HasStagedChanges: true},
+ {Name: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true},
+ },
+ expected: []*models.File{
+ {Name: "dir2/dir2/file4", ShortStatus: "DU", HasMergeConflicts: true},
+ {Name: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true},
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ s := s
+ t.Run(s.name, func(t *testing.T) {
+ mngr := &FileTree{getFiles: func() []*models.File { return s.files }, filter: s.filter}
+ result := mngr.getFilesForDisplay()
+ assert.EqualValues(t, s.expected, result)
+ })
+ }
+}
diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go
index 01eb751e3..333be8da2 100644
--- a/pkg/gui/filetree/file_tree_view_model.go
+++ b/pkg/gui/filetree/file_tree_view_model.go
@@ -1,159 +1,157 @@
package filetree
import (
- "fmt"
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context/traits"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sirupsen/logrus"
)
-type FileTreeDisplayFilter int
-
-const (
- DisplayAll FileTreeDisplayFilter = iota
- DisplayStaged
- DisplayUnstaged
- // this shows files with merge conflicts
- DisplayConflicted
-)
+type IFileTreeViewModel interface {
+ IFileTree
+ types.IListCursor
+}
+// This combines our FileTree struct with a cursor that retains information about
+// which item is selected. It also contains logic for repositioning that cursor
+// after the files are refreshed
type FileTreeViewModel struct {
- files []*models.File
- tree *FileNode
- showTree bool
- log *logrus.Entry
- filter FileTreeDisplayFilter
- collapsedPaths CollapsedPaths
sync.RWMutex
+ IFileTree
+ types.IListCursor
}
-func NewFileTreeViewModel(files []*models.File, log *logrus.Entry, showTree bool) *FileTreeViewModel {
- viewModel := &FileTreeViewModel{
- log: log,
- showTree: showTree,
- filter: DisplayAll,
- collapsedPaths: CollapsedPaths{},
- RWMutex: sync.RWMutex{},
- }
+var _ IFileTreeViewModel = &FileTreeViewModel{}
- viewModel.SetFiles(files)
-
- return viewModel
-}
-
-func (self *FileTreeViewModel) InTreeMode() bool {
- return self.showTree
-}
-
-func (self *FileTreeViewModel) ExpandToPath(path string) {
- self.collapsedPaths.ExpandToPath(path)
-}
-
-func (self *FileTreeViewModel) GetFilesForDisplay() []*models.File {
- files := self.files
-
- switch self.filter {
- case DisplayAll:
- return files
- case DisplayStaged:
- return self.FilterFiles(func(file *models.File) bool { return file.HasStagedChanges })
- case DisplayUnstaged:
- return self.FilterFiles(func(file *models.File) bool { return file.HasUnstagedChanges })
- case DisplayConflicted:
- return self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts })
- default:
- panic(fmt.Sprintf("Unexpected files display filter: %d", self.filter))
+func NewFileTreeViewModel(getFiles func() []*models.File, log *logrus.Entry, showTree bool) *FileTreeViewModel {
+ fileTree := NewFileTree(getFiles, log, showTree)
+ listCursor := traits.NewListCursor(fileTree)
+ return &FileTreeViewModel{
+ IFileTree: fileTree,
+ IListCursor: listCursor,
}
}
-func (self *FileTreeViewModel) FilterFiles(test func(*models.File) bool) []*models.File {
- result := make([]*models.File, 0)
- for _, file := range self.files {
- if test(file) {
- result = append(result, file)
- }
- }
- return result
-}
-
-func (self *FileTreeViewModel) SetFilter(filter FileTreeDisplayFilter) {
- self.filter = filter
- self.SetTree()
-}
-
-func (self *FileTreeViewModel) ToggleShowTree() {
- self.showTree = !self.showTree
- self.SetTree()
-}
-
-func (self *FileTreeViewModel) GetItemAtIndex(index int) *FileNode {
- // need to traverse the three depth first until we get to the index.
- return self.tree.GetNodeAtIndex(index+1, self.collapsedPaths) // ignoring root
-}
-
-func (self *FileTreeViewModel) GetFile(path string) *models.File {
- for _, file := range self.files {
- if file.Name == path {
- return file
- }
- }
-
- return nil
-}
-
-func (self *FileTreeViewModel) GetIndexForPath(path string) (int, bool) {
- index, found := self.tree.GetIndexForPath(path, self.collapsedPaths)
- return index - 1, found
-}
-
-func (self *FileTreeViewModel) GetAllItems() []*FileNode {
- if self.tree == nil {
+func (self *FileTreeViewModel) GetSelected() *FileNode {
+ if self.Len() == 0 {
return nil
}
- return self.tree.Flatten(self.collapsedPaths)[1:] // ignoring root
+ return self.Get(self.GetSelectedLineIdx())
}
-func (self *FileTreeViewModel) GetItemsLength() int {
- return self.tree.Size(self.collapsedPaths) - 1 // ignoring root
+func (self *FileTreeViewModel) GetSelectedFile() *models.File {
+ node := self.GetSelected()
+ if node == nil {
+ return nil
+ }
+
+ return node.File
}
-func (self *FileTreeViewModel) GetAllFiles() []*models.File {
- return self.files
-}
+func (self *FileTreeViewModel) GetSelectedPath() string {
+ node := self.GetSelected()
+ if node == nil {
+ return ""
+ }
-func (self *FileTreeViewModel) SetFiles(files []*models.File) {
- self.files = files
-
- self.SetTree()
+ return node.GetPath()
}
func (self *FileTreeViewModel) SetTree() {
- filesForDisplay := self.GetFilesForDisplay()
- if self.showTree {
- self.tree = BuildTreeFromFiles(filesForDisplay)
- } else {
- self.tree = BuildFlatTreeFromFiles(filesForDisplay)
+ newFiles := self.GetAllFiles()
+ selectedNode := self.GetSelected()
+
+ // for when you stage the old file of a rename and the new file is in a collapsed dir
+ for _, file := range newFiles {
+ if selectedNode != nil && selectedNode.Path != "" && file.PreviousName == selectedNode.Path {
+ self.ExpandToPath(file.Name)
+ }
+ }
+
+ prevNodes := self.GetAllItems()
+ prevSelectedLineIdx := self.GetSelectedLineIdx()
+
+ self.IFileTree.SetTree()
+
+ if selectedNode != nil {
+ newNodes := self.GetAllItems()
+ newIdx := self.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], newNodes)
+ if newIdx != -1 && newIdx != prevSelectedLineIdx {
+ self.SetSelectedLineIdx(newIdx)
+ }
+ }
+
+ self.RefreshSelectedIdx()
+}
+
+// Let's try to find our file again and move the cursor to that.
+// If we can't find our file, it was probably just removed by the user. In that
+// case, we go looking for where the next file has been moved to. Given that the
+// user could have removed a whole directory, we continue iterating through the old
+// nodes until we find one that exists in the new set of nodes, then move the cursor
+// to that.
+// prevNodes starts from our previously selected node because we don't need to consider anything above that
+func (self *FileTreeViewModel) findNewSelectedIdx(prevNodes []*FileNode, currNodes []*FileNode) int {
+ getPaths := func(node *FileNode) []string {
+ if node == nil {
+ return nil
+ }
+ if node.File != nil && node.File.IsRename() {
+ return node.File.Names()
+ } else {
+ return []string{node.Path}
+ }
+ }
+
+ for _, prevNode := range prevNodes {
+ selectedPaths := getPaths(prevNode)
+
+ for idx, node := range currNodes {
+ paths := getPaths(node)
+
+ // If you started off with a rename selected, and now it's broken in two, we want you to jump to the new file, not the old file.
+ // This is because the new should be in the same position as the rename was meaning less cursor jumping
+ foundOldFileInRename := prevNode.File != nil && prevNode.File.IsRename() && node.Path == prevNode.File.PreviousName
+ foundNode := utils.StringArraysOverlap(paths, selectedPaths) && !foundOldFileInRename
+ if foundNode {
+ return idx
+ }
+ }
+ }
+
+ return -1
+}
+
+func (self *FileTreeViewModel) SetFilter(filter FileTreeDisplayFilter) {
+ self.IFileTree.SetFilter(filter)
+ self.IListCursor.SetSelectedLineIdx(0)
+}
+
+// If we're going from flat to tree we want to select the same file.
+// If we're going from tree to flat and we have a file selected we want to select that.
+// If instead we've selected a directory we need to select the first file in that directory.
+func (self *FileTreeViewModel) ToggleShowTree() {
+ selectedNode := self.GetSelected()
+
+ self.IFileTree.ToggleShowTree()
+
+ if selectedNode == nil {
+ return
+ }
+ path := selectedNode.Path
+
+ if self.InTreeMode() {
+ self.ExpandToPath(path)
+ } else if len(selectedNode.Children) > 0 {
+ path = selectedNode.GetLeaves()[0].Path
+ }
+
+ index, found := self.GetIndexForPath(path)
+ if found {
+ self.SetSelectedLineIdx(index)
}
}
-
-func (self *FileTreeViewModel) IsCollapsed(path string) bool {
- return self.collapsedPaths.IsCollapsed(path)
-}
-
-func (self *FileTreeViewModel) ToggleCollapsed(path string) {
- self.collapsedPaths.ToggleCollapsed(path)
-}
-
-func (self *FileTreeViewModel) Tree() INode {
- return self.tree
-}
-
-func (self *FileTreeViewModel) CollapsedPaths() CollapsedPaths {
- return self.collapsedPaths
-}
-
-func (self *FileTreeViewModel) GetFilter() FileTreeDisplayFilter {
- return self.filter
-}
diff --git a/pkg/gui/filetree/file_tree_view_model_test.go b/pkg/gui/filetree/file_tree_view_model_test.go
deleted file mode 100644
index 89b8e74df..000000000
--- a/pkg/gui/filetree/file_tree_view_model_test.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package filetree
-
-import (
- "testing"
-
- "github.com/jesseduffield/lazygit/pkg/commands/models"
- "github.com/stretchr/testify/assert"
-)
-
-func TestFilterAction(t *testing.T) {
- scenarios := []struct {
- name string
- filter FileTreeDisplayFilter
- files []*models.File
- expected []*models.File
- }{
- {
- name: "filter files with unstaged changes",
- filter: DisplayUnstaged,
- files: []*models.File{
- {Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
- {Name: "dir2/file5", ShortStatus: "M ", HasStagedChanges: true},
- {Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
- },
- expected: []*models.File{
- {Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
- {Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
- },
- },
- {
- name: "filter files with staged changes",
- filter: DisplayStaged,
- files: []*models.File{
- {Name: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
- {Name: "dir2/file5", ShortStatus: "M ", HasStagedChanges: false},
- {Name: "file1", ShortStatus: "M ", HasStagedChanges: true},
- },
- expected: []*models.File{
- {Name: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
- {Name: "file1", ShortStatus: "M ", HasStagedChanges: true},
- },
- },
- {
- name: "filter all files",
- filter: DisplayAll,
- files: []*models.File{
- {Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
- {Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
- {Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
- },
- expected: []*models.File{
- {Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
- {Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
- {Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
- },
- },
- {
- name: "filter conflicted files",
- filter: DisplayConflicted,
- files: []*models.File{
- {Name: "dir2/dir2/file4", ShortStatus: "DU", HasMergeConflicts: true},
- {Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
- {Name: "dir2/file6", ShortStatus: " M", HasStagedChanges: true},
- {Name: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true},
- },
- expected: []*models.File{
- {Name: "dir2/dir2/file4", ShortStatus: "DU", HasMergeConflicts: true},
- {Name: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true},
- },
- },
- }
-
- for _, s := range scenarios {
- s := s
- t.Run(s.name, func(t *testing.T) {
- mngr := &FileTreeViewModel{files: s.files, filter: s.filter}
- result := mngr.GetFilesForDisplay()
- assert.EqualValues(t, s.expected, result)
- })
- }
-}
diff --git a/pkg/gui/filetree/inode.go b/pkg/gui/filetree/inode.go
deleted file mode 100644
index 7d9035fe3..000000000
--- a/pkg/gui/filetree/inode.go
+++ /dev/null
@@ -1,213 +0,0 @@
-package filetree
-
-import (
- "sort"
-)
-
-type INode interface {
- IsNil() bool
- IsLeaf() bool
- GetPath() string
- GetChildren() []INode
- SetChildren([]INode)
- GetCompressionLevel() int
- SetCompressionLevel(int)
-}
-
-func sortNode(node INode) {
- sortChildren(node)
-
- for _, child := range node.GetChildren() {
- sortNode(child)
- }
-}
-
-func sortChildren(node INode) {
- if node.IsLeaf() {
- return
- }
-
- children := node.GetChildren()
- sortedChildren := make([]INode, len(children))
- copy(sortedChildren, children)
-
- sort.Slice(sortedChildren, func(i, j int) bool {
- if !sortedChildren[i].IsLeaf() && sortedChildren[j].IsLeaf() {
- return true
- }
- if sortedChildren[i].IsLeaf() && !sortedChildren[j].IsLeaf() {
- return false
- }
-
- return sortedChildren[i].GetPath() < sortedChildren[j].GetPath()
- })
-
- // TODO: think about making this in-place
- node.SetChildren(sortedChildren)
-}
-
-func forEachLeaf(node INode, cb func(INode) error) error {
- if node.IsLeaf() {
- if err := cb(node); err != nil {
- return err
- }
- }
-
- for _, child := range node.GetChildren() {
- if err := forEachLeaf(child, cb); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func any(node INode, test func(INode) bool) bool {
- if test(node) {
- return true
- }
-
- for _, child := range node.GetChildren() {
- if any(child, test) {
- return true
- }
- }
-
- return false
-}
-
-func every(node INode, test func(INode) bool) bool {
- if !test(node) {
- return false
- }
-
- for _, child := range node.GetChildren() {
- if !every(child, test) {
- return false
- }
- }
-
- return true
-}
-
-func flatten(node INode, collapsedPaths map[string]bool) []INode {
- result := []INode{}
- result = append(result, node)
-
- if !collapsedPaths[node.GetPath()] {
- for _, child := range node.GetChildren() {
- result = append(result, flatten(child, collapsedPaths)...)
- }
- }
-
- return result
-}
-
-func getNodeAtIndex(node INode, index int, collapsedPaths map[string]bool) INode {
- foundNode, _ := getNodeAtIndexAux(node, index, collapsedPaths)
-
- return foundNode
-}
-
-func getNodeAtIndexAux(node INode, index int, collapsedPaths map[string]bool) (INode, int) {
- offset := 1
-
- if index == 0 {
- return node, offset
- }
-
- if !collapsedPaths[node.GetPath()] {
- for _, child := range node.GetChildren() {
- foundNode, offsetChange := getNodeAtIndexAux(child, index-offset, collapsedPaths)
- offset += offsetChange
- if foundNode != nil {
- return foundNode, offset
- }
- }
- }
-
- return nil, offset
-}
-
-func getIndexForPath(node INode, path string, collapsedPaths map[string]bool) (int, bool) {
- offset := 0
-
- if node.GetPath() == path {
- return offset, true
- }
-
- if !collapsedPaths[node.GetPath()] {
- for _, child := range node.GetChildren() {
- offsetChange, found := getIndexForPath(child, path, collapsedPaths)
- offset += offsetChange + 1
- if found {
- return offset, true
- }
- }
- }
-
- return offset, false
-}
-
-func size(node INode, collapsedPaths map[string]bool) int {
- output := 1
-
- if !collapsedPaths[node.GetPath()] {
- for _, child := range node.GetChildren() {
- output += size(child, collapsedPaths)
- }
- }
-
- return output
-}
-
-func compressAux(node INode) INode {
- if node.IsLeaf() {
- return node
- }
-
- children := node.GetChildren()
- for i := range children {
- grandchildren := children[i].GetChildren()
- for len(grandchildren) == 1 && !grandchildren[0].IsLeaf() {
- grandchildren[0].SetCompressionLevel(children[i].GetCompressionLevel() + 1)
- children[i] = grandchildren[0]
- grandchildren = children[i].GetChildren()
- }
- }
-
- for i := range children {
- children[i] = compressAux(children[i])
- }
-
- node.SetChildren(children)
-
- return node
-}
-
-func getPathsMatching(node INode, test func(INode) bool) []string {
- paths := []string{}
-
- if test(node) {
- paths = append(paths, node.GetPath())
- }
-
- for _, child := range node.GetChildren() {
- paths = append(paths, getPathsMatching(child, test)...)
- }
-
- return paths
-}
-
-func getLeaves(node INode) []INode {
- if node.IsLeaf() {
- return []INode{node}
- }
-
- output := []INode{}
- for _, child := range node.GetChildren() {
- output = append(output, getLeaves(child)...)
- }
-
- return output
-}
diff --git a/pkg/gui/filetree/node.go b/pkg/gui/filetree/node.go
new file mode 100644
index 000000000..8de655b37
--- /dev/null
+++ b/pkg/gui/filetree/node.go
@@ -0,0 +1,301 @@
+package filetree
+
+import (
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// Represents a file or directory in a file tree.
+type Node[T any] struct {
+ // File will be nil if the node is a directory.
+ File *T
+
+ // If the node is a directory, Children contains the contents of the directory,
+ // otherwise it's nil.
+ Children []*Node[T]
+
+ // path of the file/directory
+ Path string
+
+ // rather than render a tree as:
+ // a/
+ // b/
+ // file.blah
+ //
+ // we instead render it as:
+ // a/b/
+ // file.blah
+ // This saves vertical space. The CompressionLevel of a node is equal to the
+ // number of times a 'compression' like the above has happened, where two
+ // nodes are squished into one.
+ CompressionLevel int
+}
+
+var _ types.ListItem = &Node[models.File]{}
+
+func (self *Node[T]) IsFile() bool {
+ return self.File != nil
+}
+
+func (self *Node[T]) GetPath() string {
+ return self.Path
+}
+
+func (self *Node[T]) Sort() {
+ self.SortChildren()
+
+ for _, child := range self.Children {
+ child.Sort()
+ }
+}
+
+func (self *Node[T]) ForEachFile(cb func(*T) error) error {
+ if self.IsFile() {
+ if err := cb(self.File); err != nil {
+ return err
+ }
+ }
+
+ for _, child := range self.Children {
+ if err := child.ForEachFile(cb); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (self *Node[T]) SortChildren() {
+ if self.IsFile() {
+ return
+ }
+
+ children := slices.Clone(self.Children)
+
+ slices.SortFunc(children, func(a, b *Node[T]) bool {
+ if !a.IsFile() && b.IsFile() {
+ return true
+ }
+ if a.IsFile() && !b.IsFile() {
+ return false
+ }
+
+ return a.GetPath() < b.GetPath()
+ })
+
+ // TODO: think about making this in-place
+ self.Children = children
+}
+
+func (self *Node[T]) Some(test func(*Node[T]) bool) bool {
+ if test(self) {
+ return true
+ }
+
+ for _, child := range self.Children {
+ if child.Some(test) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (self *Node[T]) SomeFile(test func(*T) bool) bool {
+ if self.IsFile() {
+ if test(self.File) {
+ return true
+ }
+ } else {
+ for _, child := range self.Children {
+ if child.SomeFile(test) {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+func (self *Node[T]) Every(test func(*Node[T]) bool) bool {
+ if !test(self) {
+ return false
+ }
+
+ for _, child := range self.Children {
+ if !child.Every(test) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func (self *Node[T]) EveryFile(test func(*T) bool) bool {
+ if self.IsFile() {
+ if !test(self.File) {
+ return false
+ }
+ } else {
+ for _, child := range self.Children {
+ if !child.EveryFile(test) {
+ return false
+ }
+ }
+ }
+
+ return true
+}
+
+func (self *Node[T]) Flatten(collapsedPaths *CollapsedPaths) []*Node[T] {
+ result := []*Node[T]{self}
+
+ if len(self.Children) > 0 && !collapsedPaths.IsCollapsed(self.GetPath()) {
+ result = append(result, slices.FlatMap(self.Children, func(child *Node[T]) []*Node[T] {
+ return child.Flatten(collapsedPaths)
+ })...)
+ }
+
+ return result
+}
+
+func (self *Node[T]) GetNodeAtIndex(index int, collapsedPaths *CollapsedPaths) *Node[T] {
+ if self == nil {
+ return nil
+ }
+
+ node, _ := self.getNodeAtIndexAux(index, collapsedPaths)
+
+ return node
+}
+
+func (self *Node[T]) getNodeAtIndexAux(index int, collapsedPaths *CollapsedPaths) (*Node[T], int) {
+ offset := 1
+
+ if index == 0 {
+ return self, offset
+ }
+
+ if !collapsedPaths.IsCollapsed(self.GetPath()) {
+ for _, child := range self.Children {
+ foundNode, offsetChange := child.getNodeAtIndexAux(index-offset, collapsedPaths)
+ offset += offsetChange
+ if foundNode != nil {
+ return foundNode, offset
+ }
+ }
+ }
+
+ return nil, offset
+}
+
+func (self *Node[T]) GetIndexForPath(path string, collapsedPaths *CollapsedPaths) (int, bool) {
+ offset := 0
+
+ if self.GetPath() == path {
+ return offset, true
+ }
+
+ if !collapsedPaths.IsCollapsed(self.GetPath()) {
+ for _, child := range self.Children {
+ offsetChange, found := child.GetIndexForPath(path, collapsedPaths)
+ offset += offsetChange + 1
+ if found {
+ return offset, true
+ }
+ }
+ }
+
+ return offset, false
+}
+
+func (self *Node[T]) Size(collapsedPaths *CollapsedPaths) int {
+ if self == nil {
+ return 0
+ }
+
+ output := 1
+
+ if !collapsedPaths.IsCollapsed(self.GetPath()) {
+ for _, child := range self.Children {
+ output += child.Size(collapsedPaths)
+ }
+ }
+
+ return output
+}
+
+func (self *Node[T]) Compress() {
+ if self == nil {
+ return
+ }
+
+ self.compressAux()
+}
+
+func (self *Node[T]) compressAux() *Node[T] {
+ if self.IsFile() {
+ return self
+ }
+
+ children := self.Children
+ for i := range children {
+ grandchildren := children[i].Children
+ for len(grandchildren) == 1 && !grandchildren[0].IsFile() {
+ grandchildren[0].CompressionLevel = children[i].CompressionLevel + 1
+ children[i] = grandchildren[0]
+ grandchildren = children[i].Children
+ }
+ }
+
+ for i := range children {
+ children[i] = children[i].compressAux()
+ }
+
+ self.Children = children
+
+ return self
+}
+
+func (self *Node[T]) GetPathsMatching(test func(*Node[T]) bool) []string {
+ paths := []string{}
+
+ if test(self) {
+ paths = append(paths, self.GetPath())
+ }
+
+ for _, child := range self.Children {
+ paths = append(paths, child.GetPathsMatching(test)...)
+ }
+
+ return paths
+}
+
+func (self *Node[T]) GetFilePathsMatching(test func(*T) bool) []string {
+ matchingFileNodes := slices.Filter(self.GetLeaves(), func(node *Node[T]) bool {
+ return test(node.File)
+ })
+
+ return slices.Map(matchingFileNodes, func(node *Node[T]) string {
+ return node.GetPath()
+ })
+}
+
+func (self *Node[T]) GetLeaves() []*Node[T] {
+ if self.IsFile() {
+ return []*Node[T]{self}
+ }
+
+ return slices.FlatMap(self.Children, func(child *Node[T]) []*Node[T] {
+ return child.GetLeaves()
+ })
+}
+
+func (self *Node[T]) ID() string {
+ return self.GetPath()
+}
+
+func (self *Node[T]) Description() string {
+ return self.GetPath()
+}
diff --git a/pkg/gui/filtering.go b/pkg/gui/filtering.go
index 1f5c5032a..2746d0e2b 100644
--- a/pkg/gui/filtering.go
+++ b/pkg/gui/filtering.go
@@ -1,16 +1,30 @@
package gui
-func (gui *Gui) validateNotInFilterMode() (bool, error) {
+import (
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+func (gui *Gui) validateNotInFilterMode() bool {
if gui.State.Modes.Filtering.Active() {
- err := gui.ask(askOpts{
- title: gui.Tr.MustExitFilterModeTitle,
- prompt: gui.Tr.MustExitFilterModePrompt,
- handleConfirm: gui.exitFilterMode,
+ _ = gui.c.Confirm(types.ConfirmOpts{
+ Title: gui.c.Tr.MustExitFilterModeTitle,
+ Prompt: gui.c.Tr.MustExitFilterModePrompt,
+ HandleConfirm: gui.exitFilterMode,
})
- return false, err
+ return false
+ }
+ return true
+}
+
+func (gui *Gui) outsideFilterMode(f func() error) func() error {
+ return func() error {
+ if !gui.validateNotInFilterMode() {
+ return nil
+ }
+
+ return f()
}
- return true, nil
}
func (gui *Gui) exitFilterMode() error {
@@ -23,7 +37,7 @@ func (gui *Gui) clearFiltering() error {
gui.State.ScreenMode = SCREEN_NORMAL
}
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{COMMITS}})
+ return gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}})
}
func (gui *Gui) setFiltering(path string) error {
@@ -32,11 +46,11 @@ func (gui *Gui) setFiltering(path string) error {
gui.State.ScreenMode = SCREEN_HALF
}
- if err := gui.pushContext(gui.State.Contexts.BranchCommits); err != nil {
+ if err := gui.c.PushContext(gui.State.Contexts.LocalCommits); err != nil {
return err
}
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{COMMITS}, then: func() {
- gui.State.Contexts.BranchCommits.GetPanelState().SetSelectedLineIdx(0)
+ return gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}, Then: func() {
+ gui.State.Contexts.LocalCommits.SetSelectedLineIdx(0)
}})
}
diff --git a/pkg/gui/filtering_menu_panel.go b/pkg/gui/filtering_menu_panel.go
index 2955f6e8d..97327324e 100644
--- a/pkg/gui/filtering_menu_panel.go
+++ b/pkg/gui/filtering_menu_panel.go
@@ -3,6 +3,8 @@ package gui
import (
"fmt"
"strings"
+
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
func (gui *Gui) handleCreateFilteringMenuPanel() error {
@@ -14,30 +16,30 @@ func (gui *Gui) handleCreateFilteringMenuPanel() error {
fileName = node.GetPath()
}
case gui.State.Contexts.CommitFiles:
- node := gui.getSelectedCommitFileNode()
+ node := gui.State.Contexts.CommitFiles.GetSelected()
if node != nil {
fileName = node.GetPath()
}
}
- menuItems := []*menuItem{}
+ menuItems := []*types.MenuItem{}
if fileName != "" {
- menuItems = append(menuItems, &menuItem{
- displayString: fmt.Sprintf("%s '%s'", gui.Tr.LcFilterBy, fileName),
- onPress: func() error {
+ menuItems = append(menuItems, &types.MenuItem{
+ Label: fmt.Sprintf("%s '%s'", gui.c.Tr.LcFilterBy, fileName),
+ OnPress: func() error {
return gui.setFiltering(fileName)
},
})
}
- menuItems = append(menuItems, &menuItem{
- displayString: gui.Tr.LcFilterPathOption,
- onPress: func() error {
- return gui.prompt(promptOpts{
- findSuggestionsFunc: gui.getFilePathSuggestionsFunc(),
- title: gui.Tr.EnterFileName,
- handleConfirm: func(response string) error {
+ menuItems = append(menuItems, &types.MenuItem{
+ Label: gui.c.Tr.LcFilterPathOption,
+ OnPress: func() error {
+ return gui.c.Prompt(types.PromptOpts{
+ FindSuggestionsFunc: gui.helpers.Suggestions.GetFilePathSuggestionsFunc(),
+ Title: gui.c.Tr.EnterFileName,
+ HandleConfirm: func(response string) error {
return gui.setFiltering(strings.TrimSpace(response))
},
})
@@ -45,11 +47,11 @@ func (gui *Gui) handleCreateFilteringMenuPanel() error {
})
if gui.State.Modes.Filtering.Active() {
- menuItems = append(menuItems, &menuItem{
- displayString: gui.Tr.LcExitFilterMode,
- onPress: gui.clearFiltering,
+ menuItems = append(menuItems, &types.MenuItem{
+ Label: gui.c.Tr.LcExitFilterMode,
+ OnPress: gui.clearFiltering,
})
}
- return gui.createMenu(gui.Tr.FilteringMenuTitle, menuItems, createMenuOptions{showCancel: true})
+ return gui.c.Menu(types.CreateMenuOptions{Title: gui.c.Tr.FilteringMenuTitle, Items: menuItems})
}
diff --git a/pkg/gui/git_flow.go b/pkg/gui/git_flow.go
deleted file mode 100644
index e89c7637c..000000000
--- a/pkg/gui/git_flow.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package gui
-
-import (
- "fmt"
-
- "github.com/jesseduffield/lazygit/pkg/utils"
-)
-
-func (gui *Gui) handleCreateGitFlowMenu() error {
- branch := gui.getSelectedBranch()
- if branch == nil {
- return nil
- }
-
- if !gui.Git.Flow.GitFlowEnabled() {
- return gui.createErrorPanel("You need to install git-flow and enable it in this repo to use git-flow features")
- }
-
- startHandler := func(branchType string) func() error {
- return func() error {
- title := utils.ResolvePlaceholderString(gui.Tr.NewGitFlowBranchPrompt, map[string]string{"branchType": branchType})
-
- return gui.prompt(promptOpts{
- title: title,
- handleConfirm: func(name string) error {
- gui.logAction(gui.Tr.Actions.GitFlowStart)
- return gui.runSubprocessWithSuspenseAndRefresh(
- gui.Git.Flow.StartCmdObj(branchType, name),
- )
- },
- })
- }
- }
-
- menuItems := []*menuItem{
- {
- // not localising here because it's one to one with the actual git flow commands
- displayString: fmt.Sprintf("finish branch '%s'", branch.Name),
- onPress: func() error {
- return gui.gitFlowFinishBranch(branch.Name)
- },
- },
- {
- displayString: "start feature",
- onPress: startHandler("feature"),
- },
- {
- displayString: "start hotfix",
- onPress: startHandler("hotfix"),
- },
- {
- displayString: "start bugfix",
- onPress: startHandler("bugfix"),
- },
- {
- displayString: "start release",
- onPress: startHandler("release"),
- },
- }
-
- return gui.createMenu("git flow", menuItems, createMenuOptions{})
-}
-
-func (gui *Gui) gitFlowFinishBranch(branchName string) error {
- cmdObj, err := gui.Git.Flow.FinishCmdObj(branchName)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- gui.logAction(gui.Tr.Actions.GitFlowFinish)
- return gui.runSubprocessWithSuspenseAndRefresh(cmdObj)
-}
diff --git a/pkg/gui/global_handlers.go b/pkg/gui/global_handlers.go
index 22b43b6b7..326b856bd 100644
--- a/pkg/gui/global_handlers.go
+++ b/pkg/gui/global_handlers.go
@@ -2,11 +2,11 @@ package gui
import (
"fmt"
- "math"
"strings"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@@ -15,8 +15,9 @@ const HORIZONTAL_SCROLL_FACTOR = 3
// these views need to be re-rendered when the screen mode changes. The commits view,
// for example, will show authorship information in half and full screen mode.
func (gui *Gui) rerenderViewsWithScreenModeDependentContent() error {
- for _, view := range []*gocui.View{gui.Views.Branches, gui.Views.Commits} {
- if err := gui.rerenderView(view); err != nil {
+ // for now we re-render all list views.
+ for _, context := range gui.getListContexts() {
+ if err := gui.rerenderView(context.GetView()); err != nil {
return err
}
}
@@ -24,7 +25,6 @@ func (gui *Gui) rerenderViewsWithScreenModeDependentContent() error {
return nil
}
-// TODO: GENERICS
func nextIntInCycle(sl []WindowMaximisation, current WindowMaximisation) WindowMaximisation {
for i, val := range sl {
if val == current {
@@ -37,7 +37,6 @@ func nextIntInCycle(sl []WindowMaximisation, current WindowMaximisation) WindowM
return sl[0]
}
-// TODO: GENERICS
func prevIntInCycle(sl []WindowMaximisation, current WindowMaximisation) WindowMaximisation {
for i, val := range sl {
if val == current {
@@ -62,101 +61,81 @@ func (gui *Gui) prevScreenMode() error {
return gui.rerenderViewsWithScreenModeDependentContent()
}
-func (gui *Gui) scrollUpView(view *gocui.View) error {
- ox, oy := view.Origin()
- newOy := int(math.Max(0, float64(oy-gui.UserConfig.Gui.ScrollHeight)))
- return view.SetOrigin(ox, newOy)
+func (gui *Gui) scrollUpView(view *gocui.View) {
+ view.ScrollUp(gui.c.UserConfig.Gui.ScrollHeight)
}
-func (gui *Gui) scrollDownView(view *gocui.View) error {
- ox, oy := view.Origin()
- scrollHeight := gui.linesToScrollDown(view)
- if scrollHeight > 0 {
- if err := view.SetOrigin(ox, oy+scrollHeight); err != nil {
- return err
- }
- }
+func (gui *Gui) scrollDownView(view *gocui.View) {
+ scrollHeight := gui.c.UserConfig.Gui.ScrollHeight
+ view.ScrollDown(scrollHeight)
if manager, ok := gui.viewBufferManagerMap[view.Name()]; ok {
manager.ReadLines(scrollHeight)
}
- return nil
-}
-
-func (gui *Gui) linesToScrollDown(view *gocui.View) int {
- _, oy := view.Origin()
- y := oy
- canScrollPastBottom := gui.UserConfig.Gui.ScrollPastBottom
- if !canScrollPastBottom {
- _, sy := view.Size()
- y += sy
- }
- scrollHeight := gui.UserConfig.Gui.ScrollHeight
- scrollableLines := view.ViewLinesHeight() - y
- if scrollableLines < 0 {
- return 0
- }
-
- // margin is about how many lines must still appear if you scroll
- // all the way down. In practice every file ends in a newline so it will really
- // just show a single line
- margin := 1
- if canScrollPastBottom {
- margin = 2
- }
- if scrollableLines-margin < scrollHeight {
- scrollHeight = scrollableLines - margin
- }
- if oy+scrollHeight < 0 {
- return 0
- } else {
- return scrollHeight
- }
}
func (gui *Gui) scrollUpMain() error {
- if gui.renderingConflicts() {
- gui.State.Panels.Merging.UserVerticalScrolling = true
+ var view *gocui.View
+ if gui.c.CurrentContext().GetWindowName() == "secondary" {
+ view = gui.secondaryView()
+ } else {
+ view = gui.mainView()
}
- return gui.scrollUpView(gui.Views.Main)
+ if view.Name() == "mergeConflicts" {
+ // although we have this same logic in the controller, this method can be invoked
+ // via the global scroll up/down keybindings, as opposed to just the mouse wheel keybinding.
+ // It would be nice to have a concept of a global keybinding that runs on the top context in a
+ // window but that might be overkill for this one use case.
+ gui.State.Contexts.MergeConflicts.SetUserScrolling(true)
+ }
+
+ gui.scrollUpView(view)
+
+ return nil
}
func (gui *Gui) scrollDownMain() error {
- if gui.renderingConflicts() {
- gui.State.Panels.Merging.UserVerticalScrolling = true
+ var view *gocui.View
+ if gui.c.CurrentContext().GetWindowName() == "secondary" {
+ view = gui.secondaryView()
+ } else {
+ view = gui.mainView()
}
- return gui.scrollDownView(gui.Views.Main)
-}
+ if view.Name() == "mergeConflicts" {
+ gui.State.Contexts.MergeConflicts.SetUserScrolling(true)
+ }
-func (gui *Gui) scrollLeftMain() error {
- gui.scrollLeft(gui.Views.Main)
+ gui.scrollDownView(view)
return nil
}
-func (gui *Gui) scrollRightMain() error {
- gui.scrollRight(gui.Views.Main)
-
- return nil
+func (gui *Gui) mainView() *gocui.View {
+ viewName := gui.getViewNameForWindow("main")
+ view, _ := gui.g.View(viewName)
+ return view
}
-func (gui *Gui) scrollLeft(view *gocui.View) {
- newOriginX := utils.Max(view.OriginX()-view.InnerWidth()/HORIZONTAL_SCROLL_FACTOR, 0)
- _ = view.SetOriginX(newOriginX)
-}
-
-func (gui *Gui) scrollRight(view *gocui.View) {
- _ = view.SetOriginX(view.OriginX() + view.InnerWidth()/HORIZONTAL_SCROLL_FACTOR)
+func (gui *Gui) secondaryView() *gocui.View {
+ viewName := gui.getViewNameForWindow("secondary")
+ view, _ := gui.g.View(viewName)
+ return view
}
func (gui *Gui) scrollUpSecondary() error {
- return gui.scrollUpView(gui.Views.Secondary)
+ gui.scrollUpView(gui.secondaryView())
+
+ return nil
}
func (gui *Gui) scrollDownSecondary() error {
- return gui.scrollDownView(gui.Views.Secondary)
+ secondaryView := gui.secondaryView()
+
+ gui.scrollDownView(secondaryView)
+
+ return nil
}
func (gui *Gui) scrollUpConfirmationPanel() error {
@@ -164,7 +143,9 @@ func (gui *Gui) scrollUpConfirmationPanel() error {
return nil
}
- return gui.scrollUpView(gui.Views.Confirmation)
+ gui.scrollUpView(gui.Views.Confirmation)
+
+ return nil
}
func (gui *Gui) scrollDownConfirmationPanel() error {
@@ -172,67 +153,19 @@ func (gui *Gui) scrollDownConfirmationPanel() error {
return nil
}
- return gui.scrollDownView(gui.Views.Confirmation)
+ gui.scrollDownView(gui.Views.Confirmation)
+
+ return nil
}
func (gui *Gui) handleRefresh() error {
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
-}
-
-func (gui *Gui) handleMouseDownMain() error {
- if gui.popupPanelFocused() {
- return nil
- }
-
- switch gui.currentSideContext() {
- case gui.State.Contexts.Files:
- // set filename, set primary/secondary selected, set line number, then switch context
- // I'll need to know it was changed though.
- // Could I pass something along to the context change?
- return gui.enterFile(OnFocusOpts{ClickedViewName: "main", ClickedViewLineIdx: gui.Views.Main.SelectedLineIdx()})
- case gui.State.Contexts.CommitFiles:
- return gui.enterCommitFile(OnFocusOpts{ClickedViewName: "main", ClickedViewLineIdx: gui.Views.Main.SelectedLineIdx()})
- }
-
- return nil
-}
-
-func (gui *Gui) handleMouseDownSecondary() error {
- if gui.popupPanelFocused() {
- return nil
- }
-
- switch gui.g.CurrentView() {
- case gui.Views.Files:
- return gui.enterFile(OnFocusOpts{ClickedViewName: "secondary", ClickedViewLineIdx: gui.Views.Secondary.SelectedLineIdx()})
- }
-
- return nil
-}
-
-func (gui *Gui) fetch() (err error) {
- gui.Mutexes.FetchMutex.Lock()
- defer gui.Mutexes.FetchMutex.Unlock()
-
- gui.logAction("Fetch")
- err = gui.Git.Sync.Fetch(git_commands.FetchOptions{})
-
- if err != nil && strings.Contains(err.Error(), "exit status 128") {
- _ = gui.createErrorPanel(gui.Tr.PassUnameWrong)
- }
-
- _ = gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{BRANCHES, COMMITS, REMOTES, TAGS}, mode: ASYNC})
-
- return err
+ return gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
}
func (gui *Gui) backgroundFetch() (err error) {
- gui.Mutexes.FetchMutex.Lock()
- defer gui.Mutexes.FetchMutex.Unlock()
+ err = gui.git.Sync.Fetch(git_commands.FetchOptions{Background: true})
- err = gui.Git.Sync.Fetch(git_commands.FetchOptions{Background: true})
-
- _ = gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{BRANCHES, COMMITS, REMOTES, TAGS}, mode: ASYNC})
+ _ = gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.ASYNC})
return err
}
@@ -245,14 +178,14 @@ func (gui *Gui) handleCopySelectedSideContextItemToClipboard() error {
return nil
}
- gui.logAction(gui.Tr.Actions.CopyToClipboard)
- if err := gui.OSCommand.CopyToClipboard(itemId); err != nil {
- return gui.surfaceError(err)
+ gui.c.LogAction(gui.c.Tr.Actions.CopyToClipboard)
+ if err := gui.os.CopyToClipboard(itemId); err != nil {
+ return gui.c.Error(err)
}
truncatedItemId := utils.TruncateWithEllipsis(strings.Replace(itemId, "\n", " ", -1), 50)
- gui.raiseToast(fmt.Sprintf("'%s' %s", truncatedItemId, gui.Tr.LcCopiedToClipboard))
+ gui.c.Toast(fmt.Sprintf("'%s' %s", truncatedItemId, gui.c.Tr.LcCopiedToClipboard))
return nil
}
diff --git a/pkg/gui/gpg.go b/pkg/gui/gpg.go
deleted file mode 100644
index ca7e4b842..000000000
--- a/pkg/gui/gpg.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package gui
-
-import (
- "fmt"
-
- "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
- "github.com/jesseduffield/lazygit/pkg/gui/style"
-)
-
-// Currently there is a bug where if we switch to a subprocess from within
-// WithWaitingStatus we get stuck there and can't return to lazygit. We could
-// fix this bug, or just stop running subprocesses from within there, given that
-// we don't need to see a loading status if we're in a subprocess.
-// TODO: work out if we actually need to use a shell command here
-func (gui *Gui) withGpgHandling(cmdObj oscommands.ICmdObj, waitingStatus string, onSuccess func() error) error {
- gui.logCommand(cmdObj.ToString(), true)
-
- useSubprocess := gui.Git.Config.UsingGpg()
- if useSubprocess {
- success, err := gui.runSubprocessWithSuspense(gui.OSCommand.Cmd.NewShell(cmdObj.ToString()))
- if success && onSuccess != nil {
- if err := onSuccess(); err != nil {
- return err
- }
- }
- if err := gui.refreshSidePanels(refreshOptions{mode: ASYNC}); err != nil {
- return err
- }
-
- return err
- } else {
- return gui.RunAndStream(cmdObj, waitingStatus, onSuccess)
- }
-}
-
-func (gui *Gui) RunAndStream(cmdObj oscommands.ICmdObj, waitingStatus string, onSuccess func() error) error {
- return gui.WithWaitingStatus(waitingStatus, func() error {
- cmdObj := gui.OSCommand.Cmd.NewShell(cmdObj.ToString())
- cmdObj.AddEnvVars("TERM=dumb")
- cmdWriter := gui.getCmdWriter()
- cmd := cmdObj.GetCmd()
- cmd.Stdout = cmdWriter
- cmd.Stderr = cmdWriter
-
- if err := cmd.Run(); err != nil {
- if _, err := cmd.Stdout.Write([]byte(fmt.Sprintf("%s\n", style.FgRed.Sprint(err.Error())))); err != nil {
- gui.Log.Error(err)
- }
- _ = gui.refreshSidePanels(refreshOptions{mode: ASYNC})
- return gui.surfaceError(
- fmt.Errorf(
- gui.Tr.GitCommandFailed, gui.UserConfig.Keybinding.Universal.ExtrasMenu,
- ),
- )
- }
-
- if onSuccess != nil {
- if err := onSuccess(); err != nil {
- return err
- }
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
- })
-}
diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go
index 375f7c0d4..d7103914d 100644
--- a/pkg/gui/gui.go
+++ b/pkg/gui/gui.go
@@ -5,12 +5,12 @@ import (
"io/ioutil"
"log"
"os"
- "sync"
-
"strings"
+ "sync"
"time"
"github.com/jesseduffield/gocui"
+ appTypes "github.com/jesseduffield/lazygit/pkg/app/types"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/git_config"
@@ -18,21 +18,26 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
- "github.com/jesseduffield/lazygit/pkg/gui/filetree"
- "github.com/jesseduffield/lazygit/pkg/gui/lbl"
- "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking"
"github.com/jesseduffield/lazygit/pkg/gui/modes/diffing"
"github.com/jesseduffield/lazygit/pkg/gui/modes/filtering"
+ "github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/graph"
+ "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
+ "github.com/jesseduffield/lazygit/pkg/gui/services/custom_commands"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
+ integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/updates"
"github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/sasha-s/go-deadlock"
"gopkg.in/ozeidan/fuzzy-patricia.v3/patricia"
)
@@ -54,13 +59,13 @@ const StartupPopupVersion = 5
var OverlappingEdges = false
type ContextManager struct {
- ContextStack []Context
+ ContextStack []types.Context
sync.RWMutex
}
-func NewContextManager(initialContext Context) ContextManager {
+func NewContextManager(initialContext types.Context) ContextManager {
return ContextManager{
- ContextStack: []Context{initialContext},
+ ContextStack: []types.Context{initialContext},
RWMutex: sync.RWMutex{},
}
}
@@ -70,30 +75,35 @@ type Repo string
// Gui wraps the gocui Gui object which handles rendering and events
type Gui struct {
*common.Common
- g *gocui.Gui
- Git *commands.GitCommand
- OSCommand *oscommands.OSCommand
+ g *gocui.Gui
+ git *commands.GitCommand
+ os *oscommands.OSCommand
// this is the state of the GUI for the current repo
- State *guiState
+ State *GuiRepoState
+
+ CustomCommandsClient *custom_commands.Client
// this is a mapping of repos to gui states, so that we can restore the original
// gui state when returning from a subrepo
- RepoStateMap map[Repo]*guiState
+ RepoStateMap map[Repo]*GuiRepoState
Config config.AppConfigurer
Updater *updates.Updater
statusManager *statusManager
- credentials credentials
waitForIntro sync.WaitGroup
fileWatcher *fileWatcher
viewBufferManagerMap map[string]*tasks.ViewBufferManager
- stopChan chan struct{}
+ // holds a mapping of view names to ptmx's. This is for rendering command outputs
+ // from within a pty. The point of keeping track of them is so that if we re-size
+ // the window, we can tell the pty it needs to resize accordingly.
+ viewPtmxMap map[string]*os.File
+ stopChan chan struct{}
// when lazygit is opened outside a git directory we want to open to the most
// recent repo with the recent repos popup showing
showRecentRepos bool
- Mutexes guiMutexes
+ Mutexes types.Mutexes
// findSuggestions will take a string that the user has typed into a prompt
// and return a slice of suggestions which match that string.
@@ -101,7 +111,7 @@ type Gui struct {
// when you enter into a submodule we'll append the superproject's path to this array
// so that you can return to the superproject
- RepoPathStack []string
+ RepoPathStack *utils.StringStack
// this tells us whether our views have been initially set up
ViewsSetup bool
@@ -121,142 +131,74 @@ type Gui struct {
suggestionsAsyncHandler *tasks.AsyncHandler
- PopupHandler PopupHandler
+ PopupHandler types.IPopupHandler
IsNewRepo bool
+
+ // flag as to whether or not the diff view should ignore whitespace
+ IgnoreWhitespaceInDiffView bool
+
+ // we use this to decide whether we'll return to the original directory that
+ // lazygit was opened in, or if we'll retain the one we're currently in.
+ RetainOriginalDir bool
+
+ PrevLayout PrevLayout
+
+ // this is the initial dir we are in upon opening lazygit. We hold onto this
+ // in case we want to restore it before quitting for users who have set up
+ // the feature for changing directory upon quit.
+ // The reason we don't just wait until quit time to handle changing directories
+ // is because some users want to keep track of the current lazygit directory in an outside
+ // process
+ InitialDir string
+
+ c *types.HelperCommon
+ helpers *helpers.Helpers
}
-type listPanelState struct {
- SelectedLineIdx int
+// we keep track of some stuff from one render to the next to see if certain
+// things have changed
+type PrevLayout struct {
+ Information string
+ MainWidth int
+ MainHeight int
}
-func (h *listPanelState) SetSelectedLineIdx(value int) {
- h.SelectedLineIdx = value
-}
+type GuiRepoState struct {
+ Model *types.Model
+ Modes *types.Modes
-func (h *listPanelState) GetSelectedLineIdx() int {
- return h.SelectedLineIdx
-}
+ // Suggestions will sometimes appear when typing into a prompt
+ Suggestions []*types.Suggestion
-// for now the staging panel state, unlike the other panel states, is going to be
-// non-mutative, so that we don't accidentally end up
-// with mismatches of data. We might change this in the future
-type LblPanelState struct {
- *lbl.State
- SecondaryFocused bool // this is for if we show the left or right panel
-}
+ Updating bool
+ SplitMainPanel bool
+ LimitCommits bool
-type MergingPanelState struct {
- *mergeconflicts.State
+ IsRefreshingFiles bool
+ Searching searchingState
+ StartupStage StartupStage // Allows us to not load everything at once
- // UserVerticalScrolling tells us if the user has started scrolling through the file themselves
- // in which case we won't auto-scroll to a conflict.
- UserVerticalScrolling bool
-}
+ ContextManager ContextManager
+ Contexts *context.ContextTree
-type filePanelState struct {
- listPanelState
-}
+ // WindowViewNameMap is a mapping of windows to the current view of that window.
+ // Some views move between windows for example the commitFiles view and when cycling through
+ // side windows we need to know which view to give focus to for a given window
+ WindowViewNameMap map[string]string
-// TODO: consider splitting this out into the window and the branches view
-type branchPanelState struct {
- listPanelState
-}
+ // tells us whether we've set up our views for the current repo. We'll need to
+ // do this whenever we switch back and forth between repos to get the views
+ // back in sync with the repo state
+ ViewsSetup bool
-type remotePanelState struct {
- listPanelState
-}
+ // we store a commit message in this field if we've escaped the commit message
+ // panel without committing or if our commit failed
+ savedCommitMessage string
-type remoteBranchesState struct {
- listPanelState
-}
+ ScreenMode WindowMaximisation
-type tagsPanelState struct {
- listPanelState
-}
-
-type commitPanelState struct {
- listPanelState
-
- LimitCommits bool
-}
-
-type reflogCommitPanelState struct {
- listPanelState
-}
-
-type subCommitPanelState struct {
- listPanelState
-
- // e.g. name of branch whose commits we're looking at
- refName string
-}
-
-type stashPanelState struct {
- listPanelState
-}
-
-type menuPanelState struct {
- listPanelState
- OnPress func() error
-}
-
-type commitFilesPanelState struct {
- listPanelState
-
- // this is the SHA of the commit or the stash index of the stash.
- // Not sure if ref is actually the right word here
- refName string
- canRebase bool
-}
-
-type submodulePanelState struct {
- listPanelState
-}
-
-type suggestionsPanelState struct {
- listPanelState
-}
-
-type panelStates struct {
- Files *filePanelState
- Branches *branchPanelState
- Remotes *remotePanelState
- RemoteBranches *remoteBranchesState
- Tags *tagsPanelState
- Commits *commitPanelState
- ReflogCommits *reflogCommitPanelState
- SubCommits *subCommitPanelState
- Stash *stashPanelState
- Menu *menuPanelState
- LineByLine *LblPanelState
- Merging *MergingPanelState
- CommitFiles *commitFilesPanelState
- Submodules *submodulePanelState
- Suggestions *suggestionsPanelState
-}
-
-type Views struct {
- Status *gocui.View
- Files *gocui.View
- Branches *gocui.View
- Commits *gocui.View
- Stash *gocui.View
- Main *gocui.View
- Secondary *gocui.View
- Options *gocui.View
- Confirmation *gocui.View
- Menu *gocui.View
- Credentials *gocui.View
- CommitMessage *gocui.View
- CommitFiles *gocui.View
- Information *gocui.View
- AppStatus *gocui.View
- Search *gocui.View
- SearchPrefix *gocui.View
- Limit *gocui.View
- Suggestions *gocui.View
- Extras *gocui.View
+ CurrentPopupOpts *types.CreatePopupPanelOpts
}
type searchingState struct {
@@ -273,89 +215,27 @@ const (
COMPLETE
)
-type Modes struct {
- Filtering filtering.Filtering
- CherryPicking cherrypicking.CherryPicking
- Diffing diffing.Diffing
-}
+func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, reuseState bool) error {
+ var err error
+ gui.git, err = commands.NewGitCommand(
+ gui.Common,
+ gui.os,
+ git_config.NewStdCachedGitConfig(gui.Log),
+ gui.Mutexes.SyncMutex,
+ )
+ if err != nil {
+ return err
+ }
-type guiMutexes struct {
- RefreshingFilesMutex sync.Mutex
- RefreshingStatusMutex sync.Mutex
- FetchMutex sync.Mutex
- BranchCommitsMutex sync.Mutex
- LineByLinePanelMutex sync.Mutex
- SubprocessMutex sync.Mutex
-}
+ gui.resetState(startArgs, reuseState)
-type guiState struct {
- // the file panels (files and commit files) can render as a tree, so we have
- // managers for them which handle rendering a flat list of files in tree form
- FileTreeViewModel *filetree.FileTreeViewModel
- CommitFileTreeViewModel *filetree.CommitFileTreeViewModel
+ gui.resetControllers()
- Submodules []*models.SubmoduleConfig
- Branches []*models.Branch
- Commits []*models.Commit
- StashEntries []*models.StashEntry
- // Suggestions will sometimes appear when typing into a prompt
- Suggestions []*types.Suggestion
- // FilteredReflogCommits are the ones that appear in the reflog panel.
- // when in filtering mode we only include the ones that match the given path
- FilteredReflogCommits []*models.Commit
- // ReflogCommits are the ones used by the branches panel to obtain recency values
- // if we're not in filtering mode, CommitFiles and FilteredReflogCommits will be
- // one and the same
- ReflogCommits []*models.Commit
- SubCommits []*models.Commit
- Remotes []*models.Remote
- RemoteBranches []*models.RemoteBranch
- Tags []*models.Tag
- MenuItems []*menuItem
- BisectInfo *git_commands.BisectInfo
- GithubState *GithubState
+ if err := gui.resetKeybindings(); err != nil {
+ return err
+ }
- Updating bool
- Panels *panelStates
- SplitMainPanel bool
- MainContext ContextKey // used to keep the main and secondary views' contexts in sync
- RetainOriginalDir bool
- IsRefreshingFiles bool
- Searching searchingState
- // if this is true, we'll load our commits using `git log --all`
- ShowWholeGitGraph bool
- ScreenMode WindowMaximisation
- Ptmx *os.File
- PrevMainWidth int
- PrevMainHeight int
- OldInformation string
- StartupStage StartupStage // Allows us to not load everything at once
-
- Modes Modes
-
- ContextManager ContextManager
- Contexts ContextTree
- ViewContextMap map[string]Context
- ViewTabContextMap map[string][]tabContext
-
- // WindowViewNameMap is a mapping of windows to the current view of that window.
- // Some views move between windows for example the commitFiles view and when cycling through
- // side windows we need to know which view to give focus to for a given window
- WindowViewNameMap map[string]string
-
- // tells us whether we've set up our views for the current repo. We'll need to
- // do this whenever we switch back and forth between repos to get the views
- // back in sync with the repo state
- ViewsSetup bool
-
- // flag as to whether or not the diff view should ignore whitespace
- IgnoreWhitespaceInDiffView bool
-
- // for displaying suggestions while typing in a file name
- FilesTrie *patricia.Trie
-
- // this is the message of the last failed commit attempt
- failedCommitMessage string
+ return nil
}
type GithubState struct {
@@ -371,7 +251,7 @@ type GithubState struct {
// it gets a bit confusing to land back in the status panel when visiting a repo
// you've already switched from. There's no doubt some easy way to make the UX
// optimal for all cases but I'm too lazy to think about what that is right now
-func (gui *Gui) resetState(filterPath string, reuseState bool) {
+func (gui *Gui) resetState(startArgs appTypes.StartArgs, reuseState bool) {
currentDir, err := os.Getwd()
if reuseState {
@@ -379,71 +259,84 @@ func (gui *Gui) resetState(filterPath string, reuseState bool) {
if state := gui.RepoStateMap[Repo(currentDir)]; state != nil {
gui.State = state
gui.State.ViewsSetup = false
+
+ // setting this to nil so we don't get stuck based on a popup that was
+ // previously opened
+ gui.Mutexes.PopupMutex.Lock()
+ gui.State.CurrentPopupOpts = nil
+ gui.Mutexes.PopupMutex.Unlock()
+
return
}
} else {
- gui.Log.Error(err)
+ gui.c.Log.Error(err)
}
}
- showTree := gui.UserConfig.Gui.ShowFileTree
+ contextTree := gui.contextTree()
- contexts := gui.contextTree()
+ initialContext := initialContext(contextTree, startArgs)
+ initialScreenMode := initialScreenMode(startArgs)
- screenMode := SCREEN_NORMAL
- initialContext := contexts.Files
- if filterPath != "" {
- screenMode = SCREEN_HALF
- initialContext = contexts.BranchCommits
- }
+ initialWindowViewNameMap := gui.initialWindowViewNameMap(contextTree)
- gui.State = &guiState{
- FileTreeViewModel: filetree.NewFileTreeViewModel(make([]*models.File, 0), gui.Log, showTree),
- CommitFileTreeViewModel: filetree.NewCommitFileTreeViewModel(make([]*models.CommitFile, 0), gui.Log, showTree),
- Commits: make([]*models.Commit, 0),
- FilteredReflogCommits: make([]*models.Commit, 0),
- ReflogCommits: make([]*models.Commit, 0),
- StashEntries: make([]*models.StashEntry, 0),
- BisectInfo: gui.Git.Bisect.GetInfo(),
- Panels: &panelStates{
- // TODO: work out why some of these are -1 and some are 0. Last time I checked there was a good reason but I'm less certain now
- Files: &filePanelState{listPanelState{SelectedLineIdx: -1}},
- Submodules: &submodulePanelState{listPanelState{SelectedLineIdx: -1}},
- Branches: &branchPanelState{listPanelState{SelectedLineIdx: 0}},
- Remotes: &remotePanelState{listPanelState{SelectedLineIdx: 0}},
- RemoteBranches: &remoteBranchesState{listPanelState{SelectedLineIdx: -1}},
- Tags: &tagsPanelState{listPanelState{SelectedLineIdx: -1}},
- Commits: &commitPanelState{listPanelState: listPanelState{SelectedLineIdx: 0}, LimitCommits: true},
- ReflogCommits: &reflogCommitPanelState{listPanelState{SelectedLineIdx: 0}},
- SubCommits: &subCommitPanelState{listPanelState: listPanelState{SelectedLineIdx: 0}, refName: ""},
- CommitFiles: &commitFilesPanelState{listPanelState: listPanelState{SelectedLineIdx: -1}, refName: ""},
- Stash: &stashPanelState{listPanelState{SelectedLineIdx: -1}},
- Menu: &menuPanelState{listPanelState: listPanelState{SelectedLineIdx: 0}, OnPress: nil},
- Suggestions: &suggestionsPanelState{listPanelState: listPanelState{SelectedLineIdx: 0}},
- Merging: &MergingPanelState{
- State: mergeconflicts.NewState(),
- UserVerticalScrolling: false,
- },
+ gui.State = &GuiRepoState{
+ Model: &types.Model{
+ CommitFiles: nil,
+ Files: make([]*models.File, 0),
+ Commits: make([]*models.Commit, 0),
+ StashEntries: make([]*models.StashEntry, 0),
+ FilteredReflogCommits: make([]*models.Commit, 0),
+ ReflogCommits: make([]*models.Commit, 0),
+ BisectInfo: git_commands.NewNullBisectInfo(),
+ FilesTrie: patricia.NewTrie(),
},
- GithubState: &GithubState{},
- Ptmx: nil,
- Modes: Modes{
- Filtering: filtering.New(filterPath),
+ Modes: &types.Modes{
+ Filtering: filtering.New(startArgs.FilterPath),
CherryPicking: cherrypicking.New(),
Diffing: diffing.New(),
},
- ViewContextMap: contexts.initialViewContextMap(),
- ViewTabContextMap: contexts.initialViewTabContextMap(),
- ScreenMode: screenMode,
+ ScreenMode: initialScreenMode,
// TODO: put contexts in the context manager
- ContextManager: NewContextManager(initialContext),
- Contexts: contexts,
- FilesTrie: patricia.NewTrie(),
+ ContextManager: NewContextManager(initialContext),
+ Contexts: contextTree,
+ WindowViewNameMap: initialWindowViewNameMap,
}
gui.RepoStateMap[Repo(currentDir)] = gui.State
}
+func initialScreenMode(startArgs appTypes.StartArgs) WindowMaximisation {
+ if startArgs.FilterPath != "" || startArgs.GitArg != appTypes.GitArgNone {
+ return SCREEN_HALF
+ } else {
+ return SCREEN_NORMAL
+ }
+}
+
+func initialContext(contextTree *context.ContextTree, startArgs appTypes.StartArgs) types.IListContext {
+ var initialContext types.IListContext = contextTree.Files
+
+ if startArgs.FilterPath != "" {
+ initialContext = contextTree.LocalCommits
+ } else if startArgs.GitArg != appTypes.GitArgNone {
+ switch startArgs.GitArg {
+ case appTypes.GitArgStatus:
+ initialContext = contextTree.Files
+ case appTypes.GitArgBranch:
+ initialContext = contextTree.Branches
+ case appTypes.GitArgLog:
+ initialContext = contextTree.LocalCommits
+ case appTypes.GitArgStash:
+ initialContext = contextTree.Stash
+ default:
+ panic("unhandled git arg")
+ }
+ }
+
+ return initialContext
+}
+
// for now the split view will always be on
// NewGui builds a new gui handler
func NewGui(
@@ -451,8 +344,8 @@ func NewGui(
config config.AppConfigurer,
gitConfig git_config.IGitConfig,
updater *updates.Updater,
- filterPath string,
showRecentRepos bool,
+ initialDir string,
) (*Gui, error) {
gui := &Gui{
Common: cmn,
@@ -460,9 +353,10 @@ func NewGui(
Updater: updater,
statusManager: &statusManager{},
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
+ viewPtmxMap: map[string]*os.File{},
showRecentRepos: showRecentRepos,
- RepoPathStack: []string{},
- RepoStateMap: map[Repo]*guiState{},
+ RepoPathStack: &utils.StringStack{},
+ RepoStateMap: map[Repo]*GuiRepoState{},
CmdLog: []string{},
suggestionsAsyncHandler: tasks.NewAsyncHandler(),
@@ -470,35 +364,54 @@ func NewGui(
// but now we do it via state. So we need to still support the config for the
// sake of backwards compatibility. We're making use of short circuiting here
ShowExtrasWindow: cmn.UserConfig.Gui.ShowCommandLog && !config.GetAppState().HideCommandLog,
+ Mutexes: types.Mutexes{
+ RefreshingFilesMutex: &deadlock.Mutex{},
+ RefreshingStatusMutex: &deadlock.Mutex{},
+ SyncMutex: &deadlock.Mutex{},
+ LocalCommitsMutex: &deadlock.Mutex{},
+ SubprocessMutex: &deadlock.Mutex{},
+ PopupMutex: &deadlock.Mutex{},
+ PtyMutex: &deadlock.Mutex{},
+ },
+ InitialDir: initialDir,
}
- guiIO := oscommands.NewGuiIO(
- cmn.Log,
- gui.logCommand,
- gui.getCmdWriter,
- gui.promptUserForCredential,
- )
-
- osCommand := oscommands.NewOSCommand(cmn, oscommands.GetPlatform(), guiIO)
-
- gui.OSCommand = osCommand
- var err error
- gui.Git, err = commands.NewGitCommand(
- cmn,
- osCommand,
- gitConfig,
- )
- if err != nil {
- return nil, err
- }
-
- gui.resetState(filterPath, false)
-
gui.watchFilesForChanges()
- gui.PopupHandler = &RealPopupHandler{gui: gui}
+ gui.PopupHandler = popup.NewPopupHandler(
+ cmn,
+ gui.createPopupPanel,
+ func() error { return gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) },
+ gui.popContext,
+ gui.currentContext,
+ gui.createMenu,
+ gui.withWaitingStatus,
+ gui.toast,
+ func() string { return gui.Views.Confirmation.TextArea.GetContent() },
+ )
+
+ guiCommon := &guiCommon{gui: gui, IPopupHandler: gui.PopupHandler}
+ helperCommon := &types.HelperCommon{IGuiCommon: guiCommon, Common: cmn}
+
+ credentialsHelper := helpers.NewCredentialsHelper(helperCommon)
+
+ guiIO := oscommands.NewGuiIO(
+ cmn.Log,
+ gui.LogCommand,
+ gui.getCmdWriter,
+ credentialsHelper.PromptUserForCredential,
+ )
+
+ osCommand := oscommands.NewOSCommand(cmn, config, oscommands.GetPlatform(), guiIO)
+
+ gui.os = osCommand
+
+ // storing this stuff on the gui for now to ease refactoring
+ // TODO: reset these controllers upon changing repos due to state changing
+ gui.c = helperCommon
authors.SetCustomAuthors(gui.UserConfig.Gui.AuthorColors)
+ icons.SetIconEnabled(gui.UserConfig.Gui.ShowIcons)
presentation.SetCustomBranches(gui.UserConfig.Gui.BranchColors)
return gui, nil
@@ -510,80 +423,146 @@ var RuneReplacements = map[rune]string{
graph.CommitSymbol: "o",
}
-// Run setup the gui with keybindings and start the mainloop
-func (gui *Gui) Run() error {
- recordEvents := recordingEvents()
+func (gui *Gui) initGocui(headless bool, test integrationTypes.IntegrationTest) (*gocui.Gui, error) {
+ recordEvents := RecordingEvents()
playMode := gocui.NORMAL
if recordEvents {
playMode = gocui.RECORDING
- } else if replaying() {
+ } else if Replaying() {
playMode = gocui.REPLAYING
+ } else if test != nil {
+ playMode = gocui.REPLAYING_NEW
}
- g, err := gocui.NewGui(gocui.OutputTrue, OverlappingEdges, playMode, headless(), RuneReplacements)
+ g, err := gocui.NewGui(gocui.OutputTrue, OverlappingEdges, playMode, headless, RuneReplacements)
+ if err != nil {
+ return nil, err
+ }
+
+ return g, nil
+}
+
+func (gui *Gui) viewTabMap() map[string][]context.TabView {
+ return map[string][]context.TabView{
+ "branches": {
+ {
+ Tab: gui.c.Tr.LocalBranchesTitle,
+ ViewName: "localBranches",
+ },
+ {
+ Tab: gui.c.Tr.RemotesTitle,
+ ViewName: "remotes",
+ },
+ {
+ Tab: gui.c.Tr.TagsTitle,
+ ViewName: "tags",
+ },
+ },
+ "commits": {
+ {
+ Tab: gui.c.Tr.CommitsTitle,
+ ViewName: "commits",
+ },
+ {
+ Tab: gui.c.Tr.ReflogCommitsTitle,
+ ViewName: "reflogCommits",
+ },
+ },
+ "files": {
+ {
+ Tab: gui.c.Tr.FilesTitle,
+ ViewName: "files",
+ },
+ {
+ Tab: gui.c.Tr.SubmodulesTitle,
+ ViewName: "submodules",
+ },
+ },
+ }
+}
+
+// Run: setup the gui with keybindings and start the mainloop
+func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
+ g, err := gui.initGocui(Headless(), startArgs.IntegrationTest)
if err != nil {
return err
}
- gui.g = g // TODO: always use gui.g rather than passing g around everywhere
- defer g.Close()
+ gui.g = g
+ defer gui.g.Close()
- if replaying() {
- g.RecordingConfig = gocui.RecordingConfig{
- Speed: getRecordingSpeed(),
- Leeway: 100,
- }
+ // if the deadlock package wants to report a deadlock, we first need to
+ // close the gui so that we can actually read what it prints.
+ deadlock.Opts.LogBuf = utils.NewOnceWriter(os.Stderr, func() {
+ gui.g.Close()
+ })
+ deadlock.Opts.Disable = !gui.Debug
- g.Recording, err = gui.loadRecording()
- if err != nil {
- return err
- }
+ gui.handleTestMode(startArgs.IntegrationTest)
- go utils.Safe(func() {
- time.Sleep(time.Second * 40)
- log.Fatal("40 seconds is up, lazygit recording took too long to complete")
- })
- }
-
- g.OnSearchEscape = gui.onSearchEscape
+ gui.g.OnSearchEscape = gui.onSearchEscape
if err := gui.Config.ReloadUserConfig(); err != nil {
return nil
}
userConfig := gui.UserConfig
- g.SearchEscapeKey = gui.getKey(userConfig.Keybinding.Universal.Return)
- g.NextSearchMatchKey = gui.getKey(userConfig.Keybinding.Universal.NextMatch)
- g.PrevSearchMatchKey = gui.getKey(userConfig.Keybinding.Universal.PrevMatch)
+ gui.g.SearchEscapeKey = keybindings.GetKey(userConfig.Keybinding.Universal.Return)
+ gui.g.NextSearchMatchKey = keybindings.GetKey(userConfig.Keybinding.Universal.NextMatch)
+ gui.g.PrevSearchMatchKey = keybindings.GetKey(userConfig.Keybinding.Universal.PrevMatch)
- g.ShowListFooter = userConfig.Gui.ShowListFooter
+ gui.g.ShowListFooter = userConfig.Gui.ShowListFooter
if userConfig.Gui.MouseEvents {
- g.Mouse = true
+ gui.g.Mouse = true
}
if err := gui.setColorScheme(); err != nil {
return err
}
- gui.waitForIntro.Add(1)
- if gui.UserConfig.Git.AutoFetch {
- go utils.Safe(gui.startBackgroundFetch)
+ gui.g.SetManager(gocui.ManagerFunc(gui.layout), gocui.ManagerFunc(gui.getFocusLayout()))
+
+ if err := gui.createAllViews(); err != nil {
+ return err
}
- gui.goEvery(time.Second*time.Duration(userConfig.Refresher.RefreshInterval), gui.stopChan, gui.refreshFilesAndSubmodules)
+ // onNewRepo must be called after g.SetManager because SetManager deletes keybindings
+ if err := gui.onNewRepo(startArgs, false); err != nil {
+ return err
+ }
- g.SetManager(gocui.ManagerFunc(gui.layout), gocui.ManagerFunc(gui.getFocusLayout()))
+ gui.waitForIntro.Add(1)
- gui.Log.Info("starting main loop")
+ if userConfig.Git.AutoFetch {
+ fetchInterval := userConfig.Refresher.FetchInterval
+ if fetchInterval > 0 {
+ go utils.Safe(gui.startBackgroundFetch)
+ } else {
+ gui.c.Log.Errorf(
+ "Value of config option 'refresher.fetchInterval' (%d) is invalid, disabling auto-fetch",
+ fetchInterval)
+ }
+ }
- err = g.MainLoop()
- return err
+ if userConfig.Git.AutoRefresh {
+ refreshInterval := userConfig.Refresher.RefreshInterval
+ if refreshInterval > 0 {
+ gui.goEvery(time.Second*time.Duration(refreshInterval), gui.stopChan, gui.refreshFilesAndSubmodules)
+ } else {
+ gui.c.Log.Errorf(
+ "Value of config option 'refresher.refreshInterval' (%d) is invalid, disabling auto-refresh",
+ refreshInterval)
+ }
+ }
+
+ gui.c.Log.Info("starting main loop")
+
+ return gui.g.MainLoop()
}
-// RunAndHandleError
-func (gui *Gui) RunAndHandleError() error {
+func (gui *Gui) RunAndHandleError(startArgs appTypes.StartArgs) error {
gui.stopChan = make(chan struct{})
return utils.SafeWithError(func() error {
- if err := gui.Run(); err != nil {
+ if err := gui.Run(startArgs); err != nil {
for _, manager := range gui.viewBufferManagerMap {
manager.Close()
}
@@ -596,13 +575,17 @@ func (gui *Gui) RunAndHandleError() error {
switch err {
case gocui.ErrQuit:
- if !gui.State.RetainOriginalDir {
+ if gui.RetainOriginalDir {
+ if err := gui.recordDirectory(gui.InitialDir); err != nil {
+ return err
+ }
+ } else {
if err := gui.recordCurrentDirectory(); err != nil {
return err
}
}
- if err := gui.saveRecording(gui.g.Recording); err != nil {
+ if err := SaveRecording(gui.g.Recording); err != nil {
return err
}
@@ -624,7 +607,7 @@ func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess oscommands.ICmdOb
return err
}
- if err := gui.refreshSidePanels(refreshOptions{mode: ASYNC}); err != nil {
+ if err := gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); err != nil {
return err
}
@@ -636,7 +619,7 @@ func (gui *Gui) runSubprocessWithSuspense(subprocess oscommands.ICmdObj) (bool,
gui.Mutexes.SubprocessMutex.Lock()
defer gui.Mutexes.SubprocessMutex.Unlock()
- if replaying() {
+ if Replaying() {
// we do not yet support running subprocesses within integration tests. So if
// we're replaying an integration test and we're inside this method, something
// has gone wrong, so we should fail
@@ -645,7 +628,7 @@ func (gui *Gui) runSubprocessWithSuspense(subprocess oscommands.ICmdObj) (bool,
}
if err := gui.g.Suspend(); err != nil {
- return false, gui.surfaceError(err)
+ return false, gui.c.Error(err)
}
gui.PauseBackgroundThreads = true
@@ -659,14 +642,14 @@ func (gui *Gui) runSubprocessWithSuspense(subprocess oscommands.ICmdObj) (bool,
gui.PauseBackgroundThreads = false
if cmdErr != nil {
- return false, gui.surfaceError(cmdErr)
+ return false, gui.c.Error(cmdErr)
}
return true, nil
}
func (gui *Gui) runSubprocess(cmdObj oscommands.ICmdObj) error { //nolint:unparam
- gui.logCommand(cmdObj.ToString(), true)
+ gui.LogCommand(cmdObj.ToString(), true)
subprocess := cmdObj.GetCmd()
subprocess.Stdout = os.Stdout
@@ -681,8 +664,10 @@ func (gui *Gui) runSubprocess(cmdObj oscommands.ICmdObj) error { //nolint:unpara
subprocess.Stderr = ioutil.Discard
subprocess.Stdin = nil
- fmt.Fprintf(os.Stdout, "\n%s\n", style.FgGreen.Sprint(gui.Tr.PressEnterToReturn))
- fmt.Scanln() // wait for enter press
+ if gui.Config.GetUserConfig().PromptToReturnFromSubprocess {
+ fmt.Fprintf(os.Stdout, "\n%s", style.FgGreen.Sprint(gui.Tr.PressEnterToReturn))
+ fmt.Scanln() // wait for enter press
+ }
return err
}
@@ -692,11 +677,11 @@ func (gui *Gui) loadNewRepo() error {
return err
}
- if err := gui.refreshSidePanels(refreshOptions{mode: ASYNC}); err != nil {
+ if err := gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); err != nil {
return err
}
- if err := gui.OSCommand.UpdateWindowTitle(); err != nil {
+ if err := gui.os.UpdateWindowTitle(); err != nil {
return err
}
@@ -712,7 +697,7 @@ func (gui *Gui) showInitialPopups(tasks []func(chan struct{}) error) {
task := task
go utils.Safe(func() {
if err := task(done); err != nil {
- _ = gui.surfaceError(err)
+ _ = gui.c.Error(err)
}
})
@@ -725,15 +710,15 @@ func (gui *Gui) showInitialPopups(tasks []func(chan struct{}) error) {
func (gui *Gui) showIntroPopupMessage(done chan struct{}) error {
onConfirm := func() error {
done <- struct{}{}
- gui.Config.GetAppState().StartupPopupVersion = StartupPopupVersion
- return gui.Config.SaveAppState()
+ gui.c.GetAppState().StartupPopupVersion = StartupPopupVersion
+ return gui.c.SaveAppState()
}
- return gui.ask(askOpts{
- title: "",
- prompt: gui.Tr.IntroPopupMessage,
- handleConfirm: onConfirm,
- handleClose: onConfirm,
+ return gui.c.Confirm(types.ConfirmOpts{
+ Title: "",
+ Prompt: gui.c.Tr.IntroPopupMessage,
+ HandleConfirm: onConfirm,
+ HandleClose: onConfirm,
})
}
@@ -764,10 +749,7 @@ func (gui *Gui) startBackgroundFetch() {
}
err := gui.backgroundFetch()
if err != nil && strings.Contains(err.Error(), "exit status 128") && isNew {
- _ = gui.ask(askOpts{
- title: gui.Tr.NoAutomaticGitFetchTitle,
- prompt: gui.Tr.NoAutomaticGitFetchBody,
- })
+ _ = gui.c.Alert(gui.c.Tr.NoAutomaticGitFetchTitle, gui.c.Tr.NoAutomaticGitFetchBody)
} else {
gui.goEvery(time.Second*time.Duration(userConfig.Refresher.FetchInterval), gui.stopChan, func() error {
err := gui.backgroundFetch()
@@ -790,22 +772,7 @@ func (gui *Gui) setColorScheme() error {
return nil
}
-func (gui *Gui) GetPr(branch *models.Branch) (*models.GithubPullRequest, bool, error) {
- prs, err := git_commands.GenerateGithubPullRequestMap(
- gui.State.GithubState.RecentPRs,
- []*models.Branch{branch},
- gui.State.Remotes,
- )
- if err != nil {
- return nil, false, err
- }
-
- pr, hasPr := prs[branch]
-
- return pr, hasPr, nil
-}
-
-func (gui *Gui) OnUIThread(f func() error) {
+func (gui *Gui) onUIThread(f func() error) {
gui.g.Update(func(*gocui.Gui) error {
return f()
})
diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go
new file mode 100644
index 000000000..835aa4f54
--- /dev/null
+++ b/pkg/gui/gui_common.go
@@ -0,0 +1,104 @@
+package gui
+
+import (
+ "errors"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// hacking this by including the gui struct for now until we split more things out
+type guiCommon struct {
+ gui *Gui
+ types.IPopupHandler
+}
+
+var _ types.IGuiCommon = &guiCommon{}
+
+func (self *guiCommon) LogAction(msg string) {
+ self.gui.LogAction(msg)
+}
+
+func (self *guiCommon) LogCommand(cmdStr string, isCommandLine bool) {
+ self.gui.LogCommand(cmdStr, isCommandLine)
+}
+
+func (self *guiCommon) Refresh(opts types.RefreshOptions) error {
+ return self.gui.Refresh(opts)
+}
+
+func (self *guiCommon) PostRefreshUpdate(context types.Context) error {
+ return self.gui.postRefreshUpdate(context)
+}
+
+func (self *guiCommon) RunSubprocessAndRefresh(cmdObj oscommands.ICmdObj) error {
+ return self.gui.runSubprocessWithSuspenseAndRefresh(cmdObj)
+}
+
+func (self *guiCommon) RunSubprocess(cmdObj oscommands.ICmdObj) (bool, error) {
+ return self.gui.runSubprocessWithSuspense(cmdObj)
+}
+
+func (self *guiCommon) PushContext(context types.Context, opts ...types.OnFocusOpts) error {
+ singleOpts := types.OnFocusOpts{}
+ if len(opts) > 0 {
+ // using triple dot but you should only ever pass one of these opt structs
+ if len(opts) > 1 {
+ return errors.New("cannot pass multiple opts to pushContext")
+ }
+
+ singleOpts = opts[0]
+ }
+
+ return self.gui.pushContext(context, singleOpts)
+}
+
+func (self *guiCommon) PopContext() error {
+ return self.gui.popContext()
+}
+
+func (self *guiCommon) CurrentContext() types.Context {
+ return self.gui.currentContext()
+}
+
+func (self *guiCommon) CurrentStaticContext() types.Context {
+ return self.gui.currentStaticContext()
+}
+
+func (self *guiCommon) IsCurrentContext(c types.Context) bool {
+ return self.CurrentContext().GetKey() == c.GetKey()
+}
+
+func (self *guiCommon) GetAppState() *config.AppState {
+ return self.gui.Config.GetAppState()
+}
+
+func (self *guiCommon) SaveAppState() error {
+ return self.gui.Config.SaveAppState()
+}
+
+func (self *guiCommon) Render() {
+ self.gui.render()
+}
+
+func (self *guiCommon) OpenSearch() {
+ _ = self.gui.handleOpenSearch(self.gui.currentViewName())
+}
+
+func (self *guiCommon) OnUIThread(f func() error) {
+ self.gui.onUIThread(f)
+}
+
+func (self *guiCommon) RenderToMainViews(opts types.RefreshMainOpts) error {
+ return self.gui.refreshMainViews(opts)
+}
+
+func (self *guiCommon) MainViewPairs() types.MainViewPairs {
+ return types.MainViewPairs{
+ Normal: self.gui.normalMainContextPair(),
+ Staging: self.gui.stagingMainContextPair(),
+ PatchBuilding: self.gui.patchBuildingMainContextPair(),
+ MergeConflicts: self.gui.mergingMainContextPair(),
+ }
+}
diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go
new file mode 100644
index 000000000..860c6c9b8
--- /dev/null
+++ b/pkg/gui/gui_driver.go
@@ -0,0 +1,73 @@
+package gui
+
+import (
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
+)
+
+// this gives our integration test a way of interacting with the gui for sending keypresses
+// and reading state.
+type GuiDriver struct {
+ gui *Gui
+}
+
+var _ integrationTypes.GuiDriver = &GuiDriver{}
+
+func (self *GuiDriver) PressKey(keyStr string) {
+ key := keybindings.GetKey(keyStr)
+
+ var r rune
+ var tcellKey tcell.Key
+ switch v := key.(type) {
+ case rune:
+ r = v
+ tcellKey = tcell.KeyRune
+ case gocui.Key:
+ tcellKey = tcell.Key(v)
+ }
+
+ self.gui.g.ReplayedEvents.Keys <- gocui.NewTcellKeyEventWrapper(
+ tcell.NewEventKey(tcellKey, r, tcell.ModNone),
+ 0,
+ )
+}
+
+func (self *GuiDriver) Keys() config.KeybindingConfig {
+ return self.gui.Config.GetUserConfig().Keybinding
+}
+
+func (self *GuiDriver) CurrentContext() types.Context {
+ return self.gui.c.CurrentContext()
+}
+
+func (self *GuiDriver) Model() *types.Model {
+ return self.gui.State.Model
+}
+
+func (self *GuiDriver) Fail(message string) {
+ self.gui.g.Close()
+ // need to give the gui time to close
+ time.Sleep(time.Millisecond * 100)
+ panic(message)
+}
+
+// logs to the normal place that you log to i.e. viewable with `lazygit --logs`
+func (self *GuiDriver) Log(message string) {
+ self.gui.c.Log.Warn(message)
+}
+
+// logs in the actual UI (in the commands panel)
+func (self *GuiDriver) LogUI(message string) {
+ self.gui.c.LogAction(message)
+}
+
+func (self *GuiDriver) CheckedOutRef() *models.Branch {
+ return self.gui.helpers.Refs.GetCheckedOutRef()
+}
diff --git a/pkg/gui/gui_test.go b/pkg/gui/gui_test.go
deleted file mode 100644
index e35ab1896..000000000
--- a/pkg/gui/gui_test.go
+++ /dev/null
@@ -1,81 +0,0 @@
-//go:build !windows
-// +build !windows
-
-package gui
-
-import (
- "fmt"
- "io"
- "io/ioutil"
- "os"
- "os/exec"
- "testing"
-
- "github.com/creack/pty"
- "github.com/jesseduffield/lazygit/pkg/integration"
- "github.com/stretchr/testify/assert"
-)
-
-// This file is quite similar to integration/main.go. The main difference is that this file is
-// run via `go test` whereas the other is run via `test/lazyintegration/main.go` which provides
-// a convenient gui wrapper around our integration tests. The `go test` approach is better
-// for CI and for running locally in the background to ensure you haven't broken
-// anything while making changes. If you want to visually see what's happening when a test is run,
-// you'll need to take the other approach
-//
-// As for this file, to run an integration test, e.g. for test 'commit', go:
-// go test pkg/gui/gui_test.go -run /commit
-//
-// To update a snapshot for an integration test, pass UPDATE_SNAPSHOTS=true
-// UPDATE_SNAPSHOTS=true go test pkg/gui/gui_test.go -run /commit
-//
-// integration tests are run in test/integration//actual and the final test does
-// not clean up that directory so you can cd into it to see for yourself what
-// happened when a test fails.
-//
-// To override speed, pass e.g. `SPEED=1` as an env var. Otherwise we start each test
-// at a high speed and then drop down to lower speeds upon each failure until finally
-// trying at the original playback speed (speed 1). A speed of 2 represents twice the
-// original playback speed. Speed may be a decimal.
-
-func Test(t *testing.T) {
- mode := integration.GetModeFromEnv()
- speedEnv := os.Getenv("SPEED")
- includeSkipped := os.Getenv("INCLUDE_SKIPPED") != ""
-
- err := integration.RunTests(
- t.Logf,
- runCmdHeadless,
- func(test *integration.Test, f func(*testing.T) error) {
- t.Run(test.Name, func(t *testing.T) {
- err := f(t)
- assert.NoError(t, err)
- })
- },
- mode,
- speedEnv,
- func(t *testing.T, expected string, actual string, prefix string) {
- assert.Equal(t, expected, actual, fmt.Sprintf("Unexpected %s. Expected:\n%s\nActual:\n%s\n", prefix, expected, actual))
- },
- includeSkipped,
- )
-
- assert.NoError(t, err)
-}
-
-func runCmdHeadless(cmd *exec.Cmd) error {
- cmd.Env = append(
- cmd.Env,
- "HEADLESS=true",
- "TERM=xterm",
- )
-
- f, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 100, Cols: 100})
- if err != nil {
- return err
- }
-
- _, _ = io.Copy(ioutil.Discard, f)
-
- return f.Close()
-}
diff --git a/pkg/gui/information_panel.go b/pkg/gui/information_panel.go
index d09f495c5..1577e3a2e 100644
--- a/pkg/gui/information_panel.go
+++ b/pkg/gui/information_panel.go
@@ -3,26 +3,38 @@ package gui
import (
"fmt"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/constants"
"github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/mattn/go-runewidth"
)
func (gui *Gui) informationStr() string {
- for _, mode := range gui.modeStatuses() {
- if mode.isActive() {
- return mode.description()
- }
+ if activeMode, ok := gui.getActiveMode(); ok {
+ return activeMode.description()
}
if gui.g.Mouse {
- donate := style.FgMagenta.SetUnderline().Sprint(gui.Tr.Donate)
- askQuestion := style.FgYellow.SetUnderline().Sprint(gui.Tr.AskQuestion)
+ donate := style.FgMagenta.SetUnderline().Sprint(gui.c.Tr.Donate)
+ askQuestion := style.FgYellow.SetUnderline().Sprint(gui.c.Tr.AskQuestion)
return fmt.Sprintf("%s %s %s", donate, askQuestion, gui.Config.GetVersion())
} else {
return gui.Config.GetVersion()
}
}
+func (gui *Gui) getActiveMode() (modeStatus, bool) {
+ return slices.Find(gui.modeStatuses(), func(mode modeStatus) bool {
+ return mode.isActive()
+ })
+}
+
+func (gui *Gui) isAnyModeActive() bool {
+ return slices.Some(gui.modeStatuses(), func(mode modeStatus) bool {
+ return mode.isActive()
+ })
+}
+
func (gui *Gui) handleInfoClick() error {
if !gui.g.Mouse {
return nil
@@ -33,20 +45,18 @@ func (gui *Gui) handleInfoClick() error {
cx, _ := view.Cursor()
width, _ := view.Size()
- for _, mode := range gui.modeStatuses() {
- if mode.isActive() {
- if width-cx > len(gui.Tr.ResetInParentheses) {
- return nil
- }
- return mode.reset()
+ if activeMode, ok := gui.getActiveMode(); ok {
+ if width-cx > runewidth.StringWidth(gui.c.Tr.ResetInParentheses) {
+ return nil
}
+ return activeMode.reset()
}
// if we're not in an active mode we show the donate button
- if cx <= len(gui.Tr.Donate) {
- return gui.OSCommand.OpenLink(constants.Links.Donate)
- } else if cx <= len(gui.Tr.Donate)+1+len(gui.Tr.AskQuestion) {
- return gui.OSCommand.OpenLink(constants.Links.Discussions)
+ if cx <= runewidth.StringWidth(gui.c.Tr.Donate) {
+ return gui.os.OpenLink(constants.Links.Donate)
+ } else if cx <= runewidth.StringWidth(gui.c.Tr.Donate)+1+runewidth.StringWidth(gui.c.Tr.AskQuestion) {
+ return gui.os.OpenLink(constants.Links.Discussions)
}
return nil
}
diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go
index 93d956061..8982a3056 100644
--- a/pkg/gui/keybindings.go
+++ b/pkg/gui/keybindings.go
@@ -1,1854 +1,427 @@
package gui
import (
- "fmt"
"log"
- "strings"
-
- "unicode/utf8"
"github.com/jesseduffield/gocui"
- "github.com/jesseduffield/lazygit/pkg/constants"
+ "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
-// Binding - a keybinding mapping a key and modifier to a handler. The keypress
-// is only handled if the given view has focus, or handled globally if the view
-// is ""
-type Binding struct {
- ViewName string
- Contexts []string
- Handler func() error
- Key interface{} // FIXME: find out how to get `gocui.Key | rune`
- Modifier gocui.Modifier
- Description string
- Alternative string
- Tag string // e.g. 'navigation'. Used for grouping things in the cheatsheet
- OpensMenu bool
-}
-
-// GetDisplayStrings returns the display string of a file
-func (b *Binding) GetDisplayStrings(isFocused bool) []string {
- return []string{GetKeyDisplay(b.Key), b.Description}
-}
-
-var keyMapReversed = map[gocui.Key]string{
- gocui.KeyF1: "f1",
- gocui.KeyF2: "f2",
- gocui.KeyF3: "f3",
- gocui.KeyF4: "f4",
- gocui.KeyF5: "f5",
- gocui.KeyF6: "f6",
- gocui.KeyF7: "f7",
- gocui.KeyF8: "f8",
- gocui.KeyF9: "f9",
- gocui.KeyF10: "f10",
- gocui.KeyF11: "f11",
- gocui.KeyF12: "f12",
- gocui.KeyInsert: "insert",
- gocui.KeyDelete: "delete",
- gocui.KeyHome: "home",
- gocui.KeyEnd: "end",
- gocui.KeyPgup: "pgup",
- gocui.KeyPgdn: "pgdown",
- gocui.KeyArrowUp: "â–˛",
- gocui.KeyArrowDown: "â–Ľ",
- gocui.KeyArrowLeft: "â—„",
- gocui.KeyArrowRight: "â–ş",
- gocui.KeyTab: "tab", // ctrl+i
- gocui.KeyBacktab: "shift+tab",
- gocui.KeyEnter: "enter", // ctrl+m
- gocui.KeyAltEnter: "alt+enter",
- gocui.KeyEsc: "esc", // ctrl+[, ctrl+3
- gocui.KeyBackspace: "backspace", // ctrl+h
- gocui.KeyCtrlSpace: "ctrl+space", // ctrl+~, ctrl+2
- gocui.KeyCtrlSlash: "ctrl+/", // ctrl+_
- gocui.KeySpace: "space",
- gocui.KeyCtrlA: "ctrl+a",
- gocui.KeyCtrlB: "ctrl+b",
- gocui.KeyCtrlC: "ctrl+c",
- gocui.KeyCtrlD: "ctrl+d",
- gocui.KeyCtrlE: "ctrl+e",
- gocui.KeyCtrlF: "ctrl+f",
- gocui.KeyCtrlG: "ctrl+g",
- gocui.KeyCtrlJ: "ctrl+j",
- gocui.KeyCtrlK: "ctrl+k",
- gocui.KeyCtrlL: "ctrl+l",
- gocui.KeyCtrlN: "ctrl+n",
- gocui.KeyCtrlO: "ctrl+o",
- gocui.KeyCtrlP: "ctrl+p",
- gocui.KeyCtrlQ: "ctrl+q",
- gocui.KeyCtrlR: "ctrl+r",
- gocui.KeyCtrlS: "ctrl+s",
- gocui.KeyCtrlT: "ctrl+t",
- gocui.KeyCtrlU: "ctrl+u",
- gocui.KeyCtrlV: "ctrl+v",
- gocui.KeyCtrlW: "ctrl+w",
- gocui.KeyCtrlX: "ctrl+x",
- gocui.KeyCtrlY: "ctrl+y",
- gocui.KeyCtrlZ: "ctrl+z",
- gocui.KeyCtrl4: "ctrl+4", // ctrl+\
- gocui.KeyCtrl5: "ctrl+5", // ctrl+]
- gocui.KeyCtrl6: "ctrl+6",
- gocui.KeyCtrl8: "ctrl+8",
-}
-
-var keymap = map[string]interface{}{
- "": gocui.KeyCtrlA,
- "": gocui.KeyCtrlB,
- "": gocui.KeyCtrlC,
- "": gocui.KeyCtrlD,
- "": gocui.KeyCtrlE,
- "": gocui.KeyCtrlF,
- "": gocui.KeyCtrlG,
- "": gocui.KeyCtrlH,
- "": gocui.KeyCtrlI,
- "": gocui.KeyCtrlJ,
- "": gocui.KeyCtrlK,
- "": gocui.KeyCtrlL,
- "": gocui.KeyCtrlM,
- "": gocui.KeyCtrlN,
- "": gocui.KeyCtrlO,
- "": gocui.KeyCtrlP,
- "": gocui.KeyCtrlQ,
- "": gocui.KeyCtrlR,
- "": gocui.KeyCtrlS,
- "": gocui.KeyCtrlT,
- "": gocui.KeyCtrlU,
- "": gocui.KeyCtrlV,
- "": gocui.KeyCtrlW,
- "": gocui.KeyCtrlX,
- "": gocui.KeyCtrlY,
- "": gocui.KeyCtrlZ,
- "": gocui.KeyCtrlTilde,
- "": gocui.KeyCtrl2,
- "": gocui.KeyCtrl3,
- "": gocui.KeyCtrl4,
- "": gocui.KeyCtrl5,
- "": gocui.KeyCtrl6,
- "": gocui.KeyCtrl7,
- "": gocui.KeyCtrl8,
- "": gocui.KeyCtrlSpace,
- "": gocui.KeyCtrlBackslash,
- "": gocui.KeyCtrlLsqBracket,
- "": gocui.KeyCtrlRsqBracket,
- "": gocui.KeyCtrlSlash,
- "": gocui.KeyCtrlUnderscore,
- "": gocui.KeyBackspace,
- "": gocui.KeyTab,
- "": gocui.KeyBacktab,
- "": gocui.KeyEnter,
- "": gocui.KeyAltEnter,
- "": gocui.KeyEsc,
- "": gocui.KeySpace,
- "": gocui.KeyF1,
- "": gocui.KeyF2,
- "": gocui.KeyF3,
- "": gocui.KeyF4,
- "": gocui.KeyF5,
- "": gocui.KeyF6,
- "": gocui.KeyF7,
- "": gocui.KeyF8,
- "": gocui.KeyF9,
- "": gocui.KeyF10,
- "": gocui.KeyF11,
- "": gocui.KeyF12,
- "": gocui.KeyInsert,
- "": gocui.KeyDelete,
- "": gocui.KeyHome,
- "": gocui.KeyEnd,
- "": gocui.KeyPgup,
- "": gocui.KeyPgdn,
- "": gocui.KeyArrowUp,
- "": gocui.KeyArrowDown,
- "": gocui.KeyArrowLeft,
- "": gocui.KeyArrowRight,
-}
-
-func (gui *Gui) getKeyDisplay(name string) string {
- key := gui.getKey(name)
- return GetKeyDisplay(key)
-}
-
-func GetKeyDisplay(key interface{}) string {
- keyInt := 0
-
- switch key := key.(type) {
- case rune:
- keyInt = int(key)
- case gocui.Key:
- value, ok := keyMapReversed[key]
- if ok {
- return value
+func (gui *Gui) noPopupPanel(f func() error) func() error {
+ return func() error {
+ if gui.popupPanelFocused() {
+ return nil
}
- keyInt = int(key)
+
+ return f()
+ }
+}
+
+// only to be called from the cheatsheet generate script. This mutates the Gui struct.
+func (self *Gui) GetCheatsheetKeybindings() []*types.Binding {
+ self.g = &gocui.Gui{}
+ if err := self.createAllViews(); err != nil {
+ panic(err)
+ }
+ // need to instantiate views
+ self.helpers = helpers.NewStubHelpers()
+ self.State = &GuiRepoState{}
+ self.State.Contexts = self.contextTree()
+ self.resetControllers()
+ bindings, _ := self.GetInitialKeybindings()
+ return bindings
+}
+
+// renaming receiver to 'self' to aid refactoring. Will probably end up moving all Gui handlers to this pattern eventually.
+func (self *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBinding) {
+ config := self.c.UserConfig.Keybinding
+
+ guards := types.KeybindingGuards{
+ OutsideFilterMode: self.outsideFilterMode,
+ NoPopupPanel: self.noPopupPanel,
}
- return fmt.Sprintf("%c", keyInt)
-}
-
-func (gui *Gui) getKey(key string) interface{} {
- runeCount := utf8.RuneCountInString(key)
- if runeCount > 1 {
- binding := keymap[strings.ToLower(key)]
- if binding == nil {
- log.Fatalf("Unrecognized key %s for keybinding. For permitted values see %s", strings.ToLower(key), constants.Links.Docs.CustomKeybindings)
- } else {
- return binding
- }
- } else if runeCount == 1 {
- return []rune(key)[0]
+ opts := types.KeybindingsOpts{
+ GetKey: keybindings.GetKey,
+ Config: config,
+ Guards: guards,
}
- log.Fatal("Key empty for keybinding: " + strings.ToLower(key))
- return nil
-}
-// GetInitialKeybindings is a function.
-func (gui *Gui) GetInitialKeybindings() []*Binding {
- config := gui.UserConfig.Keybinding
-
- bindings := []*Binding{
+ bindings := []*types.Binding{
{
ViewName: "",
- Key: gui.getKey(config.Universal.Quit),
+ Key: opts.GetKey(opts.Config.Universal.Quit),
Modifier: gocui.ModNone,
- Handler: gui.handleQuit,
+ Handler: self.handleQuit,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.QuitWithoutChangingDirectory),
+ Key: opts.GetKey(opts.Config.Universal.QuitWithoutChangingDirectory),
Modifier: gocui.ModNone,
- Handler: gui.handleQuitWithoutChangingDirectory,
+ Handler: self.handleQuitWithoutChangingDirectory,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.QuitAlt1),
+ Key: opts.GetKey(opts.Config.Universal.QuitAlt1),
Modifier: gocui.ModNone,
- Handler: gui.handleQuit,
+ Handler: self.handleQuit,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.Return),
+ Key: opts.GetKey(opts.Config.Universal.Return),
Modifier: gocui.ModNone,
- Handler: gui.handleTopLevelReturn,
+ Handler: self.handleTopLevelReturn,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.OpenRecentRepos),
- Handler: gui.handleCreateRecentReposMenu,
- Alternative: "",
- Description: gui.Tr.SwitchRepo,
+ Key: opts.GetKey(opts.Config.Universal.OpenRecentRepos),
+ Handler: self.handleCreateRecentReposMenu,
+ Description: self.c.Tr.SwitchRepo,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.ScrollUpMain),
- Handler: gui.scrollUpMain,
- Alternative: "fn+up",
- Description: gui.Tr.LcScrollUpMainPanel,
+ Key: opts.GetKey(opts.Config.Universal.ScrollUpMain),
+ Handler: self.scrollUpMain,
+ Alternative: "fn+up/shift+k",
+ Description: self.c.Tr.LcScrollUpMainPanel,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.ScrollDownMain),
- Handler: gui.scrollDownMain,
- Alternative: "fn+down",
- Description: gui.Tr.LcScrollDownMainPanel,
+ Key: opts.GetKey(opts.Config.Universal.ScrollDownMain),
+ Handler: self.scrollDownMain,
+ Alternative: "fn+down/shift+j",
+ Description: self.c.Tr.LcScrollDownMainPanel,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.ScrollUpMainAlt1),
+ Key: opts.GetKey(opts.Config.Universal.ScrollUpMainAlt1),
Modifier: gocui.ModNone,
- Handler: gui.scrollUpMain,
+ Handler: self.scrollUpMain,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.ScrollDownMainAlt1),
+ Key: opts.GetKey(opts.Config.Universal.ScrollDownMainAlt1),
Modifier: gocui.ModNone,
- Handler: gui.scrollDownMain,
+ Handler: self.scrollDownMain,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.ScrollUpMainAlt2),
+ Key: opts.GetKey(opts.Config.Universal.ScrollUpMainAlt2),
Modifier: gocui.ModNone,
- Handler: gui.scrollUpMain,
+ Handler: self.scrollUpMain,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.ScrollDownMainAlt2),
+ Key: opts.GetKey(opts.Config.Universal.ScrollDownMainAlt2),
Modifier: gocui.ModNone,
- Handler: gui.scrollDownMain,
+ Handler: self.scrollDownMain,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.CreateRebaseOptionsMenu),
- Handler: gui.handleCreateRebaseOptionsMenu,
- Description: gui.Tr.ViewMergeRebaseOptions,
+ Key: opts.GetKey(opts.Config.Universal.CreateRebaseOptionsMenu),
+ Handler: self.helpers.MergeAndRebase.CreateRebaseOptionsMenu,
+ Description: self.c.Tr.ViewMergeRebaseOptions,
OpensMenu: true,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.CreatePatchOptionsMenu),
- Handler: gui.handleCreatePatchOptionsMenu,
- Description: gui.Tr.ViewPatchOptions,
+ Key: opts.GetKey(opts.Config.Universal.CreatePatchOptionsMenu),
+ Handler: self.handleCreatePatchOptionsMenu,
+ Description: self.c.Tr.ViewPatchOptions,
OpensMenu: true,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.PushFiles),
- Handler: gui.pushFiles,
- Description: gui.Tr.LcPush,
+ Key: opts.GetKey(opts.Config.Universal.Refresh),
+ Handler: self.handleRefresh,
+ Description: self.c.Tr.LcRefresh,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.PullFiles),
- Handler: gui.handlePullFiles,
- Description: gui.Tr.LcPull,
- },
- {
- ViewName: "",
- Key: gui.getKey(config.Universal.Refresh),
- Handler: gui.handleRefresh,
- Description: gui.Tr.LcRefresh,
- },
- {
- ViewName: "",
- Key: gui.getKey(config.Universal.OptionMenu),
- Handler: gui.handleCreateOptionsMenu,
- Description: gui.Tr.LcOpenMenu,
+ Key: opts.GetKey(opts.Config.Universal.OptionMenu),
+ Handler: self.handleCreateOptionsMenu,
+ Description: self.c.Tr.LcOpenMenu,
OpensMenu: true,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.OptionMenuAlt1),
+ Key: opts.GetKey(opts.Config.Universal.OptionMenuAlt1),
Modifier: gocui.ModNone,
- Handler: gui.handleCreateOptionsMenu,
- },
- {
- ViewName: "",
- Key: gocui.MouseMiddle,
- Modifier: gocui.ModNone,
- Handler: gui.handleCreateOptionsMenu,
- },
- {
- ViewName: "",
- Key: gui.getKey(config.Universal.Undo),
- Handler: gui.reflogUndo,
- Description: gui.Tr.LcUndoReflog,
- },
- {
- ViewName: "",
- Key: gui.getKey(config.Universal.Redo),
- Handler: gui.reflogRedo,
- Description: gui.Tr.LcRedoReflog,
+ Handler: self.handleCreateOptionsMenu,
},
{
ViewName: "status",
- Key: gui.getKey(config.Universal.Edit),
- Handler: gui.handleEditConfig,
- Description: gui.Tr.EditConfig,
+ Key: opts.GetKey(opts.Config.Universal.Edit),
+ Handler: self.handleEditConfig,
+ Description: self.c.Tr.EditConfig,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.NextScreenMode),
- Handler: gui.nextScreenMode,
- Description: gui.Tr.LcNextScreenMode,
+ Key: opts.GetKey(opts.Config.Universal.NextScreenMode),
+ Handler: self.nextScreenMode,
+ Description: self.c.Tr.LcNextScreenMode,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.PrevScreenMode),
- Handler: gui.prevScreenMode,
- Description: gui.Tr.LcPrevScreenMode,
+ Key: opts.GetKey(opts.Config.Universal.PrevScreenMode),
+ Handler: self.prevScreenMode,
+ Description: self.c.Tr.LcPrevScreenMode,
},
{
ViewName: "status",
- Key: gui.getKey(config.Universal.OpenFile),
- Handler: gui.handleOpenConfig,
- Description: gui.Tr.OpenConfig,
+ Key: opts.GetKey(opts.Config.Universal.OpenFile),
+ Handler: self.handleOpenConfig,
+ Description: self.c.Tr.OpenConfig,
},
{
ViewName: "status",
- Key: gui.getKey(config.Status.CheckForUpdate),
- Handler: gui.handleCheckForUpdate,
- Description: gui.Tr.LcCheckForUpdate,
+ Key: opts.GetKey(opts.Config.Status.CheckForUpdate),
+ Handler: self.handleCheckForUpdate,
+ Description: self.c.Tr.LcCheckForUpdate,
},
{
ViewName: "status",
- Key: gui.getKey(config.Status.RecentRepos),
- Handler: gui.handleCreateRecentReposMenu,
- Description: gui.Tr.SwitchRepo,
+ Key: opts.GetKey(opts.Config.Status.RecentRepos),
+ Handler: self.handleCreateRecentReposMenu,
+ Description: self.c.Tr.SwitchRepo,
},
{
ViewName: "status",
- Key: gui.getKey(config.Status.AllBranchesLogGraph),
- Handler: gui.handleShowAllBranchLogs,
- Description: gui.Tr.LcAllBranchesLogGraph,
+ Key: opts.GetKey(opts.Config.Status.AllBranchesLogGraph),
+ Handler: self.handleShowAllBranchLogs,
+ Description: self.c.Tr.LcAllBranchesLogGraph,
},
{
ViewName: "files",
- Key: gui.getKey(""),
- Handler: gui.handleStatusFilterPressed,
- Description: gui.Tr.LcCommitFileFilter,
+ Key: opts.GetKey(opts.Config.Universal.CopyToClipboard),
+ Handler: self.handleCopySelectedSideContextItemToClipboard,
+ Description: self.c.Tr.LcCopyFileNameToClipboard,
},
{
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.CommitChanges),
- Handler: gui.handleCommitPress,
- Description: gui.Tr.CommitChanges,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.CommitChangesWithoutHook),
- Handler: gui.handleWIPCommitPress,
- Description: gui.Tr.LcCommitChangesWithoutHook,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.AmendLastCommit),
- Handler: gui.handleAmendCommitPress,
- Description: gui.Tr.AmendLastCommit,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.CommitChangesWithEditor),
- Handler: gui.handleCommitEditorPress,
- Description: gui.Tr.CommitChangesWithEditor,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Select),
- Handler: gui.handleFilePress,
- Description: gui.Tr.LcToggleStaged,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Remove),
- Handler: gui.handleCreateDiscardMenu,
- Description: gui.Tr.LcViewDiscardOptions,
- OpensMenu: true,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Edit),
- Handler: gui.handleFileEdit,
- Description: gui.Tr.LcEditFile,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.OpenFile),
- Handler: gui.handleFileOpen,
- Description: gui.Tr.LcOpenFile,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.IgnoreFile),
- Handler: gui.handleIgnoreFile,
- Description: gui.Tr.LcIgnoreFile,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.RefreshFiles),
- Handler: gui.handleRefreshFiles,
- Description: gui.Tr.LcRefreshFiles,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.StashAllChanges),
- Handler: gui.handleStashChanges,
- Description: gui.Tr.LcStashAllChanges,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.ViewStashOptions),
- Handler: gui.handleCreateStashMenu,
- Description: gui.Tr.LcViewStashOptions,
- OpensMenu: true,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.ToggleStagedAll),
- Handler: gui.handleStageAll,
- Description: gui.Tr.LcToggleStagedAll,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.ViewResetOptions),
- Handler: gui.handleCreateResetMenu,
- Description: gui.Tr.LcViewResetOptions,
- OpensMenu: true,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.GoInto),
- Handler: gui.handleEnterFile,
- Description: gui.Tr.FileEnter,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.Fetch),
- Handler: gui.handleGitFetch,
- Description: gui.Tr.LcFetch,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.CopyToClipboard),
- Handler: gui.handleCopySelectedSideContextItemToClipboard,
- Description: gui.Tr.LcCopyFileNameToClipboard,
- },
- {
- ViewName: "",
- Key: gui.getKey(config.Universal.ExecuteCustomCommand),
- Handler: gui.handleCustomCommand,
- Description: gui.Tr.LcExecuteCustomCommand,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.ViewResetOptions),
- Handler: gui.handleCreateResetToUpstreamMenu,
- Description: gui.Tr.LcViewResetToUpstreamOptions,
- OpensMenu: true,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.ToggleTreeView),
- Handler: gui.handleToggleFileTreeView,
- Description: gui.Tr.LcToggleTreeView,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.OpenMergeTool),
- Handler: gui.handleOpenMergeTool,
- Description: gui.Tr.LcOpenMergeTool,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Select),
- Handler: gui.handleBranchPress,
- Description: gui.Tr.LcCheckout,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.CreateOrShowPullRequest),
- Handler: gui.handleCreateOrShowPullRequestPress,
- Description: gui.Tr.LcCreateOrShowPullRequest,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.ViewPullRequestOptions),
- Handler: gui.handleCreateOrOpenPullRequestMenu,
- Description: gui.Tr.LcCreateOrOpenPullRequestOptions,
- OpensMenu: true,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.CopyPullRequestURL),
- Handler: gui.handleCopyPullRequestURLPress,
- Description: gui.Tr.LcCopyPullRequestURL,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.CheckoutBranchByName),
- Handler: gui.handleCheckoutByName,
- Description: gui.Tr.LcCheckoutByName,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.ForceCheckoutBranch),
- Handler: gui.handleForceCheckout,
- Description: gui.Tr.LcForceCheckout,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.New),
- Handler: gui.handleNewBranchOffCurrentItem,
- Description: gui.Tr.LcNewBranch,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Remove),
- Handler: gui.handleDeleteBranch,
- Description: gui.Tr.LcDeleteBranch,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.RebaseBranch),
- Handler: gui.handleRebaseOntoLocalBranch,
- Description: gui.Tr.LcRebaseBranch,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.MergeIntoCurrentBranch),
- Handler: gui.handleMerge,
- Description: gui.Tr.LcMergeIntoCurrentBranch,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.ViewGitFlowOptions),
- Handler: gui.handleCreateGitFlowMenu,
- Description: gui.Tr.LcGitFlowOptions,
- OpensMenu: true,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.FastForward),
- Handler: gui.handleFastForward,
- Description: gui.Tr.FastForward,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.ViewResetOptions),
- Handler: gui.handleCreateResetToBranchMenu,
- Description: gui.Tr.LcViewResetOptions,
- OpensMenu: true,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.RenameBranch),
- Handler: gui.handleRenameBranch,
- Description: gui.Tr.LcRenameBranch,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.CopyToClipboard),
- Handler: gui.handleCopySelectedSideContextItemToClipboard,
- Description: gui.Tr.LcCopyBranchNameToClipboard,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(LOCAL_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.GoInto),
- Handler: gui.handleSwitchToSubCommits,
- Description: gui.Tr.LcViewCommits,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(TAGS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Select),
- Handler: gui.withSelectedTag(gui.handleCheckoutTag),
- Description: gui.Tr.LcCheckout,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(TAGS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Remove),
- Handler: gui.withSelectedTag(gui.handleDeleteTag),
- Description: gui.Tr.LcDeleteTag,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(TAGS_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.PushTag),
- Handler: gui.withSelectedTag(gui.handlePushTag),
- Description: gui.Tr.LcPushTag,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(TAGS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.New),
- Handler: gui.handleCreateTag,
- Description: gui.Tr.LcCreateTag,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(TAGS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.ViewResetOptions),
- Handler: gui.withSelectedTag(gui.handleCreateResetToTagMenu),
- Description: gui.Tr.LcViewResetOptions,
- OpensMenu: true,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(TAGS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.GoInto),
- Handler: gui.handleSwitchToSubCommits,
- Description: gui.Tr.LcViewCommits,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTE_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Return),
- Handler: gui.handleRemoteBranchesEscape,
- Description: gui.Tr.ReturnToRemotesList,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTE_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.ViewResetOptions),
- Handler: gui.handleCreateResetToRemoteBranchMenu,
- Description: gui.Tr.LcViewResetOptions,
- OpensMenu: true,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTE_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.GoInto),
- Handler: gui.handleSwitchToSubCommits,
- Description: gui.Tr.LcViewCommits,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.FetchRemote),
- Handler: gui.handleFetchRemote,
- Description: gui.Tr.LcFetchRemote,
+ ViewName: "localBranches",
+ Key: opts.GetKey(opts.Config.Universal.CopyToClipboard),
+ Handler: self.handleCopySelectedSideContextItemToClipboard,
+ Description: self.c.Tr.LcCopyBranchNameToClipboard,
},
{
ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.OpenLogMenu),
- Handler: gui.handleOpenLogMenu,
- Description: gui.Tr.LcOpenLogMenu,
- OpensMenu: true,
+ Key: opts.GetKey(opts.Config.Universal.CopyToClipboard),
+ Handler: self.handleCopySelectedSideContextItemToClipboard,
+ Description: self.c.Tr.LcCopyCommitShaToClipboard,
},
{
ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.SquashDown),
- Handler: gui.handleCommitSquashDown,
- Description: gui.Tr.LcSquashDown,
+ Key: opts.GetKey(opts.Config.Commits.ResetCherryPick),
+ Handler: self.helpers.CherryPick.Reset,
+ Description: self.c.Tr.LcResetCherryPick,
},
{
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.RenameCommit),
- Handler: gui.handleRewordCommit,
- Description: gui.Tr.LcRewordCommit,
+ ViewName: "reflogCommits",
+ Key: opts.GetKey(opts.Config.Universal.CopyToClipboard),
+ Handler: self.handleCopySelectedSideContextItemToClipboard,
+ Description: self.c.Tr.LcCopyCommitShaToClipboard,
},
{
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.RenameCommitWithEditor),
- Handler: gui.handleRewordCommitEditor,
- Description: gui.Tr.LcRenameCommitEditor,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.ViewResetOptions),
- Handler: gui.handleCreateCommitResetMenu,
- Description: gui.Tr.LcResetToThisCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.MarkCommitAsFixup),
- Handler: gui.handleCommitFixup,
- Description: gui.Tr.LcFixupCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.CreateFixupCommit),
- Handler: gui.handleCreateFixupCommit,
- Description: gui.Tr.LcCreateFixupCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.SquashAboveCommits),
- Handler: gui.handleSquashAllAboveFixupCommits,
- Description: gui.Tr.LcSquashAboveCommits,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Remove),
- Handler: gui.handleCommitDelete,
- Description: gui.Tr.LcDeleteCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.MoveDownCommit),
- Handler: gui.handleCommitMoveDown,
- Description: gui.Tr.LcMoveDownCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.MoveUpCommit),
- Handler: gui.handleCommitMoveUp,
- Description: gui.Tr.LcMoveUpCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Edit),
- Handler: gui.handleCommitEdit,
- Description: gui.Tr.LcEditCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.AmendToCommit),
- Handler: gui.handleCommitAmendTo,
- Description: gui.Tr.LcAmendToCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.PickCommit),
- Handler: gui.handleCommitPick,
- Description: gui.Tr.LcPickCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.RevertCommit),
- Handler: gui.handleCommitRevert,
- Description: gui.Tr.LcRevertCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.CherryPickCopy),
- Handler: gui.handleCopyCommit,
- Description: gui.Tr.LcCherryPickCopy,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.CopyToClipboard),
- Handler: gui.handleCopySelectedSideContextItemToClipboard,
- Description: gui.Tr.LcCopyCommitShaToClipboard,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.CherryPickCopyRange),
- Handler: gui.handleCopyCommitRange,
- Description: gui.Tr.LcCherryPickCopyRange,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.PasteCommits),
- Handler: gui.HandlePasteCommits,
- Description: gui.Tr.LcPasteCommits,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.GoInto),
- Handler: gui.handleViewCommitFiles,
- Description: gui.Tr.LcViewCommitFiles,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.CheckoutCommit),
- Handler: gui.handleCheckoutCommit,
- Description: gui.Tr.LcCheckoutCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.New),
- Modifier: gocui.ModNone,
- Handler: gui.handleNewBranchOffCurrentItem,
- Description: gui.Tr.LcCreateNewBranchFromCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.TagCommit),
- Handler: gui.handleTagCommit,
- Description: gui.Tr.LcTagCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.ResetCherryPick),
- Handler: gui.exitCherryPickingMode,
- Description: gui.Tr.LcResetCherryPick,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.CopyCommitMessageToClipboard),
- Handler: gui.handleCopySelectedCommitMessageToClipboard,
- Description: gui.Tr.LcCopyCommitMessageToClipboard,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.OpenInBrowser),
- Handler: gui.handleOpenCommitInBrowser,
- Description: gui.Tr.LcOpenCommitInBrowser,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(BRANCH_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.ViewBisectOptions),
- Handler: gui.handleOpenBisectMenu,
- Description: gui.Tr.LcViewBisectOptions,
- OpensMenu: true,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(REFLOG_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.GoInto),
- Handler: gui.handleViewReflogCommitFiles,
- Description: gui.Tr.LcViewCommitFiles,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(REFLOG_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Select),
- Handler: gui.handleCheckoutReflogCommit,
- Description: gui.Tr.LcCheckoutCommit,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(REFLOG_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.ViewResetOptions),
- Handler: gui.handleCreateReflogResetMenu,
- Description: gui.Tr.LcViewResetOptions,
- OpensMenu: true,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(REFLOG_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.CherryPickCopy),
- Handler: gui.handleCopyCommit,
- Description: gui.Tr.LcCherryPickCopy,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(REFLOG_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.CherryPickCopyRange),
- Handler: gui.handleCopyCommitRange,
- Description: gui.Tr.LcCherryPickCopyRange,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(REFLOG_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.ResetCherryPick),
- Handler: gui.exitCherryPickingMode,
- Description: gui.Tr.LcResetCherryPick,
- },
- {
- ViewName: "commits",
- Contexts: []string{string(REFLOG_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.CopyToClipboard),
- Handler: gui.handleCopySelectedSideContextItemToClipboard,
- Description: gui.Tr.LcCopyCommitShaToClipboard,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(SUB_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.GoInto),
- Handler: gui.handleViewSubCommitFiles,
- Description: gui.Tr.LcViewCommitFiles,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(SUB_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Select),
- Handler: gui.handleCheckoutSubCommit,
- Description: gui.Tr.LcCheckoutCommit,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(SUB_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.ViewResetOptions),
- Handler: gui.handleCreateSubCommitResetMenu,
- Description: gui.Tr.LcViewResetOptions,
- OpensMenu: true,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(SUB_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.New),
- Handler: gui.handleNewBranchOffCurrentItem,
- Description: gui.Tr.LcNewBranch,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(SUB_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.CherryPickCopy),
- Handler: gui.handleCopyCommit,
- Description: gui.Tr.LcCherryPickCopy,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(SUB_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.CherryPickCopyRange),
- Handler: gui.handleCopyCommitRange,
- Description: gui.Tr.LcCherryPickCopyRange,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(SUB_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Commits.ResetCherryPick),
- Handler: gui.exitCherryPickingMode,
- Description: gui.Tr.LcResetCherryPick,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(SUB_COMMITS_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.CopyToClipboard),
- Handler: gui.handleCopySelectedSideContextItemToClipboard,
- Description: gui.Tr.LcCopyCommitShaToClipboard,
- },
- {
- ViewName: "stash",
- Key: gui.getKey(config.Universal.GoInto),
- Handler: gui.handleViewStashFiles,
- Description: gui.Tr.LcViewStashFiles,
- },
- {
- ViewName: "stash",
- Key: gui.getKey(config.Universal.Select),
- Handler: gui.handleStashApply,
- Description: gui.Tr.LcApply,
- },
- {
- ViewName: "stash",
- Key: gui.getKey(config.Stash.PopStash),
- Handler: gui.handleStashPop,
- Description: gui.Tr.LcPop,
- },
- {
- ViewName: "stash",
- Key: gui.getKey(config.Universal.Remove),
- Handler: gui.handleStashDrop,
- Description: gui.Tr.LcDrop,
- },
- {
- ViewName: "stash",
- Key: gui.getKey(config.Universal.New),
- Handler: gui.handleNewBranchOffCurrentItem,
- Description: gui.Tr.LcNewBranch,
- },
- {
- ViewName: "commitMessage",
- Key: gui.getKey(config.Universal.SubmitEditorText),
- Modifier: gocui.ModNone,
- Handler: gui.handleCommitConfirm,
- },
- {
- ViewName: "commitMessage",
- Key: gui.getKey(config.Universal.Return),
- Modifier: gocui.ModNone,
- Handler: gui.handleCommitClose,
- },
- {
- ViewName: "credentials",
- Key: gui.getKey(config.Universal.Confirm),
- Modifier: gocui.ModNone,
- Handler: gui.handleSubmitCredential,
- },
- {
- ViewName: "credentials",
- Key: gui.getKey(config.Universal.Return),
- Modifier: gocui.ModNone,
- Handler: gui.handleCloseCredentialsView,
- },
- {
- ViewName: "menu",
- Key: gui.getKey(config.Universal.Return),
- Handler: gui.handleMenuClose,
- Description: gui.Tr.LcCloseMenu,
+ ViewName: "subCommits",
+ Key: opts.GetKey(opts.Config.Universal.CopyToClipboard),
+ Handler: self.handleCopySelectedSideContextItemToClipboard,
+ Description: self.c.Tr.LcCopyCommitShaToClipboard,
},
{
ViewName: "information",
Key: gocui.MouseLeft,
Modifier: gocui.ModNone,
- Handler: gui.handleInfoClick,
+ Handler: self.handleInfoClick,
},
{
ViewName: "commitFiles",
- Key: gui.getKey(config.Universal.CopyToClipboard),
- Handler: gui.handleCopySelectedSideContextItemToClipboard,
- Description: gui.Tr.LcCopyCommitFileNameToClipboard,
- },
- {
- ViewName: "commitFiles",
- Key: gui.getKey(config.CommitFiles.CheckoutCommitFile),
- Handler: gui.handleCheckoutCommitFile,
- Description: gui.Tr.LcCheckoutCommitFile,
- },
- {
- ViewName: "commitFiles",
- Key: gui.getKey(config.Universal.Remove),
- Handler: gui.handleDiscardOldFileChange,
- Description: gui.Tr.LcDiscardOldFileChange,
- },
- {
- ViewName: "commitFiles",
- Key: gui.getKey(config.Universal.OpenFile),
- Handler: gui.handleOpenOldCommitFile,
- Description: gui.Tr.LcOpenFile,
- },
- {
- ViewName: "commitFiles",
- Key: gui.getKey(config.Universal.Edit),
- Handler: gui.handleEditCommitFile,
- Description: gui.Tr.LcEditFile,
- },
- {
- ViewName: "commitFiles",
- Key: gui.getKey(config.Universal.Select),
- Handler: gui.handleToggleFileForPatch,
- Description: gui.Tr.LcToggleAddToPatch,
- },
- {
- ViewName: "commitFiles",
- Key: gui.getKey(config.Universal.GoInto),
- Handler: gui.handleEnterCommitFile,
- Description: gui.Tr.LcEnterFile,
- },
- {
- ViewName: "commitFiles",
- Key: gui.getKey(config.Files.ToggleTreeView),
- Handler: gui.handleToggleCommitFileTreeView,
- Description: gui.Tr.LcToggleTreeView,
+ Key: opts.GetKey(opts.Config.Universal.CopyToClipboard),
+ Handler: self.handleCopySelectedSideContextItemToClipboard,
+ Description: self.c.Tr.LcCopyCommitFileNameToClipboard,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.FilteringMenu),
- Handler: gui.handleCreateFilteringMenuPanel,
- Description: gui.Tr.LcOpenFilteringMenu,
+ Key: opts.GetKey(opts.Config.Universal.FilteringMenu),
+ Handler: self.handleCreateFilteringMenuPanel,
+ Description: self.c.Tr.LcOpenFilteringMenu,
OpensMenu: true,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.DiffingMenu),
- Handler: gui.handleCreateDiffingMenuPanel,
- Description: gui.Tr.LcOpenDiffingMenu,
+ Key: opts.GetKey(opts.Config.Universal.DiffingMenu),
+ Handler: self.handleCreateDiffingMenuPanel,
+ Description: self.c.Tr.LcOpenDiffingMenu,
OpensMenu: true,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.DiffingMenuAlt),
- Handler: gui.handleCreateDiffingMenuPanel,
- Description: gui.Tr.LcOpenDiffingMenu,
+ Key: opts.GetKey(opts.Config.Universal.DiffingMenuAlt),
+ Handler: self.handleCreateDiffingMenuPanel,
+ Description: self.c.Tr.LcOpenDiffingMenu,
OpensMenu: true,
},
{
ViewName: "",
- Key: gui.getKey(config.Universal.ExtrasMenu),
- Handler: gui.handleCreateExtrasMenuPanel,
- Description: gui.Tr.LcOpenExtrasMenu,
+ Key: opts.GetKey(opts.Config.Universal.ExtrasMenu),
+ Handler: self.handleCreateExtrasMenuPanel,
+ Description: self.c.Tr.LcOpenExtrasMenu,
OpensMenu: true,
},
{
ViewName: "secondary",
Key: gocui.MouseWheelUp,
Modifier: gocui.ModNone,
- Handler: gui.scrollUpSecondary,
+ Handler: self.scrollUpSecondary,
},
{
ViewName: "secondary",
Key: gocui.MouseWheelDown,
Modifier: gocui.ModNone,
- Handler: gui.scrollDownSecondary,
- },
- {
- ViewName: "secondary",
- Contexts: []string{string(MAIN_NORMAL_CONTEXT_KEY)},
- Key: gocui.MouseLeft,
- Modifier: gocui.ModNone,
- Handler: gui.handleMouseDownSecondary,
+ Handler: self.scrollDownSecondary,
},
{
ViewName: "main",
- Contexts: []string{string(MAIN_NORMAL_CONTEXT_KEY)},
Key: gocui.MouseWheelDown,
- Handler: gui.scrollDownMain,
- Description: gui.Tr.ScrollDown,
+ Handler: self.scrollDownMain,
+ Description: self.c.Tr.ScrollDown,
Alternative: "fn+up",
},
{
ViewName: "main",
- Contexts: []string{string(MAIN_NORMAL_CONTEXT_KEY)},
Key: gocui.MouseWheelUp,
- Handler: gui.scrollUpMain,
- Description: gui.Tr.ScrollUp,
+ Handler: self.scrollUpMain,
+ Description: self.c.Tr.ScrollUp,
Alternative: "fn+down",
},
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_NORMAL_CONTEXT_KEY)},
- Key: gocui.MouseLeft,
- Modifier: gocui.ModNone,
- Handler: gui.handleMouseDownMain,
- },
{
ViewName: "secondary",
- Contexts: []string{string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gocui.MouseLeft,
- Modifier: gocui.ModNone,
- Handler: gui.handleTogglePanelClick,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Return),
- Handler: gui.handleStagingEscape,
- Description: gui.Tr.ReturnToFilesPanel,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Select),
- Handler: gui.handleToggleStagedSelection,
- Description: gui.Tr.StageSelection,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Remove),
- Handler: gui.handleResetSelection,
- Description: gui.Tr.ResetSelection,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.TogglePanel),
- Handler: gui.handleTogglePanel,
- Description: gui.Tr.TogglePanel,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Return),
- Handler: gui.handleEscapePatchBuildingPanel,
- Description: gui.Tr.ExitLineByLineMode,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.OpenFile),
- Handler: gui.handleOpenFileAtLine,
- Description: gui.Tr.LcOpenFile,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.PrevItem),
- Handler: gui.handleSelectPrevLine,
- Description: gui.Tr.PrevLine,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.NextItem),
- Handler: gui.handleSelectNextLine,
- Description: gui.Tr.NextLine,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.PrevItemAlt),
- Modifier: gocui.ModNone,
- Handler: gui.handleSelectPrevLine,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.NextItemAlt),
- Modifier: gocui.ModNone,
- Handler: gui.handleSelectNextLine,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
Key: gocui.MouseWheelUp,
Modifier: gocui.ModNone,
- Handler: gui.scrollUpMain,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gocui.MouseWheelDown,
- Modifier: gocui.ModNone,
- Handler: gui.scrollDownMain,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.PrevBlock),
- Handler: gui.handleSelectPrevHunk,
- Description: gui.Tr.PrevHunk,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.PrevBlockAlt),
- Modifier: gocui.ModNone,
- Handler: gui.handleSelectPrevHunk,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.NextBlock),
- Handler: gui.handleSelectNextHunk,
- Description: gui.Tr.NextHunk,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.NextBlockAlt),
- Modifier: gocui.ModNone,
- Handler: gui.handleSelectNextHunk,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.CopyToClipboard),
- Modifier: gocui.ModNone,
- Handler: gui.copySelectedToClipboard,
- Description: gui.Tr.LcCopySelectedTexToClipboard,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Edit),
- Handler: gui.handleLineByLineEdit,
- Description: gui.Tr.LcEditFile,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.OpenFile),
- Handler: gui.handleFileOpen,
- Description: gui.Tr.LcOpenFile,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.NextPage),
- Modifier: gocui.ModNone,
- Handler: gui.handleLineByLineNextPage,
- Description: gui.Tr.LcNextPage,
- Tag: "navigation",
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.PrevPage),
- Modifier: gocui.ModNone,
- Handler: gui.handleLineByLinePrevPage,
- Description: gui.Tr.LcPrevPage,
- Tag: "navigation",
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.GotoTop),
- Modifier: gocui.ModNone,
- Handler: gui.handleLineByLineGotoTop,
- Description: gui.Tr.LcGotoTop,
- Tag: "navigation",
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.GotoBottom),
- Modifier: gocui.ModNone,
- Handler: gui.handleLineByLineGotoBottom,
- Description: gui.Tr.LcGotoBottom,
- Tag: "navigation",
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.StartSearch),
- Handler: func() error { return gui.handleOpenSearch("main") },
- Description: gui.Tr.LcStartSearch,
- Tag: "navigation",
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Select),
- Handler: gui.handleToggleSelectionForPatch,
- Description: gui.Tr.ToggleSelectionForPatch,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Main.ToggleDragSelect),
- Handler: gui.handleToggleSelectRange,
- Description: gui.Tr.ToggleDragSelect,
- },
- // Alias 'V' -> 'v'
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Main.ToggleDragSelectAlt),
- Handler: gui.handleToggleSelectRange,
- Description: gui.Tr.ToggleDragSelect,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Main.ToggleSelectHunk),
- Handler: gui.handleToggleSelectHunk,
- Description: gui.Tr.ToggleSelectHunk,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gocui.MouseLeft,
- Modifier: gocui.ModNone,
- Handler: gui.handleLBLMouseDown,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gocui.MouseLeft,
- Modifier: gocui.ModMotion,
- Handler: gui.handleMouseDrag,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gocui.MouseWheelUp,
- Modifier: gocui.ModNone,
- Handler: gui.scrollUpMain,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gocui.MouseWheelDown,
- Modifier: gocui.ModNone,
- Handler: gui.scrollDownMain,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY), string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.ScrollLeft),
- Handler: gui.scrollLeftMain,
- Description: gui.Tr.LcScrollLeft,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_PATCH_BUILDING_CONTEXT_KEY), string(MAIN_STAGING_CONTEXT_KEY), string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.ScrollRight),
- Handler: gui.scrollRightMain,
- Description: gui.Tr.LcScrollRight,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.CommitChanges),
- Handler: gui.handleCommitPress,
- Description: gui.Tr.CommitChanges,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.CommitChangesWithoutHook),
- Handler: gui.handleWIPCommitPress,
- Description: gui.Tr.LcCommitChangesWithoutHook,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_STAGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.CommitChangesWithEditor),
- Handler: gui.handleCommitEditorPress,
- Description: gui.Tr.CommitChangesWithEditor,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Return),
- Handler: gui.handleEscapeMerge,
- Description: gui.Tr.ReturnToFilesPanel,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Files.OpenMergeTool),
- Handler: gui.handleOpenMergeTool,
- Description: gui.Tr.LcOpenMergeTool,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Select),
- Handler: gui.handlePickHunk,
- Description: gui.Tr.PickHunk,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Main.PickBothHunks),
- Handler: gui.handlePickAllHunks,
- Description: gui.Tr.PickAllHunks,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.PrevBlock),
- Handler: gui.handleSelectPrevConflict,
- Description: gui.Tr.PrevConflict,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.NextBlock),
- Handler: gui.handleSelectNextConflict,
- Description: gui.Tr.NextConflict,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.PrevItem),
- Handler: gui.handleSelectPrevConflictHunk,
- Description: gui.Tr.SelectPrevHunk,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.NextItem),
- Handler: gui.handleSelectNextConflictHunk,
- Description: gui.Tr.SelectNextHunk,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.PrevBlockAlt),
- Modifier: gocui.ModNone,
- Handler: gui.handleSelectPrevConflict,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.NextBlockAlt),
- Modifier: gocui.ModNone,
- Handler: gui.handleSelectNextConflict,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.PrevItemAlt),
- Modifier: gocui.ModNone,
- Handler: gui.handleSelectPrevConflictHunk,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.NextItemAlt),
- Modifier: gocui.ModNone,
- Handler: gui.handleSelectNextConflictHunk,
- },
- {
- ViewName: "main",
- Contexts: []string{string(MAIN_MERGING_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Undo),
- Handler: gui.handleMergeConflictUndo,
- Description: gui.Tr.LcUndo,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.GoInto),
- Modifier: gocui.ModNone,
- Handler: gui.handleRemoteEnter,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.New),
- Handler: gui.handleAddRemote,
- Description: gui.Tr.LcAddNewRemote,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Remove),
- Handler: gui.handleRemoveRemote,
- Description: gui.Tr.LcRemoveRemote,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Edit),
- Handler: gui.handleEditRemote,
- Description: gui.Tr.LcEditRemote,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTE_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Select),
- // gonna use the exact same handler as the 'n' keybinding because everybody wants this to happen when they checkout a remote branch
- Handler: gui.handleNewBranchOffCurrentItem,
- Description: gui.Tr.LcCheckout,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTE_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.New),
- Handler: gui.handleNewBranchOffCurrentItem,
- Description: gui.Tr.LcNewBranch,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTE_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.MergeIntoCurrentBranch),
- Handler: gui.handleMergeRemoteBranch,
- Description: gui.Tr.LcMergeIntoCurrentBranch,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTE_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Remove),
- Handler: gui.handleDeleteRemoteBranch,
- Description: gui.Tr.LcDeleteBranch,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTE_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.RebaseBranch),
- Handler: gui.handleRebaseOntoRemoteBranch,
- Description: gui.Tr.LcRebaseBranch,
- },
- {
- ViewName: "branches",
- Contexts: []string{string(REMOTE_BRANCHES_CONTEXT_KEY)},
- Key: gui.getKey(config.Branches.SetUpstream),
- Handler: gui.handleSetBranchUpstream,
- Description: gui.Tr.LcSetUpstream,
+ Handler: self.scrollUpSecondary,
},
{
ViewName: "status",
Key: gocui.MouseLeft,
Modifier: gocui.ModNone,
- Handler: gui.handleStatusClick,
+ Handler: self.handleStatusClick,
},
{
ViewName: "search",
- Key: gui.getKey(config.Universal.Confirm),
+ Key: opts.GetKey(opts.Config.Universal.Confirm),
Modifier: gocui.ModNone,
- Handler: gui.handleSearch,
+ Handler: self.handleSearch,
},
{
ViewName: "search",
- Key: gui.getKey(config.Universal.Return),
+ Key: opts.GetKey(opts.Config.Universal.Return),
Modifier: gocui.ModNone,
- Handler: gui.handleSearchEscape,
+ Handler: self.handleSearchEscape,
},
{
ViewName: "confirmation",
- Key: gui.getKey(config.Universal.PrevItem),
+ Key: opts.GetKey(opts.Config.Universal.PrevItem),
Modifier: gocui.ModNone,
- Handler: gui.scrollUpConfirmationPanel,
+ Handler: self.scrollUpConfirmationPanel,
},
{
ViewName: "confirmation",
- Key: gui.getKey(config.Universal.NextItem),
+ Key: opts.GetKey(opts.Config.Universal.NextItem),
Modifier: gocui.ModNone,
- Handler: gui.scrollDownConfirmationPanel,
+ Handler: self.scrollDownConfirmationPanel,
},
{
ViewName: "confirmation",
- Key: gui.getKey(config.Universal.PrevItemAlt),
+ Key: opts.GetKey(opts.Config.Universal.PrevItemAlt),
Modifier: gocui.ModNone,
- Handler: gui.scrollUpConfirmationPanel,
+ Handler: self.scrollUpConfirmationPanel,
},
{
ViewName: "confirmation",
- Key: gui.getKey(config.Universal.NextItemAlt),
+ Key: opts.GetKey(opts.Config.Universal.NextItemAlt),
Modifier: gocui.ModNone,
- Handler: gui.scrollDownConfirmationPanel,
+ Handler: self.scrollDownConfirmationPanel,
},
{
- ViewName: "menu",
- Key: gui.getKey(config.Universal.Select),
- Modifier: gocui.ModNone,
- Handler: gui.onMenuPress,
- },
- {
- ViewName: "menu",
- Key: gui.getKey(config.Universal.Confirm),
- Modifier: gocui.ModNone,
- Handler: gui.onMenuPress,
- },
- {
- ViewName: "menu",
- Key: gui.getKey(config.Universal.ConfirmAlt1),
- Modifier: gocui.ModNone,
- Handler: gui.onMenuPress,
+ ViewName: "submodules",
+ Key: opts.GetKey(opts.Config.Universal.CopyToClipboard),
+ Handler: self.handleCopySelectedSideContextItemToClipboard,
+ Description: self.c.Tr.LcCopySubmoduleNameToClipboard,
},
{
ViewName: "files",
- Contexts: []string{string(SUBMODULES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.CopyToClipboard),
- Handler: gui.handleCopySelectedSideContextItemToClipboard,
- Description: gui.Tr.LcCopySubmoduleNameToClipboard,
- },
- {
- ViewName: "files",
- Contexts: []string{string(SUBMODULES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.GoInto),
- Handler: gui.forSubmodule(gui.handleSubmoduleEnter),
- Description: gui.Tr.LcEnterSubmodule,
- },
- {
- ViewName: "files",
- Contexts: []string{string(SUBMODULES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Remove),
- Handler: gui.forSubmodule(gui.removeSubmodule),
- Description: gui.Tr.LcRemoveSubmodule,
- OpensMenu: true,
- },
- {
- ViewName: "files",
- Contexts: []string{string(SUBMODULES_CONTEXT_KEY)},
- Key: gui.getKey(config.Submodules.Update),
- Handler: gui.forSubmodule(gui.handleUpdateSubmodule),
- Description: gui.Tr.LcSubmoduleUpdate,
- },
- {
- ViewName: "files",
- Contexts: []string{string(SUBMODULES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.New),
- Handler: gui.handleAddSubmodule,
- Description: gui.Tr.LcAddSubmodule,
- },
- {
- ViewName: "files",
- Contexts: []string{string(SUBMODULES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.Edit),
- Handler: gui.forSubmodule(gui.handleEditSubmoduleUrl),
- Description: gui.Tr.LcEditSubmoduleUrl,
- },
- {
- ViewName: "files",
- Contexts: []string{string(SUBMODULES_CONTEXT_KEY)},
- Key: gui.getKey(config.Submodules.Init),
- Handler: gui.forSubmodule(gui.handleSubmoduleInit),
- Description: gui.Tr.LcInitSubmodule,
- },
- {
- ViewName: "files",
- Contexts: []string{string(SUBMODULES_CONTEXT_KEY)},
- Key: gui.getKey(config.Submodules.BulkMenu),
- Handler: gui.handleBulkSubmoduleActionsMenu,
- Description: gui.Tr.LcViewBulkSubmoduleOptions,
- OpensMenu: true,
- },
- {
- ViewName: "files",
- Contexts: []string{string(FILES_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.ToggleWhitespaceInDiffView),
- Handler: gui.toggleWhitespaceInDiffView,
- Description: gui.Tr.ToggleWhitespaceInDiffView,
- },
- {
- ViewName: "",
- Key: gui.getKey(config.Universal.IncreaseContextInDiffView),
- Handler: gui.IncreaseContextInDiffView,
- Description: gui.Tr.IncreaseContextInDiffView,
- },
- {
- ViewName: "",
- Key: gui.getKey(config.Universal.DecreaseContextInDiffView),
- Handler: gui.DecreaseContextInDiffView,
- Description: gui.Tr.DecreaseContextInDiffView,
+ Key: opts.GetKey(opts.Config.Universal.ToggleWhitespaceInDiffView),
+ Handler: self.toggleWhitespaceInDiffView,
+ Description: self.c.Tr.ToggleWhitespaceInDiffView,
},
{
ViewName: "extras",
Key: gocui.MouseWheelUp,
- Handler: gui.scrollUpExtra,
+ Handler: self.scrollUpExtra,
},
{
ViewName: "extras",
Key: gocui.MouseWheelDown,
- Handler: gui.scrollDownExtra,
- },
- {
- ViewName: "extras",
- Key: gui.getKey(config.Universal.ExtrasMenu),
- Handler: gui.handleCreateExtrasMenuPanel,
- Description: gui.Tr.LcOpenExtrasMenu,
- OpensMenu: true,
+ Handler: self.scrollDownExtra,
},
{
ViewName: "extras",
Tag: "navigation",
- Contexts: []string{string(COMMAND_LOG_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.PrevItemAlt),
+ Key: opts.GetKey(opts.Config.Universal.PrevItemAlt),
Modifier: gocui.ModNone,
- Handler: gui.scrollUpExtra,
+ Handler: self.scrollUpExtra,
},
{
ViewName: "extras",
Tag: "navigation",
- Contexts: []string{string(COMMAND_LOG_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.PrevItem),
+ Key: opts.GetKey(opts.Config.Universal.PrevItem),
Modifier: gocui.ModNone,
- Handler: gui.scrollUpExtra,
+ Handler: self.scrollUpExtra,
},
{
ViewName: "extras",
Tag: "navigation",
- Contexts: []string{string(COMMAND_LOG_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.NextItem),
+ Key: opts.GetKey(opts.Config.Universal.NextItem),
Modifier: gocui.ModNone,
- Handler: gui.scrollDownExtra,
+ Handler: self.scrollDownExtra,
},
{
ViewName: "extras",
Tag: "navigation",
- Contexts: []string{string(COMMAND_LOG_CONTEXT_KEY)},
- Key: gui.getKey(config.Universal.NextItemAlt),
+ Key: opts.GetKey(opts.Config.Universal.NextItemAlt),
Modifier: gocui.ModNone,
- Handler: gui.scrollDownExtra,
+ Handler: self.scrollDownExtra,
},
{
ViewName: "extras",
Tag: "navigation",
Key: gocui.MouseLeft,
Modifier: gocui.ModNone,
- Handler: gui.handleFocusCommandLog,
+ Handler: self.handleFocusCommandLog,
},
}
- for _, viewName := range []string{"status", "branches", "files", "commits", "commitFiles", "stash", "menu"} {
- bindings = append(bindings, []*Binding{
- {ViewName: viewName, Key: gui.getKey(config.Universal.PrevBlock), Modifier: gocui.ModNone, Handler: gui.previousSideWindow},
- {ViewName: viewName, Key: gui.getKey(config.Universal.NextBlock), Modifier: gocui.ModNone, Handler: gui.nextSideWindow},
- {ViewName: viewName, Key: gui.getKey(config.Universal.PrevBlockAlt), Modifier: gocui.ModNone, Handler: gui.previousSideWindow},
- {ViewName: viewName, Key: gui.getKey(config.Universal.NextBlockAlt), Modifier: gocui.ModNone, Handler: gui.nextSideWindow},
- {ViewName: viewName, Key: gui.getKey(config.Universal.PrevBlockAlt2), Modifier: gocui.ModNone, Handler: gui.previousSideWindow},
- {ViewName: viewName, Key: gui.getKey(config.Universal.NextBlockAlt2), Modifier: gocui.ModNone, Handler: gui.nextSideWindow},
+ mouseKeybindings := []*gocui.ViewMouseBinding{}
+ for _, c := range self.State.Contexts.Flatten() {
+ viewName := c.GetViewName()
+ for _, binding := range c.GetKeybindings(opts) {
+ // TODO: move all mouse keybindings into the mouse keybindings approach below
+ binding.ViewName = viewName
+ bindings = append(bindings, binding)
+ }
+
+ mouseKeybindings = append(mouseKeybindings, c.GetMouseKeybindings(opts)...)
+ }
+
+ for _, viewName := range []string{"status", "remotes", "tags", "localBranches", "remoteBranches", "files", "submodules", "reflogCommits", "commits", "commitFiles", "subCommits", "stash"} {
+ bindings = append(bindings, []*types.Binding{
+ {ViewName: viewName, Key: opts.GetKey(opts.Config.Universal.PrevBlock), Modifier: gocui.ModNone, Handler: self.previousSideWindow},
+ {ViewName: viewName, Key: opts.GetKey(opts.Config.Universal.NextBlock), Modifier: gocui.ModNone, Handler: self.nextSideWindow},
+ {ViewName: viewName, Key: opts.GetKey(opts.Config.Universal.PrevBlockAlt), Modifier: gocui.ModNone, Handler: self.previousSideWindow},
+ {ViewName: viewName, Key: opts.GetKey(opts.Config.Universal.NextBlockAlt), Modifier: gocui.ModNone, Handler: self.nextSideWindow},
+ {ViewName: viewName, Key: opts.GetKey(opts.Config.Universal.PrevBlockAlt2), Modifier: gocui.ModNone, Handler: self.previousSideWindow},
+ {ViewName: viewName, Key: opts.GetKey(opts.Config.Universal.NextBlockAlt2), Modifier: gocui.ModNone, Handler: self.nextSideWindow},
}...)
}
@@ -1859,57 +432,108 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
log.Fatal("Jump to block keybindings cannot be set. Exactly 5 keybindings must be supplied.")
} else {
for i, window := range windows {
- bindings = append(bindings, &Binding{
+ bindings = append(bindings, &types.Binding{
ViewName: "",
- Key: gui.getKey(config.Universal.JumpToBlock[i]),
+ Key: opts.GetKey(opts.Config.Universal.JumpToBlock[i]),
Modifier: gocui.ModNone,
- Handler: gui.goToSideWindow(window)})
+ Handler: self.goToSideWindow(window),
+ })
}
}
- for viewName := range gui.State.Contexts.initialViewTabContextMap() {
- bindings = append(bindings, []*Binding{
- {
- ViewName: viewName,
- Key: gui.getKey(config.Universal.NextTab),
- Handler: gui.handleNextTab,
- Description: gui.Tr.LcNextTab,
- Tag: "navigation",
- },
- {
- ViewName: viewName,
- Key: gui.getKey(config.Universal.PrevTab),
- Handler: gui.handlePrevTab,
- Description: gui.Tr.LcPrevTab,
- Tag: "navigation",
- },
- }...)
- }
+ bindings = append(bindings, []*types.Binding{
+ {
+ ViewName: "",
+ Key: opts.GetKey(opts.Config.Universal.NextTab),
+ Handler: self.handleNextTab,
+ Description: self.c.Tr.LcNextTab,
+ Tag: "navigation",
+ },
+ {
+ ViewName: "",
+ Key: opts.GetKey(opts.Config.Universal.PrevTab),
+ Handler: self.handlePrevTab,
+ Description: self.c.Tr.LcPrevTab,
+ Tag: "navigation",
+ },
+ }...)
- bindings = append(bindings, gui.getListContextKeyBindings()...)
-
- return bindings
+ return bindings, mouseKeybindings
}
-func (gui *Gui) keybindings() error {
- bindings := gui.GetCustomCommandKeybindings()
+func (gui *Gui) resetKeybindings() error {
+ gui.g.DeleteAllKeybindings()
- bindings = append(bindings, gui.GetInitialKeybindings()...)
+ bindings, mouseBindings := gui.GetInitialKeybindings()
+
+ // prepending because we want to give our custom keybindings precedence over default keybindings
+ customBindings, err := gui.CustomCommandsClient.GetCustomCommandKeybindings()
+ if err != nil {
+ log.Fatal(err)
+ }
+ bindings = append(customBindings, bindings...)
for _, binding := range bindings {
- if err := gui.g.SetKeybinding(binding.ViewName, binding.Contexts, binding.Key, binding.Modifier, gui.wrappedHandler(binding.Handler)); err != nil {
+ if err := gui.SetKeybinding(binding); err != nil {
return err
}
}
- for viewName := range gui.State.Contexts.initialViewTabContextMap() {
- viewName := viewName
- tabClickCallback := func(tabIndex int) error { return gui.onViewTabClick(viewName, tabIndex) }
-
- if err := gui.g.SetTabClickBinding(viewName, tabClickCallback); err != nil {
+ for _, binding := range mouseBindings {
+ if err := gui.SetMouseKeybinding(binding); err != nil {
return err
}
}
+ for _, values := range gui.viewTabMap() {
+ for _, value := range values {
+ viewName := value.ViewName
+ tabClickCallback := func(tabIndex int) error { return gui.onViewTabClick(gui.windowForView(viewName), tabIndex) }
+
+ if err := gui.g.SetTabClickBinding(viewName, tabClickCallback); err != nil {
+ return err
+ }
+ }
+ }
+
return nil
}
+
+func (gui *Gui) wrappedHandler(f func() error) func(g *gocui.Gui, v *gocui.View) error {
+ return func(g *gocui.Gui, v *gocui.View) error {
+ return f()
+ }
+}
+
+func (gui *Gui) SetKeybinding(binding *types.Binding) error {
+ handler := binding.Handler
+ // TODO: move all mouse-ey stuff into new mouse approach
+ if gocui.IsMouseKey(binding.Key) {
+ handler = func() error {
+ // we ignore click events on views that aren't popup panels, when a popup panel is focused
+ if gui.popupPanelFocused() && gui.currentViewName() != binding.ViewName {
+ return nil
+ }
+
+ return binding.Handler()
+ }
+ }
+
+ return gui.g.SetKeybinding(binding.ViewName, binding.Key, binding.Modifier, gui.wrappedHandler(handler))
+}
+
+// warning: mutates the binding
+func (gui *Gui) SetMouseKeybinding(binding *gocui.ViewMouseBinding) error {
+ baseHandler := binding.Handler
+ newHandler := func(opts gocui.ViewMouseBindingOpts) error {
+ // we ignore click events on views that aren't popup panels, when a popup panel is focused
+ if gui.popupPanelFocused() && gui.currentViewName() != binding.ViewName {
+ return nil
+ }
+
+ return baseHandler(opts)
+ }
+ binding.Handler = newHandler
+
+ return gui.g.SetViewClickBinding(binding)
+}
diff --git a/pkg/gui/keybindings/keybindings.go b/pkg/gui/keybindings/keybindings.go
new file mode 100644
index 000000000..a59180b56
--- /dev/null
+++ b/pkg/gui/keybindings/keybindings.go
@@ -0,0 +1,184 @@
+package keybindings
+
+import (
+ "fmt"
+ "log"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/constants"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+var keyMapReversed = map[gocui.Key]string{
+ gocui.KeyF1: "f1",
+ gocui.KeyF2: "f2",
+ gocui.KeyF3: "f3",
+ gocui.KeyF4: "f4",
+ gocui.KeyF5: "f5",
+ gocui.KeyF6: "f6",
+ gocui.KeyF7: "f7",
+ gocui.KeyF8: "f8",
+ gocui.KeyF9: "f9",
+ gocui.KeyF10: "f10",
+ gocui.KeyF11: "f11",
+ gocui.KeyF12: "f12",
+ gocui.KeyInsert: "insert",
+ gocui.KeyDelete: "delete",
+ gocui.KeyHome: "home",
+ gocui.KeyEnd: "end",
+ gocui.KeyPgup: "pgup",
+ gocui.KeyPgdn: "pgdown",
+ gocui.KeyArrowUp: "â–˛",
+ gocui.KeyArrowDown: "â–Ľ",
+ gocui.KeyArrowLeft: "â—„",
+ gocui.KeyArrowRight: "â–ş",
+ gocui.KeyTab: "tab", // ctrl+i
+ gocui.KeyBacktab: "shift+tab",
+ gocui.KeyEnter: "enter", // ctrl+m
+ gocui.KeyAltEnter: "alt+enter",
+ gocui.KeyEsc: "esc", // ctrl+[, ctrl+3
+ gocui.KeyBackspace: "backspace", // ctrl+h
+ gocui.KeyCtrlSpace: "ctrl+space", // ctrl+~, ctrl+2
+ gocui.KeyCtrlSlash: "ctrl+/", // ctrl+_
+ gocui.KeySpace: "space",
+ gocui.KeyCtrlA: "ctrl+a",
+ gocui.KeyCtrlB: "ctrl+b",
+ gocui.KeyCtrlC: "ctrl+c",
+ gocui.KeyCtrlD: "ctrl+d",
+ gocui.KeyCtrlE: "ctrl+e",
+ gocui.KeyCtrlF: "ctrl+f",
+ gocui.KeyCtrlG: "ctrl+g",
+ gocui.KeyCtrlJ: "ctrl+j",
+ gocui.KeyCtrlK: "ctrl+k",
+ gocui.KeyCtrlL: "ctrl+l",
+ gocui.KeyCtrlN: "ctrl+n",
+ gocui.KeyCtrlO: "ctrl+o",
+ gocui.KeyCtrlP: "ctrl+p",
+ gocui.KeyCtrlQ: "ctrl+q",
+ gocui.KeyCtrlR: "ctrl+r",
+ gocui.KeyCtrlS: "ctrl+s",
+ gocui.KeyCtrlT: "ctrl+t",
+ gocui.KeyCtrlU: "ctrl+u",
+ gocui.KeyCtrlV: "ctrl+v",
+ gocui.KeyCtrlW: "ctrl+w",
+ gocui.KeyCtrlX: "ctrl+x",
+ gocui.KeyCtrlY: "ctrl+y",
+ gocui.KeyCtrlZ: "ctrl+z",
+ gocui.KeyCtrl4: "ctrl+4", // ctrl+\
+ gocui.KeyCtrl5: "ctrl+5", // ctrl+]
+ gocui.KeyCtrl6: "ctrl+6",
+ gocui.KeyCtrl8: "ctrl+8",
+ gocui.MouseWheelUp: "mouse wheel â–˛",
+ gocui.MouseWheelDown: "mouse wheel â–Ľ",
+}
+
+var keyMap = map[string]types.Key{
+ "": gocui.KeyCtrlA,
+ "": gocui.KeyCtrlB,
+ "": gocui.KeyCtrlC,
+ "": gocui.KeyCtrlD,
+ "": gocui.KeyCtrlE,
+ "": gocui.KeyCtrlF,
+ "": gocui.KeyCtrlG,
+ "": gocui.KeyCtrlH,
+ "": gocui.KeyCtrlI,
+ "": gocui.KeyCtrlJ,
+ "": gocui.KeyCtrlK,
+ "": gocui.KeyCtrlL,
+ "": gocui.KeyCtrlM,
+ "": gocui.KeyCtrlN,
+ "": gocui.KeyCtrlO,
+ "": gocui.KeyCtrlP,
+ "": gocui.KeyCtrlQ,
+ "": gocui.KeyCtrlR,
+ "": gocui.KeyCtrlS,
+ "": gocui.KeyCtrlT,
+ "": gocui.KeyCtrlU,
+ "": gocui.KeyCtrlV,
+ "": gocui.KeyCtrlW,
+ "": gocui.KeyCtrlX,
+ "": gocui.KeyCtrlY,
+ "": gocui.KeyCtrlZ,
+ "": gocui.KeyCtrlTilde,
+ "": gocui.KeyCtrl2,
+ "": gocui.KeyCtrl3,
+ "": gocui.KeyCtrl4,
+ "": gocui.KeyCtrl5,
+ "": gocui.KeyCtrl6,
+ "": gocui.KeyCtrl7,
+ "": gocui.KeyCtrl8,
+ "": gocui.KeyCtrlSpace,
+ "": gocui.KeyCtrlBackslash,
+ "": gocui.KeyCtrlLsqBracket,
+ "": gocui.KeyCtrlRsqBracket,
+ "": gocui.KeyCtrlSlash,
+ "": gocui.KeyCtrlUnderscore,
+ "": gocui.KeyBackspace,
+ "": gocui.KeyTab,
+ "": gocui.KeyBacktab,
+ "": gocui.KeyEnter,
+ "": gocui.KeyAltEnter,
+ "": gocui.KeyEsc,
+ "": gocui.KeySpace,
+ "": gocui.KeyF1,
+ "": gocui.KeyF2,
+ "": gocui.KeyF3,
+ "": gocui.KeyF4,
+ "": gocui.KeyF5,
+ "": gocui.KeyF6,
+ "": gocui.KeyF7,
+ "": gocui.KeyF8,
+ "": gocui.KeyF9,
+ "": gocui.KeyF10,
+ "": gocui.KeyF11,
+ "": gocui.KeyF12,
+ "": gocui.KeyInsert,
+ "": gocui.KeyDelete,
+ "": gocui.KeyHome,
+ "": gocui.KeyEnd,
+ "": gocui.KeyPgup,
+ "": gocui.KeyPgdn,
+ "": gocui.KeyArrowUp,
+ "": gocui.KeyArrowDown,
+ "": gocui.KeyArrowLeft,
+ "": gocui.KeyArrowRight,
+}
+
+func Label(name string) string {
+ return LabelFromKey(GetKey(name))
+}
+
+func LabelFromKey(key types.Key) string {
+ keyInt := 0
+
+ switch key := key.(type) {
+ case rune:
+ keyInt = int(key)
+ case gocui.Key:
+ value, ok := keyMapReversed[key]
+ if ok {
+ return value
+ }
+ keyInt = int(key)
+ }
+
+ return fmt.Sprintf("%c", keyInt)
+}
+
+func GetKey(key string) types.Key {
+ runeCount := utf8.RuneCountInString(key)
+ if runeCount > 1 {
+ binding := keyMap[strings.ToLower(key)]
+ if binding == nil {
+ log.Fatalf("Unrecognized key %s for keybinding. For permitted values see %s", strings.ToLower(key), constants.Links.Docs.CustomKeybindings)
+ } else {
+ return binding
+ }
+ } else if runeCount == 1 {
+ return []rune(key)[0]
+ }
+ log.Fatal("Key empty for keybinding: " + strings.ToLower(key))
+ return nil
+}
diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go
index c5872654a..5777524f5 100644
--- a/pkg/gui/layout.go
+++ b/pkg/gui/layout.go
@@ -1,136 +1,20 @@
package gui
import (
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/theme"
)
const SEARCH_PREFIX = "search: "
-func (gui *Gui) createAllViews() error {
- viewNameMappings := []struct {
- viewPtr **gocui.View
- name string
- }{
- {viewPtr: &gui.Views.Status, name: "status"},
- {viewPtr: &gui.Views.Files, name: "files"},
- {viewPtr: &gui.Views.Branches, name: "branches"},
- {viewPtr: &gui.Views.Commits, name: "commits"},
- {viewPtr: &gui.Views.Stash, name: "stash"},
- {viewPtr: &gui.Views.CommitFiles, name: "commitFiles"},
- {viewPtr: &gui.Views.Main, name: "main"},
- {viewPtr: &gui.Views.Secondary, name: "secondary"},
- {viewPtr: &gui.Views.Options, name: "options"},
- {viewPtr: &gui.Views.AppStatus, name: "appStatus"},
- {viewPtr: &gui.Views.Information, name: "information"},
- {viewPtr: &gui.Views.Search, name: "search"},
- {viewPtr: &gui.Views.SearchPrefix, name: "searchPrefix"},
- {viewPtr: &gui.Views.CommitMessage, name: "commitMessage"},
- {viewPtr: &gui.Views.Credentials, name: "credentials"},
- {viewPtr: &gui.Views.Menu, name: "menu"},
- {viewPtr: &gui.Views.Suggestions, name: "suggestions"},
- {viewPtr: &gui.Views.Confirmation, name: "confirmation"},
- {viewPtr: &gui.Views.Limit, name: "limit"},
- {viewPtr: &gui.Views.Extras, name: "extras"},
- }
-
- var err error
- for _, mapping := range viewNameMappings {
- *mapping.viewPtr, err = gui.prepareView(mapping.name)
- if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
- return err
- }
- }
-
- gui.Views.Options.Frame = false
- gui.Views.Options.FgColor = theme.OptionsColor
-
- gui.Views.SearchPrefix.BgColor = gocui.ColorDefault
- gui.Views.SearchPrefix.FgColor = gocui.ColorGreen
- gui.Views.SearchPrefix.Frame = false
- gui.setViewContent(gui.Views.SearchPrefix, SEARCH_PREFIX)
-
- gui.Views.Stash.Title = gui.Tr.StashTitle
- gui.Views.Stash.FgColor = theme.GocuiDefaultTextColor
-
- gui.Views.Commits.Title = gui.Tr.CommitsTitle
- gui.Views.Commits.FgColor = theme.GocuiDefaultTextColor
-
- gui.Views.CommitFiles.Title = gui.Tr.CommitFiles
- gui.Views.CommitFiles.FgColor = theme.GocuiDefaultTextColor
-
- gui.Views.Branches.Title = gui.Tr.BranchesTitle
- gui.Views.Branches.FgColor = theme.GocuiDefaultTextColor
-
- gui.Views.Files.Highlight = true
- gui.Views.Files.Title = gui.Tr.FilesTitle
- gui.Views.Files.FgColor = theme.GocuiDefaultTextColor
-
- gui.Views.Secondary.Title = gui.Tr.DiffTitle
- gui.Views.Secondary.Wrap = true
- gui.Views.Secondary.FgColor = theme.GocuiDefaultTextColor
- gui.Views.Secondary.IgnoreCarriageReturns = true
-
- gui.Views.Main.Title = gui.Tr.DiffTitle
- gui.Views.Main.Wrap = true
- gui.Views.Main.FgColor = theme.GocuiDefaultTextColor
- gui.Views.Main.IgnoreCarriageReturns = true
-
- gui.Views.Limit.Title = gui.Tr.NotEnoughSpace
- gui.Views.Limit.Wrap = true
-
- gui.Views.Status.Title = gui.Tr.StatusTitle
- gui.Views.Status.FgColor = theme.GocuiDefaultTextColor
-
- gui.Views.Search.BgColor = gocui.ColorDefault
- gui.Views.Search.FgColor = gocui.ColorGreen
- gui.Views.Search.Frame = false
- gui.Views.Search.Editable = true
-
- gui.Views.AppStatus.BgColor = gocui.ColorDefault
- gui.Views.AppStatus.FgColor = gocui.ColorCyan
- gui.Views.AppStatus.Frame = false
- gui.Views.AppStatus.Visible = false
-
- gui.Views.CommitMessage.Visible = false
- gui.Views.CommitMessage.Title = gui.Tr.CommitMessage
- gui.Views.CommitMessage.FgColor = theme.GocuiDefaultTextColor
- gui.Views.CommitMessage.Editable = true
- gui.Views.CommitMessage.Editor = gocui.EditorFunc(gui.commitMessageEditor)
-
- gui.Views.Confirmation.Visible = false
-
- gui.Views.Credentials.Visible = false
- gui.Views.Credentials.Title = gui.Tr.CredentialsUsername
- gui.Views.Credentials.FgColor = theme.GocuiDefaultTextColor
- gui.Views.Credentials.Editable = true
-
- gui.Views.Suggestions.Visible = false
-
- gui.Views.Menu.Visible = false
-
- gui.Views.Information.BgColor = gocui.ColorDefault
- gui.Views.Information.FgColor = gocui.ColorGreen
- gui.Views.Information.Frame = false
-
- gui.Views.Extras.Title = gui.Tr.CommandLog
- gui.Views.Extras.FgColor = theme.GocuiDefaultTextColor
- gui.Views.Extras.Autoscroll = true
- gui.Views.Extras.Wrap = true
-
- gui.printCommandLogHeader()
-
- if _, err := gui.g.SetCurrentView(gui.defaultSideContext().GetViewName()); err != nil {
- return err
- }
-
- return nil
-}
-
// layout is called for every screen re-render e.g. when the screen is resized
func (gui *Gui) layout(g *gocui.Gui) error {
if !gui.ViewsSetup {
- if err := gui.createAllViews(); err != nil {
+ gui.printCommandLogHeader()
+
+ if _, err := gui.g.SetCurrentView(gui.defaultSideContext().GetViewName()); err != nil {
return err
}
}
@@ -138,15 +22,6 @@ func (gui *Gui) layout(g *gocui.Gui) error {
g.Highlight = true
width, height := g.Size()
- minimumHeight := 9
- minimumWidth := 10
- var err error
- _, err = g.SetView("limit", 0, 0, width-1, height-1, 0)
- if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
- return err
- }
- gui.Views.Limit.Visible = height < minimumHeight || width < minimumWidth
-
informationStr := gui.informationStr()
appStatus := gui.statusManager.getStatusString()
@@ -168,26 +43,30 @@ func (gui *Gui) layout(g *gocui.Gui) error {
}
}
- setViewFromDimensions := func(viewName string, windowName string, frame bool) (*gocui.View, error) {
+ // we assume that the view has already been created.
+ setViewFromDimensions := func(viewName string, windowName string) (*gocui.View, error) {
dimensionsObj, ok := viewDimensions[windowName]
+ view, err := g.View(viewName)
+ if err != nil {
+ return nil, err
+ }
+
if !ok {
// view not specified in dimensions object: so create the view and hide it
// making the view take up the whole space in the background in case it needs
// to render content as soon as it appears, because lazyloaded content (via a pty task)
// cares about the size of the view.
- view, err := g.SetView(viewName, 0, 0, width, height, 0)
- if view != nil {
- view.Visible = false
- }
+ _, err := g.SetView(viewName, 0, 0, width, height, 0)
+ view.Visible = false
return view, err
}
frameOffset := 1
- if frame {
+ if view.Frame {
frameOffset = 0
}
- view, err := g.SetView(
+ _, err = g.SetView(
viewName,
dimensionsObj.X0-frameOffset,
dimensionsObj.Y0-frameOffset,
@@ -195,48 +74,39 @@ func (gui *Gui) layout(g *gocui.Gui) error {
dimensionsObj.Y1+frameOffset,
0,
)
-
- if view != nil {
- view.Visible = true
- }
+ view.Visible = true
return view, err
}
- args := []struct {
- viewName string
- windowName string
- frame bool
- }{
- {viewName: "main", windowName: "main", frame: true},
- {viewName: "secondary", windowName: "secondary", frame: true},
- {viewName: "status", windowName: "status", frame: true},
- {viewName: "files", windowName: "files", frame: true},
- {viewName: "branches", windowName: "branches", frame: true},
- {viewName: "commitFiles", windowName: gui.State.Contexts.CommitFiles.GetWindowName(), frame: true},
- {viewName: "commits", windowName: "commits", frame: true},
- {viewName: "stash", windowName: "stash", frame: true},
- {viewName: "options", windowName: "options", frame: false},
- {viewName: "searchPrefix", windowName: "searchPrefix", frame: false},
- {viewName: "search", windowName: "search", frame: false},
- {viewName: "appStatus", windowName: "appStatus", frame: false},
- {viewName: "information", windowName: "information", frame: false},
- {viewName: "extras", windowName: "extras", frame: true},
- }
+ for _, context := range gui.State.Contexts.Flatten() {
+ if !context.HasControlledBounds() {
+ continue
+ }
- for _, arg := range args {
- _, err = setViewFromDimensions(arg.viewName, arg.windowName, arg.frame)
+ _, err := setViewFromDimensions(context.GetViewName(), context.GetWindowName())
if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return err
}
}
- // if the commit files view is the view to be displayed for its window, we'll display it
- gui.Views.CommitFiles.Visible = gui.getViewNameForWindow(gui.State.Contexts.CommitFiles.GetWindowName()) == "commitFiles"
+ minimumHeight := 9
+ minimumWidth := 10
+ gui.Views.Limit.Visible = height < minimumHeight || width < minimumWidth
- if gui.State.OldInformation != informationStr {
+ gui.Views.Tooltip.Visible = gui.Views.Menu.Visible && gui.Views.Tooltip.Buffer() != ""
+
+ for _, context := range gui.TransientContexts() {
+ view, err := gui.g.View(context.GetViewName())
+ if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
+ return err
+ }
+ view.Visible = gui.getViewNameForWindow(context.GetWindowName()) == context.GetViewName()
+ }
+
+ if gui.PrevLayout.Information != informationStr {
gui.setViewContent(gui.Views.Information, informationStr)
- gui.State.OldInformation = informationStr
+ gui.PrevLayout.Information = informationStr
}
if !gui.ViewsSetup {
@@ -261,25 +131,29 @@ func (gui *Gui) layout(g *gocui.Gui) error {
continue
}
- // ignore contexts whose view is owned by another context right now
- if ContextKey(view.Context) != listContext.GetKey() {
- continue
- }
-
listContext.FocusLine()
view.SelBgColor = theme.GocuiSelectedLineBgColor
// I doubt this is expensive though it's admittedly redundant after the first render
- view.SetOnSelectItem(gui.onSelectItemWrapper(listContext.onSearchSelect))
+ view.SetOnSelectItem(gui.onSelectItemWrapper(listContext.OnSearchSelect))
}
- gui.Views.Main.SetOnSelectItem(gui.onSelectItemWrapper(gui.handlelineByLineNavigateTo))
+ for _, context := range gui.getPatchExplorerContexts() {
+ context := context
+ context.GetView().SetOnSelectItem(gui.onSelectItemWrapper(
+ func(selectedLineIdx int) error {
+ context.GetMutex().Lock()
+ defer context.GetMutex().Unlock()
+ return context.NavigateTo(gui.c.IsCurrentContext(context), selectedLineIdx)
+ }),
+ )
+ }
mainViewWidth, mainViewHeight := gui.Views.Main.Size()
- if mainViewWidth != gui.State.PrevMainWidth || mainViewHeight != gui.State.PrevMainHeight {
- gui.State.PrevMainWidth = mainViewWidth
- gui.State.PrevMainHeight = mainViewHeight
+ if mainViewWidth != gui.PrevLayout.MainWidth || mainViewHeight != gui.PrevLayout.MainHeight {
+ gui.PrevLayout.MainWidth = mainViewWidth
+ gui.PrevLayout.MainHeight = mainViewHeight
if err := gui.onResize(); err != nil {
return err
}
@@ -288,7 +162,7 @@ func (gui *Gui) layout(g *gocui.Gui) error {
// here is a good place log some stuff
// if you run `lazygit --logs`
// this will let you see these branches as prettified json
- // gui.Log.Info(utils.AsJson(gui.State.Branches[0:4]))
+ // gui.c.Log.Info(utils.AsJson(gui.State.Model.Branches[0:4]))
return gui.resizeCurrentPopupPanel()
}
@@ -299,8 +173,6 @@ func (gui *Gui) prepareView(viewName string) (*gocui.View, error) {
}
func (gui *Gui) onInitialViewsCreationForRepo() error {
- gui.setInitialViewContexts()
-
// hide any popup views. This only applies when we've just switched repos
for _, viewName := range gui.popupViewNames() {
view, err := gui.g.View(viewName)
@@ -310,7 +182,7 @@ func (gui *Gui) onInitialViewsCreationForRepo() error {
}
initialContext := gui.currentSideContext()
- if err := gui.pushContext(initialContext); err != nil {
+ if err := gui.c.PushContext(initialContext); err != nil {
return err
}
@@ -319,39 +191,7 @@ func (gui *Gui) onInitialViewsCreationForRepo() error {
func (gui *Gui) onInitialViewsCreation() error {
// now we order the views (in order of bottom first)
- layerOneViews := []*gocui.View{
- // first layer. Ordering within this layer does not matter because there are
- // no overlapping views
- gui.Views.Status,
- gui.Views.Files,
- gui.Views.Branches,
- gui.Views.Commits,
- gui.Views.Stash,
- gui.Views.CommitFiles,
- gui.Views.Main,
- gui.Views.Secondary,
- gui.Views.Extras,
-
- // bottom line
- gui.Views.Options,
- gui.Views.AppStatus,
- gui.Views.Information,
- gui.Views.Search,
- gui.Views.SearchPrefix, // this view takes up one character. Its only purpose is to show the slash when searching
-
- // popups. Ordering within this layer does not matter because there should
- // only be one popup shown at a time
- gui.Views.CommitMessage,
- gui.Views.Menu,
- gui.Views.Suggestions,
- gui.Views.Confirmation,
- gui.Views.Credentials,
-
- // this guy will cover everything else when it appears
- gui.Views.Limit,
- }
-
- for _, view := range layerOneViews {
+ for _, view := range gui.orderedViews() {
if _, err := gui.g.SetViewOnTop(view.Name()); err != nil {
return err
}
@@ -360,21 +200,25 @@ func (gui *Gui) onInitialViewsCreation() error {
gui.g.Mutexes.ViewsMutex.Lock()
// add tabs to views
for _, view := range gui.g.Views() {
- tabs := gui.viewTabNames(view.Name())
- if len(tabs) == 0 {
- continue
+ // if the view is in our mapping, we'll set the tabs and the tab index
+ for _, values := range gui.viewTabMap() {
+ index := slices.IndexFunc(values, func(tabContext context.TabView) bool {
+ return tabContext.ViewName == view.Name()
+ })
+
+ if index != -1 {
+ view.Tabs = slices.Map(values, func(tabContext context.TabView) string {
+ return tabContext.Tab
+ })
+ view.TabIndex = index
+ }
}
- view.Tabs = tabs
}
gui.g.Mutexes.ViewsMutex.Unlock()
- if err := gui.keybindings(); err != nil {
- return err
- }
-
- if !gui.UserConfig.DisableStartupPopups {
+ if !gui.c.UserConfig.DisableStartupPopups {
popupTasks := []func(chan struct{}) error{}
- storedPopupVersion := gui.Config.GetAppState().StartupPopupVersion
+ storedPopupVersion := gui.c.GetAppState().StartupPopupVersion
if storedPopupVersion < StartupPopupVersion {
popupTasks = append(popupTasks, gui.showIntroPopupMessage)
}
diff --git a/pkg/gui/line_by_line_panel.go b/pkg/gui/line_by_line_panel.go
deleted file mode 100644
index 0576d3c0f..000000000
--- a/pkg/gui/line_by_line_panel.go
+++ /dev/null
@@ -1,292 +0,0 @@
-package gui
-
-import (
- "fmt"
-
- "github.com/go-errors/errors"
- "github.com/jesseduffield/lazygit/pkg/commands/patch"
- "github.com/jesseduffield/lazygit/pkg/gui/lbl"
-)
-
-// Currently there are two 'pseudo-panels' that make use of this 'pseudo-panel'.
-// One is the staging panel where we stage files line-by-line, the other is the
-// patch building panel where we add lines of an old commit's file to a patch.
-// This file contains the logic around selecting lines and displaying the diffs
-// staging_panel.go and patch_building_panel.go have functions specific to their
-// use cases
-
-// returns whether the patch is empty so caller can escape if necessary
-// both diffs should be non-coloured because we'll parse them and colour them here
-func (gui *Gui) refreshLineByLinePanel(diff string, secondaryDiff string, secondaryFocused bool, selectedLineIdx int) (bool, error) {
- gui.splitMainPanel(true)
-
- var oldState *lbl.State
- if gui.State.Panels.LineByLine != nil {
- oldState = gui.State.Panels.LineByLine.State
- }
-
- state := lbl.NewState(diff, selectedLineIdx, oldState, gui.Log)
- if state == nil {
- return true, nil
- }
-
- gui.State.Panels.LineByLine = &LblPanelState{
- State: state,
- SecondaryFocused: secondaryFocused,
- }
-
- if err := gui.refreshMainViewForLineByLine(gui.State.Panels.LineByLine); err != nil {
- return false, err
- }
-
- if err := gui.focusSelection(gui.State.Panels.LineByLine); err != nil {
- return false, err
- }
-
- gui.Views.Secondary.Highlight = true
- gui.Views.Secondary.Wrap = false
-
- secondaryPatchParser := patch.NewPatchParser(gui.Log, secondaryDiff)
-
- gui.setViewContent(gui.Views.Secondary, secondaryPatchParser.Render(-1, -1, nil))
-
- return false, nil
-}
-
-func (gui *Gui) handleSelectPrevLine() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.CycleSelection(false)
-
- return gui.refreshAndFocusLblPanel(state)
- })
-}
-
-func (gui *Gui) handleSelectNextLine() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.CycleSelection(true)
-
- return gui.refreshAndFocusLblPanel(state)
- })
-}
-
-func (gui *Gui) handleSelectPrevHunk() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.CycleHunk(false)
-
- return gui.refreshAndFocusLblPanel(state)
- })
-}
-
-func (gui *Gui) handleSelectNextHunk() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.CycleHunk(true)
-
- return gui.refreshAndFocusLblPanel(state)
- })
-}
-
-func (gui *Gui) copySelectedToClipboard() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- selected := state.PlainRenderSelected()
-
- gui.logAction(gui.Tr.Actions.CopySelectedTextToClipboard)
- if err := gui.OSCommand.CopyToClipboard(selected); err != nil {
- return gui.surfaceError(err)
- }
-
- return nil
- })
-}
-
-func (gui *Gui) refreshAndFocusLblPanel(state *LblPanelState) error {
- if err := gui.refreshMainViewForLineByLine(state); err != nil {
- return err
- }
-
- return gui.focusSelection(state)
-}
-
-func (gui *Gui) handleLBLMouseDown() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- if gui.popupPanelFocused() {
- return nil
- }
-
- state.SelectNewLineForRange(gui.Views.Main.SelectedLineIdx())
-
- return gui.refreshAndFocusLblPanel(state)
- })
-}
-
-func (gui *Gui) handleMouseDrag() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- if gui.popupPanelFocused() {
- return nil
- }
-
- state.SelectLine(gui.Views.Main.SelectedLineIdx())
-
- return gui.refreshAndFocusLblPanel(state)
- })
-}
-
-func (gui *Gui) getSelectedCommitFileName() string {
- idx := gui.State.Panels.CommitFiles.SelectedLineIdx
-
- return gui.State.CommitFileTreeViewModel.GetItemAtIndex(idx).GetPath()
-}
-
-func (gui *Gui) refreshMainViewForLineByLine(state *LblPanelState) error {
- var includedLineIndices []int
- // I'd prefer not to have knowledge of contexts using this file but I'm not sure
- // how to get around this
- if gui.currentContext().GetKey() == gui.State.Contexts.PatchBuilding.GetKey() {
- filename := gui.getSelectedCommitFileName()
- var err error
- includedLineIndices, err = gui.Git.Patch.PatchManager.GetFileIncLineIndices(filename)
- if err != nil {
- return err
- }
- }
- colorDiff := state.RenderForLineIndices(includedLineIndices)
-
- gui.Views.Main.Highlight = true
- gui.Views.Main.Wrap = false
-
- gui.setViewContent(gui.Views.Main, colorDiff)
-
- return nil
-}
-
-// focusSelection works out the best focus for the staging panel given the
-// selected line and size of the hunk
-func (gui *Gui) focusSelection(state *LblPanelState) error {
- stagingView := gui.Views.Main
-
- _, viewHeight := stagingView.Size()
- bufferHeight := viewHeight - 1
- _, origin := stagingView.Origin()
-
- selectedLineIdx := state.GetSelectedLineIdx()
-
- newOrigin := state.CalculateOrigin(origin, bufferHeight)
-
- if err := stagingView.SetOriginY(newOrigin); err != nil {
- return err
- }
-
- return stagingView.SetCursor(0, selectedLineIdx-newOrigin)
-}
-
-func (gui *Gui) handleToggleSelectRange() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.ToggleSelectRange()
-
- return gui.refreshMainViewForLineByLine(state)
- })
-}
-
-func (gui *Gui) handleToggleSelectHunk() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.ToggleSelectHunk()
-
- return gui.refreshAndFocusLblPanel(state)
- })
-}
-
-func (gui *Gui) escapeLineByLinePanel() {
- gui.State.Panels.LineByLine = nil
-}
-
-func (gui *Gui) handleOpenFileAtLine() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- // again, would be good to use inheritance here (or maybe even composition)
- var filename string
- switch gui.State.MainContext {
- case gui.State.Contexts.PatchBuilding.GetKey():
- filename = gui.getSelectedCommitFileName()
- case gui.State.Contexts.Staging.GetKey():
- file := gui.getSelectedFile()
- if file == nil {
- return nil
- }
- filename = file.Name
- default:
- return errors.Errorf("unknown main context: %s", gui.State.MainContext)
- }
-
- // need to look at current index, then work out what my hunk's header information is, and see how far my line is away from the hunk header
- lineNumber := state.CurrentLineNumber()
- filenameWithLineNum := fmt.Sprintf("%s:%d", filename, lineNumber)
- if err := gui.OSCommand.OpenFile(filenameWithLineNum); err != nil {
- return err
- }
-
- return nil
- })
-}
-
-func (gui *Gui) handleLineByLineNextPage() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.SetLineSelectMode()
- state.AdjustSelectedLineIdx(gui.pageDelta(gui.Views.Main))
-
- return gui.refreshAndFocusLblPanel(state)
- })
-}
-
-func (gui *Gui) handleLineByLinePrevPage() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.SetLineSelectMode()
- state.AdjustSelectedLineIdx(-gui.pageDelta(gui.Views.Main))
-
- return gui.refreshAndFocusLblPanel(state)
- })
-}
-
-func (gui *Gui) handleLineByLineGotoBottom() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.SelectBottom()
-
- return gui.refreshAndFocusLblPanel(state)
- })
-}
-
-func (gui *Gui) handleLineByLineGotoTop() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.SelectTop()
-
- return gui.refreshAndFocusLblPanel(state)
- })
-}
-
-func (gui *Gui) handlelineByLineNavigateTo(selectedLineIdx int) error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.SetLineSelectMode()
- state.SelectLine(selectedLineIdx)
-
- return gui.refreshAndFocusLblPanel(state)
- })
-}
-
-func (gui *Gui) withLBLActiveCheck(f func(*LblPanelState) error) error {
- gui.Mutexes.LineByLinePanelMutex.Lock()
- defer gui.Mutexes.LineByLinePanelMutex.Unlock()
-
- state := gui.State.Panels.LineByLine
- if state == nil {
- return nil
- }
-
- return f(state)
-}
-
-func (gui *Gui) handleLineByLineEdit() error {
- file := gui.getSelectedFile()
- if file == nil {
- return nil
- }
-
- lineNumber := gui.State.Panels.LineByLine.CurrentLineNumber()
- return gui.editFileAtLine(file.Name, lineNumber)
-}
diff --git a/pkg/gui/list_context.go b/pkg/gui/list_context.go
deleted file mode 100644
index 9f0d86372..000000000
--- a/pkg/gui/list_context.go
+++ /dev/null
@@ -1,283 +0,0 @@
-package gui
-
-import (
- "fmt"
-
- "github.com/jesseduffield/gocui"
-)
-
-type ListContext struct {
- GetItemsLength func() int
- GetDisplayStrings func(startIdx int, length int) [][]string
- OnFocus func(...OnFocusOpts) error
- OnRenderToMain func(...OnFocusOpts) error
- OnFocusLost func() error
- OnClickSelectedItem func() error
-
- // the boolean here tells us whether the item is nil. This is needed because you can't work it out on the calling end once the pointer is wrapped in an interface (unless you want to use reflection)
- SelectedItem func() (ListItem, bool)
- OnGetPanelState func() IListPanelState
- // if this is true, we'll call GetDisplayStrings for just the visible part of the
- // view and re-render that. This is useful when you need to render different
- // content based on the selection (e.g. for showing the selected commit)
- RenderSelection bool
-
- Gui *Gui
-
- *BasicContext
-}
-
-type IListContext interface {
- GetSelectedItem() (ListItem, bool)
- GetSelectedItemId() string
- handlePrevLine() error
- handleNextLine() error
- handleScrollLeft() error
- handleScrollRight() error
- handleLineChange(change int) error
- handleNextPage() error
- handleGotoTop() error
- handleGotoBottom() error
- handlePrevPage() error
- handleClick() error
- onSearchSelect(selectedLineIdx int) error
- FocusLine()
- HandleRenderToMain() error
-
- GetPanelState() IListPanelState
-
- Context
-}
-
-func (self *ListContext) GetPanelState() IListPanelState {
- return self.OnGetPanelState()
-}
-
-type IListPanelState interface {
- SetSelectedLineIdx(int)
- GetSelectedLineIdx() int
-}
-
-type ListItem interface {
- // ID is a SHA when the item is a commit, a filename when the item is a file, 'stash@{4}' when it's a stash entry, 'my_branch' when it's a branch
- ID() string
-
- // Description is something we would show in a message e.g. '123as14: push blah' for a commit
- Description() string
-}
-
-func (self *ListContext) FocusLine() {
- view, err := self.Gui.g.View(self.ViewName)
- if err != nil {
- // ignoring error for now
- return
- }
-
- // we need a way of knowing whether we've rendered to the view yet.
- view.FocusPoint(view.OriginX(), self.GetPanelState().GetSelectedLineIdx())
- if self.RenderSelection {
- _, originY := view.Origin()
- displayStrings := self.GetDisplayStrings(originY, view.InnerHeight()+1)
- self.Gui.renderDisplayStringsAtPos(view, originY, displayStrings)
- }
- view.Footer = formatListFooter(self.GetPanelState().GetSelectedLineIdx(), self.GetItemsLength())
-}
-
-func formatListFooter(selectedLineIdx int, length int) string {
- return fmt.Sprintf("%d of %d", selectedLineIdx+1, length)
-}
-
-func (self *ListContext) GetSelectedItem() (ListItem, bool) {
- return self.SelectedItem()
-}
-
-func (self *ListContext) GetSelectedItemId() string {
- item, ok := self.GetSelectedItem()
-
- if !ok {
- return ""
- }
-
- return item.ID()
-}
-
-// OnFocus assumes that the content of the context has already been rendered to the view. OnRender is the function which actually renders the content to the view
-func (self *ListContext) HandleRender() error {
- view, err := self.Gui.g.View(self.ViewName)
- if err != nil {
- return nil
- }
-
- if self.GetDisplayStrings != nil {
- self.Gui.refreshSelectedLine(self.GetPanelState(), self.GetItemsLength())
- self.Gui.renderDisplayStrings(view, self.GetDisplayStrings(0, self.GetItemsLength()))
- self.Gui.render()
- }
-
- return nil
-}
-
-func (self *ListContext) HandleFocusLost() error {
- if self.OnFocusLost != nil {
- return self.OnFocusLost()
- }
-
- view, err := self.Gui.g.View(self.ViewName)
- if err != nil {
- return nil
- }
-
- _ = view.SetOriginX(0)
-
- return nil
-}
-
-func (self *ListContext) HandleFocus(opts ...OnFocusOpts) error {
- if self.Gui.popupPanelFocused() {
- return nil
- }
-
- self.FocusLine()
-
- if self.Gui.State.Modes.Diffing.Active() {
- return self.Gui.renderDiff()
- }
-
- if self.OnFocus != nil {
- if err := self.OnFocus(opts...); err != nil {
- return err
- }
- }
-
- if self.OnRenderToMain != nil {
- if err := self.OnRenderToMain(opts...); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func (self *ListContext) handlePrevLine() error {
- return self.handleLineChange(-1)
-}
-
-func (self *ListContext) handleNextLine() error {
- return self.handleLineChange(1)
-}
-
-func (self *ListContext) handleScrollLeft() error {
- return self.scroll(self.Gui.scrollLeft)
-}
-
-func (self *ListContext) handleScrollRight() error {
- return self.scroll(self.Gui.scrollRight)
-}
-
-func (self *ListContext) scroll(scrollFunc func(*gocui.View)) error {
- if self.ignoreKeybinding() {
- return nil
- }
-
- // get the view, move the origin
- view, err := self.Gui.g.View(self.ViewName)
- if err != nil {
- return nil
- }
-
- scrollFunc(view)
-
- return self.HandleFocus()
-}
-
-func (self *ListContext) ignoreKeybinding() bool {
- return !self.Gui.isPopupPanel(self.ViewName) && self.Gui.popupPanelFocused()
-}
-
-func (self *ListContext) handleLineChange(change int) error {
- if self.ignoreKeybinding() {
- return nil
- }
-
- selectedLineIdx := self.GetPanelState().GetSelectedLineIdx()
- if (change < 0 && selectedLineIdx == 0) || (change > 0 && selectedLineIdx == self.GetItemsLength()-1) {
- return nil
- }
-
- self.Gui.changeSelectedLine(self.GetPanelState(), self.GetItemsLength(), change)
-
- return self.HandleFocus()
-}
-
-func (self *ListContext) handleNextPage() error {
- view, err := self.Gui.g.View(self.ViewName)
- if err != nil {
- return nil
- }
- delta := self.Gui.pageDelta(view)
-
- return self.handleLineChange(delta)
-}
-
-func (self *ListContext) handleGotoTop() error {
- return self.handleLineChange(-self.GetItemsLength())
-}
-
-func (self *ListContext) handleGotoBottom() error {
- return self.handleLineChange(self.GetItemsLength())
-}
-
-func (self *ListContext) handlePrevPage() error {
- view, err := self.Gui.g.View(self.ViewName)
- if err != nil {
- return nil
- }
-
- delta := self.Gui.pageDelta(view)
-
- return self.handleLineChange(-delta)
-}
-
-func (self *ListContext) handleClick() error {
- if self.ignoreKeybinding() {
- return nil
- }
-
- view, err := self.Gui.g.View(self.ViewName)
- if err != nil {
- return nil
- }
-
- prevSelectedLineIdx := self.GetPanelState().GetSelectedLineIdx()
- newSelectedLineIdx := view.SelectedLineIdx()
-
- // we need to focus the view
- if err := self.Gui.pushContext(self); err != nil {
- return err
- }
-
- if newSelectedLineIdx > self.GetItemsLength()-1 {
- return nil
- }
-
- self.GetPanelState().SetSelectedLineIdx(newSelectedLineIdx)
-
- prevViewName := self.Gui.currentViewName()
- if prevSelectedLineIdx == newSelectedLineIdx && prevViewName == self.ViewName && self.OnClickSelectedItem != nil {
- return self.OnClickSelectedItem()
- }
- return self.HandleFocus()
-}
-
-func (self *ListContext) onSearchSelect(selectedLineIdx int) error {
- self.GetPanelState().SetSelectedLineIdx(selectedLineIdx)
- return self.HandleFocus()
-}
-
-func (self *ListContext) HandleRenderToMain() error {
- if self.OnRenderToMain != nil {
- return self.OnRenderToMain()
- }
-
- return nil
-}
diff --git a/pkg/gui/list_context_config.go b/pkg/gui/list_context_config.go
index 3fde1edfd..d463c2ac5 100644
--- a/pkg/gui/list_context_config.go
+++ b/pkg/gui/list_context_config.go
@@ -3,224 +3,161 @@ package gui
import (
"log"
- "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
-func (gui *Gui) menuListContext() IListContext {
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "menu",
- Key: "menu",
- Kind: PERSISTENT_POPUP,
- OnGetOptionsMap: gui.getMenuOptions,
+func (gui *Gui) menuListContext() *context.MenuContext {
+ return context.NewMenuContext(
+ gui.Views.Menu,
+ gui.c,
+ gui.getMenuOptions,
+ func(content string) {
+ gui.Views.Tooltip.SetContent(content)
},
- GetItemsLength: func() int { return gui.Views.Menu.LinesHeight() },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.Menu },
- OnClickSelectedItem: gui.onMenuPress,
- Gui: gui,
+ )
+}
- // no GetDisplayStrings field because we do a custom render on menu creation
+func (gui *Gui) filesListContext() *context.WorkingTreeContext {
+ return context.NewWorkingTreeContext(
+ func() []*models.File { return gui.State.Model.Files },
+ gui.Views.Files,
+ func(startIdx int, length int) [][]string {
+ lines := presentation.RenderFileTree(gui.State.Contexts.Files.FileTreeViewModel, gui.State.Modes.Diffing.Ref, gui.State.Model.Submodules)
+ return slices.Map(lines, func(line string) []string {
+ return []string{line}
+ })
+ },
+ nil,
+ gui.withDiffModeCheck(gui.filesRenderToMain),
+ nil,
+ gui.c,
+ )
+}
+
+func (gui *Gui) branchesListContext() *context.BranchesContext {
+ return context.NewBranchesContext(
+ func() []*models.Branch { return gui.State.Model.Branches },
+ gui.Views.Branches,
+ func(startIdx int, length int) [][]string {
+ return presentation.GetBranchListDisplayStrings(gui.State.Model.Branches, gui.State.ScreenMode != SCREEN_NORMAL, gui.State.Modes.Diffing.Ref, gui.Tr)
+ },
+ nil,
+ gui.withDiffModeCheck(gui.branchesRenderToMain),
+ nil,
+ gui.c,
+ )
+}
+
+func (gui *Gui) remotesListContext() *context.RemotesContext {
+ return context.NewRemotesContext(
+ func() []*models.Remote { return gui.State.Model.Remotes },
+ gui.Views.Remotes,
+ func(startIdx int, length int) [][]string {
+ return presentation.GetRemoteListDisplayStrings(gui.State.Model.Remotes, gui.State.Modes.Diffing.Ref)
+ },
+ nil,
+ gui.withDiffModeCheck(gui.remotesRenderToMain),
+ nil,
+ gui.c,
+ )
+}
+
+func (gui *Gui) remoteBranchesListContext() *context.RemoteBranchesContext {
+ return context.NewRemoteBranchesContext(
+ func() []*models.RemoteBranch { return gui.State.Model.RemoteBranches },
+ gui.Views.RemoteBranches,
+ func(startIdx int, length int) [][]string {
+ return presentation.GetRemoteBranchListDisplayStrings(gui.State.Model.RemoteBranches, gui.State.Modes.Diffing.Ref)
+ },
+ nil,
+ gui.withDiffModeCheck(gui.remoteBranchesRenderToMain),
+ nil,
+ gui.c,
+ )
+}
+
+func (gui *Gui) withDiffModeCheck(f func() error) func() error {
+ return func() error {
+ if gui.State.Modes.Diffing.Active() {
+ return gui.renderDiff()
+ }
+
+ return f()
}
}
-func (gui *Gui) filesListContext() IListContext {
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "files",
- WindowName: "files",
- Key: FILES_CONTEXT_KEY,
- Kind: SIDE_CONTEXT,
+func (gui *Gui) tagsListContext() *context.TagsContext {
+ return context.NewTagsContext(
+ func() []*models.Tag { return gui.State.Model.Tags },
+ gui.Views.Tags,
+ func(startIdx int, length int) [][]string {
+ return presentation.GetTagListDisplayStrings(gui.State.Model.Tags, gui.State.Modes.Diffing.Ref)
},
- GetItemsLength: func() int { return gui.State.FileTreeViewModel.GetItemsLength() },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.Files },
- OnFocus: OnFocusWrapper(gui.onFocusFile),
- OnRenderToMain: OnFocusWrapper(gui.filesRenderToMain),
- OnClickSelectedItem: gui.handleFilePress,
- Gui: gui,
- GetDisplayStrings: func(startIdx int, length int) [][]string {
- lines := presentation.RenderFileTree(gui.State.FileTreeViewModel, gui.State.Modes.Diffing.Ref, gui.State.Submodules)
- mappedLines := make([][]string, len(lines))
- for i, line := range lines {
- mappedLines[i] = []string{line}
- }
-
- return mappedLines
- },
- SelectedItem: func() (ListItem, bool) {
- item := gui.getSelectedFileNode()
- return item, item != nil
- },
- }
+ nil,
+ gui.withDiffModeCheck(gui.tagsRenderToMain),
+ nil,
+ gui.c,
+ )
}
-func (gui *Gui) branchesListContext() IListContext {
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "branches",
- WindowName: "branches",
- Key: LOCAL_BRANCHES_CONTEXT_KEY,
- Kind: SIDE_CONTEXT,
- },
- GetItemsLength: func() int { return len(gui.State.Branches) },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.Branches },
- OnRenderToMain: OnFocusWrapper(gui.branchesRenderToMain),
- Gui: gui,
- GetDisplayStrings: func(startIdx int, length int) [][]string {
- prs, err := git_commands.GenerateGithubPullRequestMap(gui.State.GithubState.RecentPRs, gui.State.Branches, gui.State.Remotes)
- if err != nil {
- panic(err)
- }
-
- return presentation.GetBranchListDisplayStrings(gui.State.Branches, prs, gui.State.ScreenMode != SCREEN_NORMAL, gui.State.Modes.Diffing.Ref)
- },
- SelectedItem: func() (ListItem, bool) {
- item := gui.getSelectedBranch()
- return item, item != nil
- },
- }
-}
-
-func (gui *Gui) remotesListContext() IListContext {
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "branches",
- WindowName: "branches",
- Key: REMOTES_CONTEXT_KEY,
- Kind: SIDE_CONTEXT,
- },
- GetItemsLength: func() int { return len(gui.State.Remotes) },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.Remotes },
- OnRenderToMain: OnFocusWrapper(gui.remotesRenderToMain),
- OnClickSelectedItem: gui.handleRemoteEnter,
- Gui: gui,
- GetDisplayStrings: func(startIdx int, length int) [][]string {
- return presentation.GetRemoteListDisplayStrings(gui.State.Remotes, gui.State.Modes.Diffing.Ref)
- },
- SelectedItem: func() (ListItem, bool) {
- item := gui.getSelectedRemote()
- return item, item != nil
- },
- }
-}
-
-func (gui *Gui) remoteBranchesListContext() IListContext {
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "branches",
- WindowName: "branches",
- Key: REMOTE_BRANCHES_CONTEXT_KEY,
- Kind: SIDE_CONTEXT,
- },
- GetItemsLength: func() int { return len(gui.State.RemoteBranches) },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.RemoteBranches },
- OnRenderToMain: OnFocusWrapper(gui.remoteBranchesRenderToMain),
- Gui: gui,
- GetDisplayStrings: func(startIdx int, length int) [][]string {
- return presentation.GetRemoteBranchListDisplayStrings(gui.State.RemoteBranches, gui.State.Modes.Diffing.Ref)
- },
- SelectedItem: func() (ListItem, bool) {
- item := gui.getSelectedRemoteBranch()
- return item, item != nil
- },
- }
-}
-
-func (gui *Gui) tagsListContext() IListContext {
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "branches",
- WindowName: "branches",
- Key: TAGS_CONTEXT_KEY,
- Kind: SIDE_CONTEXT,
- },
- GetItemsLength: func() int { return len(gui.State.Tags) },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.Tags },
- OnRenderToMain: OnFocusWrapper(gui.tagsRenderToMain),
- Gui: gui,
- GetDisplayStrings: func(startIdx int, length int) [][]string {
- return presentation.GetTagListDisplayStrings(gui.State.Tags, gui.State.Modes.Diffing.Ref)
- },
- SelectedItem: func() (ListItem, bool) {
- item := gui.getSelectedTag()
- return item, item != nil
- },
- }
-}
-
-func (gui *Gui) branchCommitsListContext() IListContext {
- parseEmoji := gui.UserConfig.Git.ParseEmoji
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "commits",
- WindowName: "commits",
- Key: BRANCH_COMMITS_CONTEXT_KEY,
- Kind: SIDE_CONTEXT,
- },
- GetItemsLength: func() int { return len(gui.State.Commits) },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.Commits },
- OnFocus: OnFocusWrapper(gui.onCommitFocus),
- OnRenderToMain: OnFocusWrapper(gui.branchCommitsRenderToMain),
- OnClickSelectedItem: gui.handleViewCommitFiles,
- Gui: gui,
- GetDisplayStrings: func(startIdx int, length int) [][]string {
+func (gui *Gui) branchCommitsListContext() *context.LocalCommitsContext {
+ return context.NewLocalCommitsContext(
+ func() []*models.Commit { return gui.State.Model.Commits },
+ gui.Views.Commits,
+ func(startIdx int, length int) [][]string {
selectedCommitSha := ""
- if gui.currentContext().GetKey() == BRANCH_COMMITS_CONTEXT_KEY {
- selectedCommit := gui.getSelectedLocalCommit()
+ if gui.currentContext().GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY {
+ selectedCommit := gui.State.Contexts.LocalCommits.GetSelected()
if selectedCommit != nil {
selectedCommitSha = selectedCommit.Sha
}
}
return presentation.GetCommitListDisplayStrings(
- gui.State.Commits,
+ gui.State.Model.Commits,
gui.State.ScreenMode != SCREEN_NORMAL,
- gui.cherryPickedCommitShaMap(),
+ gui.helpers.CherryPick.CherryPickedCommitShaSet(),
gui.State.Modes.Diffing.Ref,
- parseEmoji,
+ gui.c.UserConfig.Gui.TimeFormat,
+ gui.c.UserConfig.Git.ParseEmoji,
selectedCommitSha,
startIdx,
length,
gui.shouldShowGraph(),
- gui.State.BisectInfo,
+ gui.State.Model.BisectInfo,
)
},
- SelectedItem: func() (ListItem, bool) {
- item := gui.getSelectedLocalCommit()
- return item, item != nil
- },
- RenderSelection: true,
- }
+ OnFocusWrapper(gui.onCommitFocus),
+ gui.withDiffModeCheck(gui.branchCommitsRenderToMain),
+ nil,
+ gui.c,
+ )
}
-func (gui *Gui) subCommitsListContext() IListContext {
- parseEmoji := gui.UserConfig.Git.ParseEmoji
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "branches",
- WindowName: "branches",
- Key: SUB_COMMITS_CONTEXT_KEY,
- Kind: SIDE_CONTEXT,
- },
- GetItemsLength: func() int { return len(gui.State.SubCommits) },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.SubCommits },
- OnRenderToMain: OnFocusWrapper(gui.subCommitsRenderToMain),
- Gui: gui,
- GetDisplayStrings: func(startIdx int, length int) [][]string {
+func (gui *Gui) subCommitsListContext() *context.SubCommitsContext {
+ return context.NewSubCommitsContext(
+ func() []*models.Commit { return gui.State.Model.SubCommits },
+ gui.Views.SubCommits,
+ func(startIdx int, length int) [][]string {
selectedCommitSha := ""
- if gui.currentContext().GetKey() == SUB_COMMITS_CONTEXT_KEY {
- selectedCommit := gui.getSelectedSubCommit()
+ if gui.currentContext().GetKey() == context.SUB_COMMITS_CONTEXT_KEY {
+ selectedCommit := gui.State.Contexts.SubCommits.GetSelected()
if selectedCommit != nil {
selectedCommitSha = selectedCommit.Sha
}
}
return presentation.GetCommitListDisplayStrings(
- gui.State.SubCommits,
+ gui.State.Model.SubCommits,
gui.State.ScreenMode != SCREEN_NORMAL,
- gui.cherryPickedCommitShaMap(),
+ gui.helpers.CherryPick.CherryPickedCommitShaSet(),
gui.State.Modes.Diffing.Ref,
- parseEmoji,
+ gui.c.UserConfig.Gui.TimeFormat,
+ gui.c.UserConfig.Git.ParseEmoji,
selectedCommitSha,
startIdx,
length,
@@ -228,12 +165,11 @@ func (gui *Gui) subCommitsListContext() IListContext {
git_commands.NewNullBisectInfo(),
)
},
- SelectedItem: func() (ListItem, bool) {
- item := gui.getSelectedSubCommit()
- return item, item != nil
- },
- RenderSelection: true,
- }
+ nil,
+ gui.withDiffModeCheck(gui.subCommitsRenderToMain),
+ nil,
+ gui.c,
+ )
}
func (gui *Gui) shouldShowGraph() bool {
@@ -241,7 +177,7 @@ func (gui *Gui) shouldShowGraph() bool {
return false
}
- value := gui.UserConfig.Git.Log.ShowGraph
+ value := gui.c.UserConfig.Git.Log.ShowGraph
switch value {
case "always":
return true
@@ -255,138 +191,102 @@ func (gui *Gui) shouldShowGraph() bool {
return false
}
-func (gui *Gui) reflogCommitsListContext() IListContext {
- parseEmoji := gui.UserConfig.Git.ParseEmoji
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "commits",
- WindowName: "commits",
- Key: REFLOG_COMMITS_CONTEXT_KEY,
- Kind: SIDE_CONTEXT,
- },
- GetItemsLength: func() int { return len(gui.State.FilteredReflogCommits) },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.ReflogCommits },
- OnRenderToMain: OnFocusWrapper(gui.reflogCommitsRenderToMain),
- Gui: gui,
- GetDisplayStrings: func(startIdx int, length int) [][]string {
+func (gui *Gui) reflogCommitsListContext() *context.ReflogCommitsContext {
+ return context.NewReflogCommitsContext(
+ func() []*models.Commit { return gui.State.Model.FilteredReflogCommits },
+ gui.Views.ReflogCommits,
+ func(startIdx int, length int) [][]string {
return presentation.GetReflogCommitListDisplayStrings(
- gui.State.FilteredReflogCommits,
+ gui.State.Model.FilteredReflogCommits,
gui.State.ScreenMode != SCREEN_NORMAL,
- gui.cherryPickedCommitShaMap(),
+ gui.helpers.CherryPick.CherryPickedCommitShaSet(),
gui.State.Modes.Diffing.Ref,
- parseEmoji,
+ gui.c.UserConfig.Gui.TimeFormat,
+ gui.c.UserConfig.Git.ParseEmoji,
)
},
- SelectedItem: func() (ListItem, bool) {
- item := gui.getSelectedReflogCommit()
- return item, item != nil
- },
- }
+ nil,
+ gui.withDiffModeCheck(gui.reflogCommitsRenderToMain),
+ nil,
+ gui.c,
+ )
}
-func (gui *Gui) stashListContext() IListContext {
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "stash",
- WindowName: "stash",
- Key: STASH_CONTEXT_KEY,
- Kind: SIDE_CONTEXT,
+func (gui *Gui) stashListContext() *context.StashContext {
+ return context.NewStashContext(
+ func() []*models.StashEntry { return gui.State.Model.StashEntries },
+ gui.Views.Stash,
+ func(startIdx int, length int) [][]string {
+ return presentation.GetStashEntryListDisplayStrings(gui.State.Model.StashEntries, gui.State.Modes.Diffing.Ref)
},
- GetItemsLength: func() int { return len(gui.State.StashEntries) },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.Stash },
- OnRenderToMain: OnFocusWrapper(gui.stashRenderToMain),
- Gui: gui,
- GetDisplayStrings: func(startIdx int, length int) [][]string {
- return presentation.GetStashEntryListDisplayStrings(gui.State.StashEntries, gui.State.Modes.Diffing.Ref)
- },
- SelectedItem: func() (ListItem, bool) {
- item := gui.getSelectedStashEntry()
- return item, item != nil
- },
- }
+ nil,
+ gui.withDiffModeCheck(gui.stashRenderToMain),
+ nil,
+ gui.c,
+ )
}
-func (gui *Gui) commitFilesListContext() IListContext {
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "commitFiles",
- WindowName: "commits",
- Key: COMMIT_FILES_CONTEXT_KEY,
- Kind: SIDE_CONTEXT,
- },
- GetItemsLength: func() int { return gui.State.CommitFileTreeViewModel.GetItemsLength() },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.CommitFiles },
- OnFocus: OnFocusWrapper(gui.onCommitFileFocus),
- OnRenderToMain: OnFocusWrapper(gui.commitFilesRenderToMain),
- Gui: gui,
- GetDisplayStrings: func(startIdx int, length int) [][]string {
- if gui.State.CommitFileTreeViewModel.GetItemsLength() == 0 {
+func (gui *Gui) commitFilesListContext() *context.CommitFilesContext {
+ return context.NewCommitFilesContext(
+ func() []*models.CommitFile { return gui.State.Model.CommitFiles },
+ gui.Views.CommitFiles,
+ func(startIdx int, length int) [][]string {
+ if gui.State.Contexts.CommitFiles.CommitFileTreeViewModel.Len() == 0 {
return [][]string{{style.FgRed.Sprint("(none)")}}
}
- lines := presentation.RenderCommitFileTree(gui.State.CommitFileTreeViewModel, gui.State.Modes.Diffing.Ref, gui.Git.Patch.PatchManager)
- mappedLines := make([][]string, len(lines))
- for i, line := range lines {
- mappedLines[i] = []string{line}
- }
-
- return mappedLines
+ lines := presentation.RenderCommitFileTree(gui.State.Contexts.CommitFiles.CommitFileTreeViewModel, gui.State.Modes.Diffing.Ref, gui.git.Patch.PatchManager)
+ return slices.Map(lines, func(line string) []string {
+ return []string{line}
+ })
},
- SelectedItem: func() (ListItem, bool) {
- item := gui.getSelectedCommitFileNode()
- return item, item != nil
- },
- }
+ nil,
+ gui.withDiffModeCheck(gui.commitFilesRenderToMain),
+ nil,
+ gui.c,
+ )
}
-func (gui *Gui) submodulesListContext() IListContext {
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "files",
- WindowName: "files",
- Key: SUBMODULES_CONTEXT_KEY,
- Kind: SIDE_CONTEXT,
+func (gui *Gui) submodulesListContext() *context.SubmodulesContext {
+ return context.NewSubmodulesContext(
+ func() []*models.SubmoduleConfig { return gui.State.Model.Submodules },
+ gui.Views.Submodules,
+ func(startIdx int, length int) [][]string {
+ return presentation.GetSubmoduleListDisplayStrings(gui.State.Model.Submodules)
},
- GetItemsLength: func() int { return len(gui.State.Submodules) },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.Submodules },
- OnRenderToMain: OnFocusWrapper(gui.submodulesRenderToMain),
- Gui: gui,
- GetDisplayStrings: func(startIdx int, length int) [][]string {
- return presentation.GetSubmoduleListDisplayStrings(gui.State.Submodules)
- },
- SelectedItem: func() (ListItem, bool) {
- item := gui.getSelectedSubmodule()
- return item, item != nil
- },
- }
+ nil,
+ gui.withDiffModeCheck(gui.submodulesRenderToMain),
+ nil,
+ gui.c,
+ )
}
-func (gui *Gui) suggestionsListContext() IListContext {
- return &ListContext{
- BasicContext: &BasicContext{
- ViewName: "suggestions",
- WindowName: "suggestions",
- Key: SUGGESTIONS_CONTEXT_KEY,
- Kind: PERSISTENT_POPUP,
- },
- GetItemsLength: func() int { return len(gui.State.Suggestions) },
- OnGetPanelState: func() IListPanelState { return gui.State.Panels.Suggestions },
- Gui: gui,
- GetDisplayStrings: func(startIdx int, length int) [][]string {
+func (gui *Gui) suggestionsListContext() *context.SuggestionsContext {
+ return context.NewSuggestionsContext(
+ func() []*types.Suggestion { return gui.State.Suggestions },
+ gui.Views.Suggestions,
+ func(startIdx int, length int) [][]string {
return presentation.GetSuggestionListDisplayStrings(gui.State.Suggestions)
},
- }
+ nil,
+ nil,
+ func(types.OnFocusLostOpts) error {
+ gui.deactivateConfirmationPrompt()
+ return nil
+ },
+ gui.c,
+ )
}
-func (gui *Gui) getListContexts() []IListContext {
- return []IListContext{
+func (gui *Gui) getListContexts() []types.IListContext {
+ return []types.IListContext{
gui.State.Contexts.Menu,
gui.State.Contexts.Files,
gui.State.Contexts.Branches,
gui.State.Contexts.Remotes,
gui.State.Contexts.RemoteBranches,
gui.State.Contexts.Tags,
- gui.State.Contexts.BranchCommits,
+ gui.State.Contexts.LocalCommits,
gui.State.Contexts.ReflogCommits,
gui.State.Contexts.SubCommits,
gui.State.Contexts.Stash,
@@ -395,58 +295,3 @@ func (gui *Gui) getListContexts() []IListContext {
gui.State.Contexts.Suggestions,
}
}
-
-func (gui *Gui) getListContextKeyBindings() []*Binding {
- bindings := make([]*Binding, 0)
-
- keybindingConfig := gui.UserConfig.Keybinding
-
- for _, listContext := range gui.getListContexts() {
- listContext := listContext
-
- bindings = append(bindings, []*Binding{
- {ViewName: listContext.GetViewName(), Tag: "navigation", Contexts: []string{string(listContext.GetKey())}, Key: gui.getKey(keybindingConfig.Universal.PrevItemAlt), Modifier: gocui.ModNone, Handler: listContext.handlePrevLine},
- {ViewName: listContext.GetViewName(), Tag: "navigation", Contexts: []string{string(listContext.GetKey())}, Key: gui.getKey(keybindingConfig.Universal.PrevItem), Modifier: gocui.ModNone, Handler: listContext.handlePrevLine},
- {ViewName: listContext.GetViewName(), Tag: "navigation", Contexts: []string{string(listContext.GetKey())}, Key: gocui.MouseWheelUp, Modifier: gocui.ModNone, Handler: listContext.handlePrevLine},
- {ViewName: listContext.GetViewName(), Tag: "navigation", Contexts: []string{string(listContext.GetKey())}, Key: gui.getKey(keybindingConfig.Universal.NextItemAlt), Modifier: gocui.ModNone, Handler: listContext.handleNextLine},
- {ViewName: listContext.GetViewName(), Tag: "navigation", Contexts: []string{string(listContext.GetKey())}, Key: gui.getKey(keybindingConfig.Universal.NextItem), Modifier: gocui.ModNone, Handler: listContext.handleNextLine},
- {ViewName: listContext.GetViewName(), Tag: "navigation", Contexts: []string{string(listContext.GetKey())}, Key: gui.getKey(keybindingConfig.Universal.PrevPage), Modifier: gocui.ModNone, Handler: listContext.handlePrevPage, Description: gui.Tr.LcPrevPage},
- {ViewName: listContext.GetViewName(), Tag: "navigation", Contexts: []string{string(listContext.GetKey())}, Key: gui.getKey(keybindingConfig.Universal.NextPage), Modifier: gocui.ModNone, Handler: listContext.handleNextPage, Description: gui.Tr.LcNextPage},
- {ViewName: listContext.GetViewName(), Tag: "navigation", Contexts: []string{string(listContext.GetKey())}, Key: gui.getKey(keybindingConfig.Universal.GotoTop), Modifier: gocui.ModNone, Handler: listContext.handleGotoTop, Description: gui.Tr.LcGotoTop},
- {ViewName: listContext.GetViewName(), Tag: "navigation", Contexts: []string{string(listContext.GetKey())}, Key: gocui.MouseWheelDown, Modifier: gocui.ModNone, Handler: listContext.handleNextLine},
- {ViewName: listContext.GetViewName(), Contexts: []string{string(listContext.GetKey())}, Key: gocui.MouseLeft, Modifier: gocui.ModNone, Handler: listContext.handleClick},
- {ViewName: listContext.GetViewName(), Tag: "navigation", Contexts: []string{string(listContext.GetKey())}, Key: gui.getKey(keybindingConfig.Universal.ScrollLeft), Modifier: gocui.ModNone, Handler: listContext.handleScrollLeft},
- {ViewName: listContext.GetViewName(), Tag: "navigation", Contexts: []string{string(listContext.GetKey())}, Key: gui.getKey(keybindingConfig.Universal.ScrollRight), Modifier: gocui.ModNone, Handler: listContext.handleScrollRight},
- }...)
-
- openSearchHandler := gui.handleOpenSearch
- gotoBottomHandler := listContext.handleGotoBottom
-
- // the branch commits context needs to lazyload things so it has a couple of its own handlers
- if listContext.GetKey() == BRANCH_COMMITS_CONTEXT_KEY {
- openSearchHandler = gui.handleOpenSearchForCommitsPanel
- gotoBottomHandler = gui.handleGotoBottomForCommitsPanel
- }
-
- bindings = append(bindings, []*Binding{
- {
- ViewName: listContext.GetViewName(),
- Contexts: []string{string(listContext.GetKey())},
- Key: gui.getKey(keybindingConfig.Universal.StartSearch),
- Handler: func() error { return openSearchHandler(listContext.GetViewName()) },
- Description: gui.Tr.LcStartSearch,
- Tag: "navigation",
- },
- {
- ViewName: listContext.GetViewName(),
- Contexts: []string{string(listContext.GetKey())},
- Key: gui.getKey(keybindingConfig.Universal.GotoBottom),
- Handler: gotoBottomHandler,
- Description: gui.Tr.LcGotoBottom,
- Tag: "navigation",
- },
- }...)
- }
-
- return bindings
-}
diff --git a/pkg/gui/main_panels.go b/pkg/gui/main_panels.go
index 72af94b1d..625391480 100644
--- a/pkg/gui/main_panels.go
+++ b/pkg/gui/main_panels.go
@@ -1,150 +1,120 @@
package gui
import (
- "os/exec"
-
"github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
-type viewUpdateOpts struct {
- title string
+func (gui *Gui) runTaskForView(view *gocui.View, task types.UpdateTask) error {
+ switch v := task.(type) {
+ case *types.RenderStringTask:
+ return gui.newStringTask(view, v.Str)
- // awkwardly calling this noWrap because of how hard Go makes it to have
- // a boolean option that defaults to true
- noWrap bool
+ case *types.RenderStringWithoutScrollTask:
+ return gui.newStringTaskWithoutScroll(view, v.Str)
- highlight bool
+ case *types.RenderStringWithScrollTask:
+ return gui.newStringTaskWithScroll(view, v.Str, v.OriginX, v.OriginY)
- task updateTask
-}
+ case *types.RunCommandTask:
+ return gui.newCmdTask(view, v.Cmd, v.Prefix)
-type refreshMainOpts struct {
- main *viewUpdateOpts
- secondary *viewUpdateOpts
-}
-
-// constants for updateTask's kind field
-type TaskKind int
-
-const (
- RENDER_STRING TaskKind = iota
- RENDER_STRING_WITHOUT_SCROLL
- RUN_COMMAND
- RUN_PTY
-)
-
-type updateTask interface {
- GetKind() TaskKind
-}
-
-type renderStringTask struct {
- str string
-}
-
-func (t *renderStringTask) GetKind() TaskKind {
- return RENDER_STRING
-}
-
-func NewRenderStringTask(str string) *renderStringTask {
- return &renderStringTask{str: str}
-}
-
-type renderStringWithoutScrollTask struct {
- str string
-}
-
-func (t *renderStringWithoutScrollTask) GetKind() TaskKind {
- return RENDER_STRING_WITHOUT_SCROLL
-}
-
-func NewRenderStringWithoutScrollTask(str string) *renderStringWithoutScrollTask {
- return &renderStringWithoutScrollTask{str: str}
-}
-
-type runCommandTask struct {
- cmd *exec.Cmd
- prefix string
-}
-
-func (t *runCommandTask) GetKind() TaskKind {
- return RUN_COMMAND
-}
-
-func NewRunCommandTask(cmd *exec.Cmd) *runCommandTask {
- return &runCommandTask{cmd: cmd}
-}
-
-func NewRunCommandTaskWithPrefix(cmd *exec.Cmd, prefix string) *runCommandTask {
- return &runCommandTask{cmd: cmd, prefix: prefix}
-}
-
-type runPtyTask struct {
- cmd *exec.Cmd
- prefix string
-}
-
-func (t *runPtyTask) GetKind() TaskKind {
- return RUN_PTY
-}
-
-func NewRunPtyTask(cmd *exec.Cmd) *runPtyTask {
- return &runPtyTask{cmd: cmd}
-}
-
-// currently unused
-// func (gui *Gui) createRunPtyTaskWithPrefix(cmd *exec.Cmd, prefix string) *runPtyTask {
-// return &runPtyTask{cmd: cmd, prefix: prefix}
-// }
-
-func (gui *Gui) runTaskForView(view *gocui.View, task updateTask) error {
- switch task.GetKind() {
- case RENDER_STRING:
- specificTask := task.(*renderStringTask)
- return gui.newStringTask(view, specificTask.str)
-
- case RENDER_STRING_WITHOUT_SCROLL:
- specificTask := task.(*renderStringWithoutScrollTask)
- return gui.newStringTaskWithoutScroll(view, specificTask.str)
-
- case RUN_COMMAND:
- specificTask := task.(*runCommandTask)
- return gui.newCmdTask(view, specificTask.cmd, specificTask.prefix)
-
- case RUN_PTY:
- specificTask := task.(*runPtyTask)
- return gui.newPtyTask(view, specificTask.cmd, specificTask.prefix)
+ case *types.RunPtyTask:
+ return gui.newPtyTask(view, v.Cmd, v.Prefix)
}
return nil
}
-func (gui *Gui) refreshMainView(opts *viewUpdateOpts, view *gocui.View) error {
- view.Title = opts.title
- view.Wrap = !opts.noWrap
- view.Highlight = opts.highlight
+func (gui *Gui) moveMainContextPairToTop(pair types.MainContextPair) {
+ gui.setWindowContext(pair.Main)
+ gui.moveToTopOfWindow(pair.Main)
+ if pair.Secondary != nil {
+ gui.setWindowContext(pair.Secondary)
+ gui.moveToTopOfWindow(pair.Secondary)
+ }
+}
- if err := gui.runTaskForView(view, opts.task); err != nil {
- gui.Log.Error(err)
+func (gui *Gui) RefreshMainView(opts *types.ViewUpdateOpts, context types.Context) error {
+ view := context.GetView()
+
+ if opts.Title != "" {
+ view.Title = opts.Title
+ }
+
+ if err := gui.runTaskForView(view, opts.Task); err != nil {
+ gui.c.Log.Error(err)
return nil
}
return nil
}
-func (gui *Gui) refreshMainViews(opts refreshMainOpts) error {
- if opts.main != nil {
- if err := gui.refreshMainView(opts.main, gui.Views.Main); err != nil {
+func (gui *Gui) normalMainContextPair() types.MainContextPair {
+ return types.NewMainContextPair(
+ gui.State.Contexts.Normal,
+ gui.State.Contexts.NormalSecondary,
+ )
+}
+
+func (gui *Gui) stagingMainContextPair() types.MainContextPair {
+ return types.NewMainContextPair(
+ gui.State.Contexts.Staging,
+ gui.State.Contexts.StagingSecondary,
+ )
+}
+
+func (gui *Gui) patchBuildingMainContextPair() types.MainContextPair {
+ return types.NewMainContextPair(
+ gui.State.Contexts.CustomPatchBuilder,
+ gui.State.Contexts.CustomPatchBuilderSecondary,
+ )
+}
+
+func (gui *Gui) mergingMainContextPair() types.MainContextPair {
+ return types.NewMainContextPair(
+ gui.State.Contexts.MergeConflicts,
+ nil,
+ )
+}
+
+func (gui *Gui) allMainContextPairs() []types.MainContextPair {
+ return []types.MainContextPair{
+ gui.normalMainContextPair(),
+ gui.stagingMainContextPair(),
+ gui.patchBuildingMainContextPair(),
+ gui.mergingMainContextPair(),
+ }
+}
+
+func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) error {
+ // need to reset scroll positions of all other main views
+ for _, pair := range gui.allMainContextPairs() {
+ if pair.Main != opts.Pair.Main {
+ _ = pair.Main.GetView().SetOrigin(0, 0)
+ }
+ if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary {
+ _ = pair.Secondary.GetView().SetOrigin(0, 0)
+ }
+ }
+
+ if opts.Main != nil {
+ if err := gui.RefreshMainView(opts.Main, opts.Pair.Main); err != nil {
return err
}
}
- if opts.secondary != nil {
- if err := gui.refreshMainView(opts.secondary, gui.Views.Secondary); err != nil {
+ if opts.Secondary != nil {
+ if err := gui.RefreshMainView(opts.Secondary, opts.Pair.Secondary); err != nil {
return err
}
+ } else if opts.Pair.Secondary != nil {
+ opts.Pair.Secondary.GetView().Clear()
}
- gui.splitMainPanel(opts.secondary != nil)
+ gui.moveMainContextPairToTop(opts.Pair)
+
+ gui.splitMainPanel(opts.Secondary != nil)
return nil
}
diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go
index b32b3bf44..bc3d087c3 100644
--- a/pkg/gui/menu_panel.go
+++ b/pkg/gui/menu_panel.go
@@ -1,105 +1,79 @@
package gui
import (
- "errors"
"fmt"
- "strings"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
+ "github.com/jesseduffield/lazygit/pkg/gui/presentation"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
)
-type menuItem struct {
- displayString string
- displayStrings []string
- onPress func() error
- // only applies when displayString is used
- opensMenu bool
-}
-
-// every item in a list context needs an ID
-func (i *menuItem) ID() string {
- if i.displayString != "" {
- return i.displayString
- }
-
- return strings.Join(i.displayStrings, "-")
-}
-
-// specific functions
-
func (gui *Gui) getMenuOptions() map[string]string {
- keybindingConfig := gui.UserConfig.Keybinding
+ keybindingConfig := gui.c.UserConfig.Keybinding
return map[string]string{
- gui.getKeyDisplay(keybindingConfig.Universal.Return): gui.Tr.LcClose,
- fmt.Sprintf("%s %s", gui.getKeyDisplay(keybindingConfig.Universal.PrevItem), gui.getKeyDisplay(keybindingConfig.Universal.NextItem)): gui.Tr.LcNavigate,
- gui.getKeyDisplay(keybindingConfig.Universal.Select): gui.Tr.LcExecute,
+ keybindings.Label(keybindingConfig.Universal.Return): gui.c.Tr.LcClose,
+ fmt.Sprintf("%s %s", keybindings.Label(keybindingConfig.Universal.PrevItem), keybindings.Label(keybindingConfig.Universal.NextItem)): gui.c.Tr.LcNavigate,
+ keybindings.Label(keybindingConfig.Universal.Select): gui.c.Tr.LcExecute,
}
}
-func (gui *Gui) handleMenuClose() error {
- return gui.returnFromContext()
-}
-
-type createMenuOptions struct {
- showCancel bool
-}
-
-func (gui *Gui) createMenu(title string, items []*menuItem, createMenuOptions createMenuOptions) error {
- if createMenuOptions.showCancel {
+// note: items option is mutated by this function
+func (gui *Gui) createMenu(opts types.CreateMenuOptions) error {
+ if !opts.HideCancel {
// this is mutative but I'm okay with that for now
- items = append(items, &menuItem{
- displayStrings: []string{gui.Tr.LcCancel},
- onPress: func() error {
+ opts.Items = append(opts.Items, &types.MenuItem{
+ LabelColumns: []string{gui.c.Tr.LcCancel},
+ OnPress: func() error {
return nil
},
})
}
- gui.State.MenuItems = items
+ maxColumnSize := 1
- stringArrays := make([][]string, len(items))
- for i, item := range items {
- if item.opensMenu && item.displayStrings != nil {
- return errors.New("Message for the developer of this app: you've set opensMenu with displaystrings on the menu panel. Bad developer!. Apologies, user")
+ for _, item := range opts.Items {
+ if item.LabelColumns == nil {
+ item.LabelColumns = []string{item.Label}
}
- if item.displayStrings == nil {
- styledStr := item.displayString
- if item.opensMenu {
- styledStr = opensMenuStyle(styledStr)
- }
- stringArrays[i] = []string{styledStr}
- } else {
- stringArrays[i] = item.displayStrings
+ if item.OpensMenu {
+ item.LabelColumns[0] = presentation.OpensMenuStyle(item.LabelColumns[0])
+ }
+
+ maxColumnSize = utils.Max(maxColumnSize, len(item.LabelColumns))
+ }
+
+ for _, item := range opts.Items {
+ if len(item.LabelColumns) < maxColumnSize {
+ // we require that each item has the same number of columns so we're padding out with blank strings
+ // if this item has too few
+ item.LabelColumns = append(item.LabelColumns, make([]string, maxColumnSize-len(item.LabelColumns))...)
}
}
- list := utils.RenderDisplayStrings(stringArrays)
+ gui.State.Contexts.Menu.SetMenuItems(opts.Items)
+ gui.State.Contexts.Menu.SetSelectedLineIdx(0)
- x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(false, list)
- menuView, _ := gui.g.SetView("menu", x0, y0, x1, y1, 0)
- menuView.Title = title
- menuView.FgColor = theme.GocuiDefaultTextColor
- menuView.SetOnSelectItem(gui.onSelectItemWrapper(func(selectedLine int) error {
+ gui.Views.Menu.Title = opts.Title
+ gui.Views.Menu.FgColor = theme.GocuiDefaultTextColor
+ gui.Views.Menu.SetOnSelectItem(gui.onSelectItemWrapper(func(selectedLine int) error {
return nil
}))
- menuView.SetContent(list)
- gui.State.Panels.Menu.SelectedLineIdx = 0
- return gui.pushContext(gui.State.Contexts.Menu)
-}
+ gui.Views.Tooltip.Wrap = true
+ gui.Views.Tooltip.FgColor = theme.GocuiDefaultTextColor
+ gui.Views.Tooltip.Visible = true
-func (gui *Gui) onMenuPress() error {
- selectedLine := gui.State.Panels.Menu.SelectedLineIdx
- if err := gui.returnFromContext(); err != nil {
+ // resetting keybindings so that the menu-specific keybindings are registered
+ if err := gui.resetKeybindings(); err != nil {
return err
}
- if err := gui.State.MenuItems[selectedLine].onPress(); err != nil {
- return err
- }
+ _ = gui.c.PostRefreshUpdate(gui.State.Contexts.Menu)
- return nil
+ // TODO: ensure that if we're opened a menu from within a menu that it renders correctly
+ return gui.c.PushContext(gui.State.Contexts.Menu)
}
diff --git a/pkg/gui/merge_panel.go b/pkg/gui/merge_panel.go
deleted file mode 100644
index dbd5b3be7..000000000
--- a/pkg/gui/merge_panel.go
+++ /dev/null
@@ -1,304 +0,0 @@
-// though this panel is called the merge panel, it's really going to use the main panel. This may change in the future
-
-package gui
-
-import (
- "fmt"
- "io/ioutil"
- "math"
-
- "github.com/jesseduffield/gocui"
- "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
-)
-
-func (gui *Gui) handleSelectPrevConflictHunk() error {
- return gui.withMergeConflictLock(func() error {
- gui.takeOverMergeConflictScrolling()
- gui.State.Panels.Merging.SelectPrevConflictHunk()
- return gui.renderConflictsWithFocus()
- })
-}
-
-func (gui *Gui) handleSelectNextConflictHunk() error {
- return gui.withMergeConflictLock(func() error {
- gui.takeOverMergeConflictScrolling()
- gui.State.Panels.Merging.SelectNextConflictHunk()
- return gui.renderConflictsWithFocus()
- })
-}
-
-func (gui *Gui) handleSelectNextConflict() error {
- return gui.withMergeConflictLock(func() error {
- gui.takeOverMergeConflictScrolling()
- gui.State.Panels.Merging.SelectNextConflict()
- return gui.renderConflictsWithFocus()
- })
-}
-
-func (gui *Gui) handleSelectPrevConflict() error {
- return gui.withMergeConflictLock(func() error {
- gui.takeOverMergeConflictScrolling()
- gui.State.Panels.Merging.SelectPrevConflict()
- return gui.renderConflictsWithFocus()
- })
-}
-
-func (gui *Gui) handleMergeConflictUndo() error {
- state := gui.State.Panels.Merging
-
- ok := state.Undo()
- if !ok {
- return nil
- }
-
- gui.logAction("Restoring file to previous state")
- gui.logCommand("Undoing last conflict resolution", false)
- if err := ioutil.WriteFile(state.GetPath(), []byte(state.GetContent()), 0644); err != nil {
- return err
- }
-
- return gui.renderConflictsWithFocus()
-}
-
-func (gui *Gui) handlePickHunk() error {
- return gui.withMergeConflictLock(func() error {
- ok, err := gui.resolveConflict(gui.State.Panels.Merging.Selection())
- if err != nil {
- return err
- }
-
- if !ok {
- return nil
- }
-
- if gui.State.Panels.Merging.AllConflictsResolved() {
- return gui.onLastConflictResolved()
- }
-
- return gui.renderConflictsWithFocus()
- })
-}
-
-func (gui *Gui) handlePickAllHunks() error {
- return gui.withMergeConflictLock(func() error {
- ok, err := gui.resolveConflict(mergeconflicts.ALL)
- if err != nil {
- return err
- }
-
- if !ok {
- return nil
- }
-
- if gui.State.Panels.Merging.AllConflictsResolved() {
- return gui.onLastConflictResolved()
- }
-
- return gui.renderConflictsWithFocus()
- })
-}
-
-func (gui *Gui) resolveConflict(selection mergeconflicts.Selection) (bool, error) {
- gui.takeOverMergeConflictScrolling()
-
- state := gui.State.Panels.Merging
-
- ok, content, err := state.ContentAfterConflictResolve(selection)
- if err != nil {
- return false, err
- }
-
- if !ok {
- return false, nil
- }
-
- var logStr string
- switch selection {
- case mergeconflicts.TOP:
- logStr = "Picking top hunk"
- case mergeconflicts.MIDDLE:
- logStr = "Picking middle hunk"
- case mergeconflicts.BOTTOM:
- logStr = "Picking bottom hunk"
- case mergeconflicts.ALL:
- logStr = "Picking all hunks"
- }
- gui.logAction("Resolve merge conflict")
- gui.logCommand(logStr, false)
- state.PushContent(content)
- return true, ioutil.WriteFile(state.GetPath(), []byte(content), 0644)
-}
-
-// precondition: we actually have conflicts to render
-func (gui *Gui) renderConflicts(hasFocus bool) error {
- state := gui.State.Panels.Merging.State
- content := mergeconflicts.ColoredConflictFile(state, hasFocus)
-
- if !gui.State.Panels.Merging.UserVerticalScrolling {
- // TODO: find a way to not have to do this OnUIThread thing. Why doesn't it work
- // without it given that we're calling the 'no scroll' variant below?
- gui.OnUIThread(func() error {
- gui.State.Panels.Merging.Lock()
- defer gui.State.Panels.Merging.Unlock()
-
- if !state.Active() {
- return nil
- }
-
- gui.centerYPos(gui.Views.Main, state.GetConflictMiddle())
- return nil
- })
- }
-
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: gui.Tr.MergeConflictsTitle,
- task: NewRenderStringWithoutScrollTask(content),
- noWrap: true,
- },
- })
-}
-
-func (gui *Gui) renderConflictsWithFocus() error {
- return gui.renderConflicts(true)
-}
-
-func (gui *Gui) renderConflictsWithLock(hasFocus bool) error {
- return gui.withMergeConflictLock(func() error {
- return gui.renderConflicts(hasFocus)
- })
-}
-
-func (gui *Gui) centerYPos(view *gocui.View, y int) {
- ox, _ := view.Origin()
- _, height := view.Size()
- newOriginY := int(math.Max(0, float64(y-(height/2))))
- _ = view.SetOrigin(ox, newOriginY)
-}
-
-func (gui *Gui) getMergingOptions() map[string]string {
- keybindingConfig := gui.UserConfig.Keybinding
-
- return map[string]string{
- fmt.Sprintf("%s %s", gui.getKeyDisplay(keybindingConfig.Universal.PrevItem), gui.getKeyDisplay(keybindingConfig.Universal.NextItem)): gui.Tr.LcSelectHunk,
- fmt.Sprintf("%s %s", gui.getKeyDisplay(keybindingConfig.Universal.PrevBlock), gui.getKeyDisplay(keybindingConfig.Universal.NextBlock)): gui.Tr.LcNavigateConflicts,
- gui.getKeyDisplay(keybindingConfig.Universal.Select): gui.Tr.LcPickHunk,
- gui.getKeyDisplay(keybindingConfig.Main.PickBothHunks): gui.Tr.LcPickAllHunks,
- gui.getKeyDisplay(keybindingConfig.Universal.Undo): gui.Tr.LcUndo,
- }
-}
-
-func (gui *Gui) handleEscapeMerge() error {
- if err := gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}}); err != nil {
- return err
- }
-
- return gui.escapeMerge()
-}
-
-func (gui *Gui) onLastConflictResolved() error {
- // as part of refreshing files, we handle the situation where a file has had
- // its merge conflicts resolved.
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
-}
-
-func (gui *Gui) resetMergeState() {
- gui.takeOverMergeConflictScrolling()
- gui.State.Panels.Merging.Reset()
-}
-
-func (gui *Gui) setMergeState(path string) (bool, error) {
- content, err := gui.Git.File.Cat(path)
- if err != nil {
- return false, err
- }
-
- gui.State.Panels.Merging.SetContent(content, path)
-
- return !gui.State.Panels.Merging.NoConflicts(), nil
-}
-
-func (gui *Gui) setMergeStateWithLock(path string) (bool, error) {
- gui.State.Panels.Merging.Lock()
- defer gui.State.Panels.Merging.Unlock()
-
- return gui.setMergeState(path)
-}
-
-func (gui *Gui) resetMergeStateWithLock() {
- gui.State.Panels.Merging.Lock()
- defer gui.State.Panels.Merging.Unlock()
-
- gui.resetMergeState()
-}
-
-func (gui *Gui) escapeMerge() error {
- gui.resetMergeState()
-
- // doing this in separate UI thread so that we're not still holding the lock by the time refresh the file
- gui.OnUIThread(func() error {
- return gui.pushContext(gui.State.Contexts.Files)
- })
- return nil
-}
-
-func (gui *Gui) renderingConflicts() bool {
- currentView := gui.g.CurrentView()
- if currentView != gui.Views.Main && currentView != gui.Views.Files {
- return false
- }
-
- return gui.State.Panels.Merging.Active()
-}
-
-func (gui *Gui) withMergeConflictLock(f func() error) error {
- gui.State.Panels.Merging.Lock()
- defer gui.State.Panels.Merging.Unlock()
-
- return f()
-}
-
-func (gui *Gui) takeOverMergeConflictScrolling() {
- gui.State.Panels.Merging.UserVerticalScrolling = false
-}
-
-func (gui *Gui) setConflictsAndRender(path string, hasFocus bool) (bool, error) {
- hasConflicts, err := gui.setMergeState(path)
- if err != nil {
- return false, err
- }
-
- // if we don't have conflicts we'll fall through and show the diff
- if hasConflicts {
- return true, gui.renderConflicts(hasFocus)
- }
-
- return false, nil
-}
-
-func (gui *Gui) setConflictsAndRenderWithLock(path string, hasFocus bool) (bool, error) {
- gui.State.Panels.Merging.Lock()
- defer gui.State.Panels.Merging.Unlock()
-
- return gui.setConflictsAndRender(path, hasFocus)
-}
-
-func (gui *Gui) refreshMergeState() error {
- gui.State.Panels.Merging.Lock()
- defer gui.State.Panels.Merging.Unlock()
-
- if gui.currentContext().GetKey() != MAIN_MERGING_CONTEXT_KEY {
- return nil
- }
-
- hasConflicts, err := gui.setConflictsAndRender(gui.State.Panels.Merging.GetPath(), true)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- if !hasConflicts {
- return gui.escapeMerge()
- }
-
- return nil
-}
diff --git a/pkg/gui/mergeconflicts/find_conflicts.go b/pkg/gui/mergeconflicts/find_conflicts.go
index 14a08fd68..3802a66b7 100644
--- a/pkg/gui/mergeconflicts/find_conflicts.go
+++ b/pkg/gui/mergeconflicts/find_conflicts.go
@@ -57,10 +57,12 @@ func findConflicts(content string) []*mergeConflict {
return conflicts
}
-var CONFLICT_START = "<<<<<<< "
-var CONFLICT_END = ">>>>>>> "
-var CONFLICT_START_BYTES = []byte(CONFLICT_START)
-var CONFLICT_END_BYTES = []byte(CONFLICT_END)
+var (
+ CONFLICT_START = "<<<<<<< "
+ CONFLICT_END = ">>>>>>> "
+ CONFLICT_START_BYTES = []byte(CONFLICT_START)
+ CONFLICT_END_BYTES = []byte(CONFLICT_END)
+)
func determineLineType(line string) LineType {
// TODO: find out whether we ever actually get this prefix
diff --git a/pkg/gui/mergeconflicts/state.go b/pkg/gui/mergeconflicts/state.go
index d84f05545..384fb735f 100644
--- a/pkg/gui/mergeconflicts/state.go
+++ b/pkg/gui/mergeconflicts/state.go
@@ -1,14 +1,11 @@
package mergeconflicts
import (
- "sync"
-
"github.com/jesseduffield/lazygit/pkg/utils"
)
+// State represents the selection state of the merge conflict context.
type State struct {
- sync.Mutex
-
// path of the file with the conflicts
path string
@@ -27,7 +24,6 @@ type State struct {
func NewState() *State {
return &State{
- Mutex: sync.Mutex{},
conflictIndex: 0,
selectionIndex: 0,
conflicts: []*mergeConflict{},
@@ -39,14 +35,14 @@ func (s *State) setConflictIndex(index int) {
if len(s.conflicts) == 0 {
s.conflictIndex = 0
} else {
- s.conflictIndex = clamp(index, 0, len(s.conflicts)-1)
+ s.conflictIndex = utils.Clamp(index, 0, len(s.conflicts)-1)
}
s.setSelectionIndex(s.selectionIndex)
}
func (s *State) setSelectionIndex(index int) {
if selections := s.availableSelections(); len(selections) != 0 {
- s.selectionIndex = clamp(index, 0, len(selections)-1)
+ s.selectionIndex = utils.Clamp(index, 0, len(selections)-1)
}
}
@@ -150,6 +146,12 @@ func (s *State) Reset() {
s.path = ""
}
+// we're not resetting selectedIndex here because the user typically would want
+// to pick either all top hunks or all bottom hunks so we retain that selection
+func (s *State) ResetConflictSelection() {
+ s.conflictIndex = 0
+}
+
func (s *State) Active() bool {
return s.path != ""
}
@@ -176,7 +178,6 @@ func (s *State) ContentAfterConflictResolve(selection Selection) (bool, string,
content += line
}
})
-
if err != nil {
return false, "", err
}
@@ -184,11 +185,12 @@ func (s *State) ContentAfterConflictResolve(selection Selection) (bool, string,
return true, content, nil
}
-func clamp(x int, min int, max int) int {
- if x < min {
- return min
- } else if x > max {
- return max
+func (s *State) GetSelectedLine() int {
+ conflict := s.currentConflict()
+ if conflict == nil {
+ return 1
}
- return x
+ selection := s.Selection()
+ startIndex, _ := selection.bounds(conflict)
+ return startIndex + 1
}
diff --git a/pkg/gui/modes.go b/pkg/gui/modes.go
index b60fafc8a..3a3bc51a6 100644
--- a/pkg/gui/modes.go
+++ b/pkg/gui/modes.go
@@ -21,7 +21,7 @@ func (gui *Gui) modeStatuses() []modeStatus {
return gui.withResetButton(
fmt.Sprintf(
"%s %s",
- gui.Tr.LcShowingGitDiff,
+ gui.c.Tr.LcShowingGitDiff,
"git diff "+gui.diffStr(),
),
style.FgMagenta,
@@ -30,11 +30,11 @@ func (gui *Gui) modeStatuses() []modeStatus {
reset: gui.exitDiffMode,
},
{
- isActive: gui.Git.Patch.PatchManager.Active,
+ isActive: gui.git.Patch.PatchManager.Active,
description: func() string {
- return gui.withResetButton(gui.Tr.LcBuildingPatch, style.FgYellow.SetBold())
+ return gui.withResetButton(gui.c.Tr.LcBuildingPatch, style.FgYellow.SetBold())
},
- reset: gui.handleResetPatch,
+ reset: gui.helpers.PatchBuilding.Reset,
},
{
isActive: gui.State.Modes.Filtering.Active,
@@ -42,7 +42,7 @@ func (gui *Gui) modeStatuses() []modeStatus {
return gui.withResetButton(
fmt.Sprintf(
"%s '%s'",
- gui.Tr.LcFilteringBy,
+ gui.c.Tr.LcFilteringBy,
gui.State.Modes.Filtering.GetPath(),
),
style.FgRed,
@@ -61,28 +61,28 @@ func (gui *Gui) modeStatuses() []modeStatus {
style.FgCyan,
)
},
- reset: gui.exitCherryPickingMode,
+ reset: gui.helpers.CherryPick.Reset,
},
{
isActive: func() bool {
- return gui.Git.Status.WorkingTreeState() != enums.REBASE_MODE_NONE
+ return gui.git.Status.WorkingTreeState() != enums.REBASE_MODE_NONE
},
description: func() string {
- workingTreeState := gui.Git.Status.WorkingTreeState()
+ workingTreeState := gui.git.Status.WorkingTreeState()
return gui.withResetButton(
formatWorkingTreeState(workingTreeState), style.FgYellow,
)
},
- reset: gui.abortMergeOrRebaseWithConfirm,
+ reset: gui.helpers.MergeAndRebase.AbortMergeOrRebaseWithConfirm,
},
{
isActive: func() bool {
- return gui.State.BisectInfo.Started()
+ return gui.State.Model.BisectInfo.Started()
},
description: func() string {
return gui.withResetButton("bisecting", style.FgGreen)
},
- reset: gui.resetBisect,
+ reset: gui.helpers.Bisect.Reset,
},
}
}
@@ -91,6 +91,6 @@ func (gui *Gui) withResetButton(content string, textStyle style.TextStyle) strin
return textStyle.Sprintf(
"%s %s",
content,
- style.AttrUnderline.Sprint(gui.Tr.ResetInParentheses),
+ style.AttrUnderline.Sprint(gui.c.Tr.ResetInParentheses),
)
}
diff --git a/pkg/gui/modes/cherrypicking/cherry_picking.go b/pkg/gui/modes/cherrypicking/cherry_picking.go
index 705735510..bd5c6437a 100644
--- a/pkg/gui/modes/cherrypicking/cherry_picking.go
+++ b/pkg/gui/modes/cherrypicking/cherry_picking.go
@@ -11,8 +11,8 @@ type CherryPicking struct {
ContextKey string
}
-func New() CherryPicking {
- return CherryPicking{
+func New() *CherryPicking {
+ return &CherryPicking{
CherryPickedCommits: make([]*models.Commit, 0),
ContextKey: "",
}
diff --git a/pkg/gui/modes/diffing/diffing.go b/pkg/gui/modes/diffing/diffing.go
index a5e103d62..aa13bd1c1 100644
--- a/pkg/gui/modes/diffing/diffing.go
+++ b/pkg/gui/modes/diffing/diffing.go
@@ -10,6 +10,19 @@ func New() Diffing {
return Diffing{}
}
-func (m *Diffing) Active() bool {
- return m.Ref != ""
+func (self *Diffing) Active() bool {
+ return self.Ref != ""
+}
+
+// GetFromAndReverseArgsForDiff tells us the from and reverse args to be used in a diff command.
+// If we're not in diff mode we'll end up with the equivalent of a `git show` i.e `git diff blah^..blah`.
+func (self *Diffing) GetFromAndReverseArgsForDiff(from string) (string, bool) {
+ reverse := false
+
+ if self.Active() {
+ reverse = self.Reverse
+ from = self.Ref
+ }
+
+ return from, reverse
}
diff --git a/pkg/gui/options_menu_panel.go b/pkg/gui/options_menu_panel.go
index 12507597f..909c97fc8 100644
--- a/pkg/gui/options_menu_panel.go
+++ b/pkg/gui/options_menu_panel.go
@@ -1,76 +1,77 @@
package gui
import (
- "strings"
+ "log"
- "github.com/jesseduffield/gocui"
- "github.com/jesseduffield/lazygit/pkg/gui/style"
- "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/samber/lo"
)
-func (gui *Gui) getBindings(v *gocui.View) []*Binding {
- var (
- bindingsGlobal, bindingsPanel []*Binding
- )
+func (gui *Gui) getBindings(context types.Context) []*types.Binding {
+ var bindingsGlobal, bindingsPanel, bindingsNavigation []*types.Binding
- bindings := append(gui.GetCustomCommandKeybindings(), gui.GetInitialKeybindings()...)
+ bindings, _ := gui.GetInitialKeybindings()
+ customBindings, err := gui.CustomCommandsClient.GetCustomCommandKeybindings()
+ if err != nil {
+ log.Fatal(err)
+ }
+ bindings = append(customBindings, bindings...)
for _, binding := range bindings {
- if GetKeyDisplay(binding.Key) != "" && binding.Description != "" {
- switch binding.ViewName {
- case "":
+ if keybindings.LabelFromKey(binding.Key) != "" && binding.Description != "" {
+ if binding.ViewName == "" {
bindingsGlobal = append(bindingsGlobal, binding)
- case v.Name():
- if len(binding.Contexts) == 0 || utils.IncludesString(binding.Contexts, v.Context) {
- bindingsPanel = append(bindingsPanel, binding)
- }
+ } else if binding.Tag == "navigation" {
+ bindingsNavigation = append(bindingsNavigation, binding)
+ } else if binding.ViewName == context.GetViewName() {
+ bindingsPanel = append(bindingsPanel, binding)
}
}
}
- // append dummy element to have a separator between
- // panel and global keybindings
- bindingsPanel = append(bindingsPanel, &Binding{})
- return append(bindingsPanel, bindingsGlobal...)
+ resultBindings := []*types.Binding{}
+ resultBindings = append(resultBindings, uniqueBindings(bindingsPanel)...)
+ // adding a separator between the panel-specific bindings and the other bindings
+ resultBindings = append(resultBindings, &types.Binding{})
+ resultBindings = append(resultBindings, uniqueBindings(bindingsGlobal)...)
+ resultBindings = append(resultBindings, uniqueBindings(bindingsNavigation)...)
+
+ return resultBindings
}
-func (gui *Gui) displayDescription(binding *Binding) string {
- if binding.OpensMenu {
- return opensMenuStyle(binding.Description)
- }
-
- return style.FgCyan.Sprint(binding.Description)
-}
-
-func opensMenuStyle(str string) string {
- return style.FgMagenta.Sprintf("%s...", str)
+// We shouldn't really need to do this. We should define alternative keys for the same
+// handler in the keybinding struct.
+func uniqueBindings(bindings []*types.Binding) []*types.Binding {
+ return lo.UniqBy(bindings, func(binding *types.Binding) string {
+ return binding.Description
+ })
}
func (gui *Gui) handleCreateOptionsMenu() error {
- view := gui.g.CurrentView()
- if view == nil {
- return nil
- }
+ context := gui.currentContext()
+ bindings := gui.getBindings(context)
- bindings := gui.getBindings(view)
-
- menuItems := make([]*menuItem, len(bindings))
-
- for i, binding := range bindings {
- binding := binding // note to self, never close over loop variables
- menuItems[i] = &menuItem{
- displayStrings: []string{GetKeyDisplay(binding.Key), gui.displayDescription(binding)},
- onPress: func() error {
+ menuItems := slices.Map(bindings, func(binding *types.Binding) *types.MenuItem {
+ return &types.MenuItem{
+ OpensMenu: binding.OpensMenu,
+ Label: binding.Description,
+ OnPress: func() error {
if binding.Key == nil {
return nil
}
- if err := gui.handleMenuClose(); err != nil {
- return err
- }
+
return binding.Handler()
},
+ Key: binding.Key,
+ Tooltip: binding.Tooltip,
}
- }
+ })
- return gui.createMenu(strings.Title(gui.Tr.LcMenu), menuItems, createMenuOptions{})
+ return gui.c.Menu(types.CreateMenuOptions{
+ Title: gui.c.Tr.MenuTitle,
+ Items: menuItems,
+ HideCancel: true,
+ })
}
diff --git a/pkg/gui/patch_building_panel.go b/pkg/gui/patch_building_panel.go
deleted file mode 100644
index eb7728100..000000000
--- a/pkg/gui/patch_building_panel.go
+++ /dev/null
@@ -1,144 +0,0 @@
-package gui
-
-import (
- "github.com/jesseduffield/lazygit/pkg/utils"
-)
-
-// getFromAndReverseArgsForDiff tells us the from and reverse args to be used in a diff command. If we're not in diff mode we'll end up with the equivalent of a `git show` i.e `git diff blah^..blah`.
-func (gui *Gui) getFromAndReverseArgsForDiff(to string) (string, bool) {
- from := to + "^"
- reverse := false
-
- if gui.State.Modes.Diffing.Active() {
- reverse = gui.State.Modes.Diffing.Reverse
- from = gui.State.Modes.Diffing.Ref
- }
-
- return from, reverse
-}
-
-func (gui *Gui) refreshPatchBuildingPanel(selectedLineIdx int) error {
- if !gui.Git.Patch.PatchManager.Active() {
- return gui.handleEscapePatchBuildingPanel()
- }
-
- gui.Views.Main.Title = "Patch"
- gui.Views.Secondary.Title = "Custom Patch"
-
- // get diff from commit file that's currently selected
- node := gui.getSelectedCommitFileNode()
- if node == nil {
- return nil
- }
-
- to := gui.State.CommitFileTreeViewModel.GetParent()
- from, reverse := gui.getFromAndReverseArgsForDiff(to)
- diff, err := gui.Git.WorkingTree.ShowFileDiff(from, to, reverse, node.GetPath(), true)
- if err != nil {
- return err
- }
-
- secondaryDiff := gui.Git.Patch.PatchManager.RenderPatchForFile(node.GetPath(), true, false, true)
- if err != nil {
- return err
- }
-
- empty, err := gui.refreshLineByLinePanel(diff, secondaryDiff, false, selectedLineIdx)
- if err != nil {
- return err
- }
-
- if empty {
- return gui.handleEscapePatchBuildingPanel()
- }
-
- return nil
-}
-
-func (gui *Gui) handleRefreshPatchBuildingPanel(selectedLineIdx int) error {
- gui.Mutexes.LineByLinePanelMutex.Lock()
- defer gui.Mutexes.LineByLinePanelMutex.Unlock()
-
- return gui.refreshPatchBuildingPanel(selectedLineIdx)
-}
-
-func (gui *Gui) onPatchBuildingFocus(selectedLineIdx int) error {
- gui.Mutexes.LineByLinePanelMutex.Lock()
- defer gui.Mutexes.LineByLinePanelMutex.Unlock()
-
- if gui.State.Panels.LineByLine == nil || selectedLineIdx != -1 {
- return gui.refreshPatchBuildingPanel(selectedLineIdx)
- }
-
- return nil
-}
-
-func (gui *Gui) handleToggleSelectionForPatch() error {
- err := gui.withLBLActiveCheck(func(state *LblPanelState) error {
- toggleFunc := gui.Git.Patch.PatchManager.AddFileLineRange
- filename := gui.getSelectedCommitFileName()
- includedLineIndices, err := gui.Git.Patch.PatchManager.GetFileIncLineIndices(filename)
- if err != nil {
- return err
- }
- currentLineIsStaged := utils.IncludesInt(includedLineIndices, state.GetSelectedLineIdx())
- if currentLineIsStaged {
- toggleFunc = gui.Git.Patch.PatchManager.RemoveFileLineRange
- }
-
- // add range of lines to those set for the file
- node := gui.getSelectedCommitFileNode()
- if node == nil {
- return nil
- }
-
- firstLineIdx, lastLineIdx := state.SelectedRange()
-
- if err := toggleFunc(node.GetPath(), firstLineIdx, lastLineIdx); err != nil {
- // might actually want to return an error here
- gui.Log.Error(err)
- }
-
- return nil
- })
-
- if err != nil {
- return err
- }
-
- if err := gui.refreshCommitFilesView(); err != nil {
- return err
- }
-
- return nil
-}
-
-func (gui *Gui) handleEscapePatchBuildingPanel() error {
- gui.escapeLineByLinePanel()
-
- if gui.Git.Patch.PatchManager.IsEmpty() {
- gui.Git.Patch.PatchManager.Reset()
- }
-
- if gui.currentContext().GetKey() == gui.State.Contexts.PatchBuilding.GetKey() {
- return gui.pushContext(gui.State.Contexts.CommitFiles)
- } else {
- // need to re-focus in case the secondary view should now be hidden
- return gui.currentContext().HandleFocus()
- }
-}
-
-func (gui *Gui) secondaryPatchPanelUpdateOpts() *viewUpdateOpts {
- if gui.Git.Patch.PatchManager.Active() {
- patch := gui.Git.Patch.PatchManager.RenderAggregatedPatchColored(false)
-
- return &viewUpdateOpts{
- title: "Custom Patch",
- noWrap: true,
- highlight: true,
- task: NewRenderStringWithoutScrollTask(patch),
- }
- }
-
- return nil
-}
diff --git a/pkg/gui/lbl/focus.go b/pkg/gui/patch_exploring/focus.go
similarity index 98%
rename from pkg/gui/lbl/focus.go
rename to pkg/gui/patch_exploring/focus.go
index 780551e5d..cf0908999 100644
--- a/pkg/gui/lbl/focus.go
+++ b/pkg/gui/patch_exploring/focus.go
@@ -1,4 +1,4 @@
-package lbl
+package patch_exploring
import "github.com/jesseduffield/lazygit/pkg/utils"
diff --git a/pkg/gui/lbl/focus_test.go b/pkg/gui/patch_exploring/focus_test.go
similarity index 98%
rename from pkg/gui/lbl/focus_test.go
rename to pkg/gui/patch_exploring/focus_test.go
index f36191ce5..eb3ed7c66 100644
--- a/pkg/gui/lbl/focus_test.go
+++ b/pkg/gui/patch_exploring/focus_test.go
@@ -1,4 +1,4 @@
-package lbl
+package patch_exploring
import (
"testing"
diff --git a/pkg/gui/lbl/state.go b/pkg/gui/patch_exploring/state.go
similarity index 84%
rename from pkg/gui/lbl/state.go
rename to pkg/gui/patch_exploring/state.go
index 8ff7d7e88..008338326 100644
--- a/pkg/gui/lbl/state.go
+++ b/pkg/gui/patch_exploring/state.go
@@ -1,10 +1,13 @@
-package lbl
+package patch_exploring
import (
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/sirupsen/logrus"
)
+// State represents the current state of the patch explorer context i.e. when
+// you're staging a file or you're building a patch from an existing commit
+// this struct holds the info about the diff you're interacting with and what's currently selected.
type State struct {
selectedLineIdx int
rangeStartLineIdx int
@@ -23,6 +26,13 @@ const (
)
func NewState(diff string, selectedLineIdx int, oldState *State, log *logrus.Entry) *State {
+ if oldState != nil && diff == oldState.diff && selectedLineIdx == -1 {
+ // if we're here then we can return the old state. If selectedLineIdx was not -1
+ // then that would mean we were trying to click and potentiall drag a range, which
+ // is why in that case we continue below
+ return oldState
+ }
+
patchParser := patch.NewPatchParser(log, diff)
if len(patchParser.StageableLines) == 0 {
@@ -175,14 +185,14 @@ func (s *State) AdjustSelectedLineIdx(change int) {
s.SelectLine(s.selectedLineIdx + change)
}
-func (s *State) RenderForLineIndices(includedLineIndices []int) string {
+func (s *State) RenderForLineIndices(isFocused bool, includedLineIndices []int) string {
firstLineIdx, lastLineIdx := s.SelectedRange()
- return s.patchParser.Render(firstLineIdx, lastLineIdx, includedLineIndices)
+ return s.patchParser.Render(isFocused, firstLineIdx, lastLineIdx, includedLineIndices)
}
func (s *State) PlainRenderSelected() string {
firstLineIdx, lastLineIdx := s.SelectedRange()
- return s.patchParser.PlainRenderLines(firstLineIdx, lastLineIdx)
+ return s.patchParser.RenderLinesPlain(firstLineIdx, lastLineIdx)
}
func (s *State) SelectBottom() {
diff --git a/pkg/gui/patch_options_panel.go b/pkg/gui/patch_options_panel.go
deleted file mode 100644
index c9a3defce..000000000
--- a/pkg/gui/patch_options_panel.go
+++ /dev/null
@@ -1,196 +0,0 @@
-package gui
-
-import (
- "fmt"
-
- "github.com/jesseduffield/lazygit/pkg/commands/types/enums"
-)
-
-func (gui *Gui) handleCreatePatchOptionsMenu() error {
- if !gui.Git.Patch.PatchManager.Active() {
- return gui.createErrorPanel(gui.Tr.NoPatchError)
- }
-
- menuItems := []*menuItem{
- {
- displayString: "reset patch",
- onPress: gui.handleResetPatch,
- },
- {
- displayString: "apply patch",
- onPress: func() error { return gui.handleApplyPatch(false) },
- },
- {
- displayString: "apply patch in reverse",
- onPress: func() error { return gui.handleApplyPatch(true) },
- },
- }
-
- if gui.Git.Patch.PatchManager.CanRebase && gui.Git.Status.WorkingTreeState() == enums.REBASE_MODE_NONE {
- menuItems = append(menuItems, []*menuItem{
- {
- displayString: fmt.Sprintf("remove patch from original commit (%s)", gui.Git.Patch.PatchManager.To),
- onPress: gui.handleDeletePatchFromCommit,
- },
- {
- displayString: "move patch out into index",
- onPress: gui.handleMovePatchIntoWorkingTree,
- },
- {
- displayString: "move patch into new commit",
- onPress: gui.handlePullPatchIntoNewCommit,
- },
- }...)
-
- if gui.currentContext().GetKey() == gui.State.Contexts.BranchCommits.GetKey() {
- selectedCommit := gui.getSelectedLocalCommit()
- if selectedCommit != nil && gui.Git.Patch.PatchManager.To != selectedCommit.Sha {
- // adding this option to index 1
- menuItems = append(
- menuItems[:1],
- append(
- []*menuItem{
- {
- displayString: fmt.Sprintf("move patch to selected commit (%s)", selectedCommit.Sha),
- onPress: gui.handleMovePatchToSelectedCommit,
- },
- }, menuItems[1:]...,
- )...,
- )
- }
- }
- }
-
- return gui.createMenu(gui.Tr.PatchOptionsTitle, menuItems, createMenuOptions{showCancel: true})
-}
-
-func (gui *Gui) getPatchCommitIndex() int {
- for index, commit := range gui.State.Commits {
- if commit.Sha == gui.Git.Patch.PatchManager.To {
- return index
- }
- }
- return -1
-}
-
-func (gui *Gui) validateNormalWorkingTreeState() (bool, error) {
- if gui.Git.Status.WorkingTreeState() != enums.REBASE_MODE_NONE {
- return false, gui.createErrorPanel(gui.Tr.CantPatchWhileRebasingError)
- }
- return true, nil
-}
-
-func (gui *Gui) returnFocusFromLineByLinePanelIfNecessary() error {
- if gui.State.MainContext == MAIN_PATCH_BUILDING_CONTEXT_KEY {
- return gui.handleEscapePatchBuildingPanel()
- }
- return nil
-}
-
-func (gui *Gui) handleDeletePatchFromCommit() error {
- if ok, err := gui.validateNormalWorkingTreeState(); !ok {
- return err
- }
-
- if err := gui.returnFocusFromLineByLinePanelIfNecessary(); err != nil {
- return err
- }
-
- return gui.WithWaitingStatus(gui.Tr.RebasingStatus, func() error {
- commitIndex := gui.getPatchCommitIndex()
- gui.logAction(gui.Tr.Actions.RemovePatchFromCommit)
- err := gui.Git.Patch.DeletePatchesFromCommit(gui.State.Commits, commitIndex)
- return gui.handleGenericMergeCommandResult(err)
- })
-}
-
-func (gui *Gui) handleMovePatchToSelectedCommit() error {
- if ok, err := gui.validateNormalWorkingTreeState(); !ok {
- return err
- }
-
- if err := gui.returnFocusFromLineByLinePanelIfNecessary(); err != nil {
- return err
- }
-
- return gui.WithWaitingStatus(gui.Tr.RebasingStatus, func() error {
- commitIndex := gui.getPatchCommitIndex()
- gui.logAction(gui.Tr.Actions.MovePatchToSelectedCommit)
- err := gui.Git.Patch.MovePatchToSelectedCommit(gui.State.Commits, commitIndex, gui.State.Panels.Commits.SelectedLineIdx)
- return gui.handleGenericMergeCommandResult(err)
- })
-}
-
-func (gui *Gui) handleMovePatchIntoWorkingTree() error {
- if ok, err := gui.validateNormalWorkingTreeState(); !ok {
- return err
- }
-
- if err := gui.returnFocusFromLineByLinePanelIfNecessary(); err != nil {
- return err
- }
-
- pull := func(stash bool) error {
- return gui.WithWaitingStatus(gui.Tr.RebasingStatus, func() error {
- commitIndex := gui.getPatchCommitIndex()
- gui.logAction(gui.Tr.Actions.MovePatchIntoIndex)
- err := gui.Git.Patch.MovePatchIntoIndex(gui.State.Commits, commitIndex, stash)
- return gui.handleGenericMergeCommandResult(err)
- })
- }
-
- if len(gui.trackedFiles()) > 0 {
- return gui.ask(askOpts{
- title: gui.Tr.MustStashTitle,
- prompt: gui.Tr.MustStashWarning,
- handleConfirm: func() error {
- return pull(true)
- },
- })
- } else {
- return pull(false)
- }
-}
-
-func (gui *Gui) handlePullPatchIntoNewCommit() error {
- if ok, err := gui.validateNormalWorkingTreeState(); !ok {
- return err
- }
-
- if err := gui.returnFocusFromLineByLinePanelIfNecessary(); err != nil {
- return err
- }
-
- return gui.WithWaitingStatus(gui.Tr.RebasingStatus, func() error {
- commitIndex := gui.getPatchCommitIndex()
- gui.logAction(gui.Tr.Actions.MovePatchIntoNewCommit)
- err := gui.Git.Patch.PullPatchIntoNewCommit(gui.State.Commits, commitIndex)
- return gui.handleGenericMergeCommandResult(err)
- })
-}
-
-func (gui *Gui) handleApplyPatch(reverse bool) error {
- if err := gui.returnFocusFromLineByLinePanelIfNecessary(); err != nil {
- return err
- }
-
- action := gui.Tr.Actions.ApplyPatch
- if reverse {
- action = "Apply patch in reverse"
- }
- gui.logAction(action)
- if err := gui.Git.Patch.PatchManager.ApplyPatches(reverse); err != nil {
- return gui.surfaceError(err)
- }
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
-}
-
-func (gui *Gui) handleResetPatch() error {
- gui.Git.Patch.PatchManager.Reset()
- if gui.currentContextKeyIgnoringPopups() == MAIN_PATCH_BUILDING_CONTEXT_KEY {
- if err := gui.pushContext(gui.State.Contexts.CommitFiles); err != nil {
- return err
- }
- }
- return gui.refreshCommitFilesView()
-}
diff --git a/pkg/gui/popup/fake_popup_handler.go b/pkg/gui/popup/fake_popup_handler.go
new file mode 100644
index 000000000..95b0a3b1d
--- /dev/null
+++ b/pkg/gui/popup/fake_popup_handler.go
@@ -0,0 +1,51 @@
+package popup
+
+import "github.com/jesseduffield/lazygit/pkg/gui/types"
+
+type FakePopupHandler struct {
+ OnErrorMsg func(message string) error
+ OnConfirm func(opts types.ConfirmOpts) error
+ OnPrompt func(opts types.PromptOpts) error
+}
+
+var _ types.IPopupHandler = &FakePopupHandler{}
+
+func (self *FakePopupHandler) Error(err error) error {
+ return self.ErrorMsg(err.Error())
+}
+
+func (self *FakePopupHandler) ErrorMsg(message string) error {
+ return self.OnErrorMsg(message)
+}
+
+func (self *FakePopupHandler) Alert(title string, message string) error {
+ panic("not yet implemented")
+}
+
+func (self *FakePopupHandler) Confirm(opts types.ConfirmOpts) error {
+ return self.OnConfirm(opts)
+}
+
+func (self *FakePopupHandler) Prompt(opts types.PromptOpts) error {
+ return self.OnPrompt(opts)
+}
+
+func (self *FakePopupHandler) WithLoaderPanel(message string, f func() error) error {
+ return f()
+}
+
+func (self *FakePopupHandler) WithWaitingStatus(message string, f func() error) error {
+ return f()
+}
+
+func (self *FakePopupHandler) Menu(opts types.CreateMenuOptions) error {
+ panic("not yet implemented")
+}
+
+func (self *FakePopupHandler) Toast(message string) {
+ panic("not yet implemented")
+}
+
+func (self *FakePopupHandler) GetPromptInput() string {
+ panic("not yet implemented")
+}
diff --git a/pkg/gui/popup/popup_handler.go b/pkg/gui/popup/popup_handler.go
new file mode 100644
index 000000000..26b886fb7
--- /dev/null
+++ b/pkg/gui/popup/popup_handler.go
@@ -0,0 +1,158 @@
+package popup
+
+import (
+ "strings"
+
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/common"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/sasha-s/go-deadlock"
+)
+
+type PopupHandler struct {
+ *common.Common
+ index int
+ deadlock.Mutex
+ createPopupPanelFn func(types.CreatePopupPanelOpts) error
+ onErrorFn func() error
+ popContextFn func() error
+ currentContextFn func() types.Context
+ createMenuFn func(types.CreateMenuOptions) error
+ withWaitingStatusFn func(message string, f func() error) error
+ toastFn func(message string)
+ getPromptInputFn func() string
+}
+
+var _ types.IPopupHandler = &PopupHandler{}
+
+func NewPopupHandler(
+ common *common.Common,
+ createPopupPanelFn func(types.CreatePopupPanelOpts) error,
+ onErrorFn func() error,
+ popContextFn func() error,
+ currentContextFn func() types.Context,
+ createMenuFn func(types.CreateMenuOptions) error,
+ withWaitingStatusFn func(message string, f func() error) error,
+ toastFn func(message string),
+ getPromptInputFn func() string,
+) *PopupHandler {
+ return &PopupHandler{
+ Common: common,
+ index: 0,
+ createPopupPanelFn: createPopupPanelFn,
+ onErrorFn: onErrorFn,
+ popContextFn: popContextFn,
+ currentContextFn: currentContextFn,
+ createMenuFn: createMenuFn,
+ withWaitingStatusFn: withWaitingStatusFn,
+ toastFn: toastFn,
+ getPromptInputFn: getPromptInputFn,
+ }
+}
+
+func (self *PopupHandler) Menu(opts types.CreateMenuOptions) error {
+ return self.createMenuFn(opts)
+}
+
+func (self *PopupHandler) Toast(message string) {
+ self.toastFn(message)
+}
+
+func (self *PopupHandler) WithWaitingStatus(message string, f func() error) error {
+ return self.withWaitingStatusFn(message, f)
+}
+
+func (self *PopupHandler) Error(err error) error {
+ if err == gocui.ErrQuit {
+ return err
+ }
+
+ return self.ErrorMsg(err.Error())
+}
+
+func (self *PopupHandler) ErrorMsg(message string) error {
+ self.Lock()
+ self.index++
+ self.Unlock()
+
+ // Need to set bold here explicitly; otherwise it gets cancelled by the red colouring.
+ coloredMessage := style.FgRed.SetBold().Sprint(strings.TrimSpace(message))
+ if err := self.onErrorFn(); err != nil {
+ return err
+ }
+
+ return self.Alert(self.Tr.Error, coloredMessage)
+}
+
+func (self *PopupHandler) Alert(title string, message string) error {
+ return self.Confirm(types.ConfirmOpts{Title: title, Prompt: message})
+}
+
+func (self *PopupHandler) Confirm(opts types.ConfirmOpts) error {
+ self.Lock()
+ self.index++
+ self.Unlock()
+
+ return self.createPopupPanelFn(types.CreatePopupPanelOpts{
+ Title: opts.Title,
+ Prompt: opts.Prompt,
+ HandleConfirm: opts.HandleConfirm,
+ HandleClose: opts.HandleClose,
+ })
+}
+
+func (self *PopupHandler) Prompt(opts types.PromptOpts) error {
+ self.Lock()
+ self.index++
+ self.Unlock()
+
+ return self.createPopupPanelFn(types.CreatePopupPanelOpts{
+ Title: opts.Title,
+ Prompt: opts.InitialContent,
+ Editable: true,
+ HandleConfirmPrompt: opts.HandleConfirm,
+ HandleClose: opts.HandleClose,
+ FindSuggestionsFunc: opts.FindSuggestionsFunc,
+ Mask: opts.Mask,
+ })
+}
+
+func (self *PopupHandler) WithLoaderPanel(message string, f func() error) error {
+ index := 0
+ self.Lock()
+ self.index++
+ index = self.index
+ self.Unlock()
+
+ err := self.createPopupPanelFn(types.CreatePopupPanelOpts{
+ Prompt: message,
+ HasLoader: true,
+ })
+ if err != nil {
+ self.Log.Error(err)
+ return nil
+ }
+
+ go utils.Safe(func() {
+ if err := f(); err != nil {
+ self.Log.Error(err)
+ }
+
+ self.Lock()
+ if index == self.index && self.currentContextFn().GetKey() == context.CONFIRMATION_CONTEXT_KEY {
+ _ = self.popContextFn()
+ }
+ self.Unlock()
+ })
+
+ return nil
+}
+
+// returns the content that has currently been typed into the prompt. Useful for
+// asynchronously updating the suggestions list under the prompt.
+func (self *PopupHandler) GetPromptInput() string {
+ return self.getPromptInputFn()
+}
diff --git a/pkg/gui/popup_handler.go b/pkg/gui/popup_handler.go
deleted file mode 100644
index 9cacc3574..000000000
--- a/pkg/gui/popup_handler.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package gui
-
-import (
- "strings"
-
- "github.com/jesseduffield/lazygit/pkg/gui/style"
-)
-
-type PopupHandler interface {
- Error(message string) error
- Ask(opts askOpts) error
- Prompt(opts promptOpts) error
- Loader(message string) error
-}
-
-type RealPopupHandler struct {
- gui *Gui
-}
-
-func (self *RealPopupHandler) Error(message string) error {
- gui := self.gui
-
- coloredMessage := style.FgRed.Sprint(strings.TrimSpace(message))
- if err := gui.refreshSidePanels(refreshOptions{mode: ASYNC}); err != nil {
- return err
- }
-
- return self.Ask(askOpts{
- title: gui.Tr.Error,
- prompt: coloredMessage,
- })
-}
-
-func (self *RealPopupHandler) Ask(opts askOpts) error {
- gui := self.gui
-
- return gui.createPopupPanel(createPopupPanelOpts{
- title: opts.title,
- prompt: opts.prompt,
- handleConfirm: opts.handleConfirm,
- handleClose: opts.handleClose,
- handlersManageFocus: opts.handlersManageFocus,
- })
-}
-
-func (self *RealPopupHandler) Prompt(opts promptOpts) error {
- gui := self.gui
-
- return gui.createPopupPanel(createPopupPanelOpts{
- title: opts.title,
- prompt: opts.initialContent,
- editable: true,
- handleConfirmPrompt: opts.handleConfirm,
- findSuggestionsFunc: opts.findSuggestionsFunc,
- })
-}
-
-func (self *RealPopupHandler) Loader(message string) error {
- gui := self.gui
-
- return gui.createPopupPanel(createPopupPanelOpts{
- prompt: message,
- hasLoader: true,
- })
-}
-
-type TestPopupHandler struct {
- onError func(message string) error
- onAsk func(opts askOpts) error
- onPrompt func(opts promptOpts) error
-}
-
-func (self *TestPopupHandler) Error(message string) error {
- return self.onError(message)
-}
-
-func (self *TestPopupHandler) Ask(opts askOpts) error {
- return self.onAsk(opts)
-}
-
-func (self *TestPopupHandler) Prompt(opts promptOpts) error {
- return self.onPrompt(opts)
-}
-
-func (self *TestPopupHandler) Loader(message string) error {
- return nil
-}
diff --git a/pkg/gui/presentation/branches.go b/pkg/gui/presentation/branches.go
index 1ace50fab..1b9cc9b12 100644
--- a/pkg/gui/presentation/branches.go
+++ b/pkg/gui/presentation/branches.go
@@ -5,34 +5,26 @@ import (
"strconv"
"strings"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
)
var branchPrefixColorCache = make(map[string]style.TextStyle)
-func GetBranchListDisplayStrings(
- branches []*models.Branch,
- prs map[*models.Branch]*models.GithubPullRequest,
- fullDescription bool, diffName string) [][]string {
- lines := make([][]string, len(branches))
-
- for i := range branches {
- diffed := branches[i].Name == diffName
- lines[i] = getBranchDisplayStrings(branches[i], prs, fullDescription, diffed)
- }
-
- return lines
+func GetBranchListDisplayStrings(branches []*models.Branch, fullDescription bool, diffName string, tr *i18n.TranslationSet) [][]string {
+ return slices.Map(branches, func(branch *models.Branch) []string {
+ diffed := branch.Name == diffName
+ return getBranchDisplayStrings(branch, fullDescription, diffed, tr)
+ })
}
// getBranchDisplayStrings returns the display string of branch
-func getBranchDisplayStrings(
- b *models.Branch,
- prs map[*models.Branch]*models.GithubPullRequest,
- fullDescription bool,
- diffed bool) []string {
+func getBranchDisplayStrings(b *models.Branch, fullDescription bool, diffed bool, tr *i18n.TranslationSet) []string {
displayName := b.Name
if b.DisplayName != "" {
displayName = b.DisplayName
@@ -42,23 +34,25 @@ func getBranchDisplayStrings(
if diffed {
nameTextStyle = theme.DiffTerminalColor
}
+
coloredName := nameTextStyle.Sprint(displayName)
- if b.IsTrackingRemote() {
- coloredName = fmt.Sprintf("%s %s", coloredName, ColoredBranchStatus(b))
- }
+ branchStatus := utils.WithPadding(ColoredBranchStatus(b, tr), 2)
+ coloredName = fmt.Sprintf("%s %s", coloredName, branchStatus)
recencyColor := style.FgCyan
if b.Recency == " *" {
recencyColor = style.FgGreen
}
- res := []string{recencyColor.Sprint(b.Recency)}
- pr, hasPr := prs[b]
-
- res = append(res, coloredPrNumber(pr, hasPr), coloredName)
-
+ res := make([]string, 0, 4)
+ res = append(res, recencyColor.Sprint(b.Recency))
+ if icons.IsIconEnabled() {
+ res = append(res, nameTextStyle.Sprint(icons.IconForBranch(b)))
+ }
+ res = append(res, coloredName)
if fullDescription {
- res = append(res,
+ res = append(
+ res,
fmt.Sprintf("%s %s",
style.FgYellow.Sprint(b.UpstreamRemote),
style.FgYellow.Sprint(b.UpstreamBranch),
@@ -88,19 +82,44 @@ func GetBranchTextStyle(name string) style.TextStyle {
}
}
-func ColoredBranchStatus(branch *models.Branch) string {
+func ColoredBranchStatus(branch *models.Branch, tr *i18n.TranslationSet) string {
colour := style.FgYellow
- if branch.MatchesUpstream() {
- colour = style.FgGreen
- } else if !branch.IsTrackingRemote() {
+ if branch.UpstreamGone {
colour = style.FgRed
+ } else if branch.MatchesUpstream() {
+ colour = style.FgGreen
+ } else if branch.RemoteBranchNotStoredLocally() {
+ colour = style.FgMagenta
}
- return colour.Sprint(BranchStatus(branch))
+ return colour.Sprint(BranchStatus(branch, tr))
}
-func BranchStatus(branch *models.Branch) string {
- return fmt.Sprintf("↑%s↓%s", branch.Pushables, branch.Pullables)
+func BranchStatus(branch *models.Branch, tr *i18n.TranslationSet) string {
+ if !branch.IsTrackingRemote() {
+ return ""
+ }
+
+ if branch.UpstreamGone {
+ return tr.UpstreamGone
+ }
+
+ if branch.MatchesUpstream() {
+ return "âś“"
+ }
+ if branch.RemoteBranchNotStoredLocally() {
+ return "?"
+ }
+
+ result := ""
+ if branch.HasCommitsToPush() {
+ result = fmt.Sprintf("↑%s", branch.Pushables)
+ }
+ if branch.HasCommitsToPull() {
+ result = fmt.Sprintf("%s↓%s", result, branch.Pullables)
+ }
+
+ return result
}
func SetCustomBranches(customBranchColors map[string]string) {
diff --git a/pkg/gui/presentation/commits.go b/pkg/gui/presentation/commits.go
index 2bc9f475c..f9bdaee47 100644
--- a/pkg/gui/presentation/commits.go
+++ b/pkg/gui/presentation/commits.go
@@ -2,16 +2,18 @@ package presentation
import (
"strings"
- "sync"
+ "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/gui/presentation/authors"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/graph"
+ "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/kyokomi/emoji/v2"
+ "github.com/sasha-s/go-deadlock"
)
type pipeSetCacheKey struct {
@@ -19,8 +21,10 @@ type pipeSetCacheKey struct {
commitCount int
}
-var pipeSetCache = make(map[pipeSetCacheKey][][]*graph.Pipe)
-var mutex sync.Mutex
+var (
+ pipeSetCache = make(map[pipeSetCacheKey][][]*graph.Pipe)
+ mutex deadlock.Mutex
+)
type bisectBounds struct {
newIndex int
@@ -30,8 +34,9 @@ type bisectBounds struct {
func GetCommitListDisplayStrings(
commits []*models.Commit,
fullDescription bool,
- cherryPickedCommitShaMap map[string]bool,
+ cherryPickedCommitShaSet *set.Set[string],
diffName string,
+ timeFormat string,
parseEmoji bool,
selectedCommitSha string,
startIdx int,
@@ -92,8 +97,9 @@ func GetCommitListDisplayStrings(
bisectStatus = getBisectStatus(unfilteredIdx, commit.Sha, bisectInfo, bisectBounds)
lines = append(lines, displayCommit(
commit,
- cherryPickedCommitShaMap,
+ cherryPickedCommitShaSet,
diffName,
+ timeFormat,
parseEmoji,
getGraphLine(unfilteredIdx),
fullDescription,
@@ -152,7 +158,7 @@ func loadPipesets(commits []*models.Commit) [][]*graph.Pipe {
// pipe sets are unique to a commit head. and a commit count. Sometimes we haven't loaded everything for that.
// so let's just cache it based on that.
getStyle := func(commit *models.Commit) style.TextStyle {
- return authors.AuthorStyle(commit.Author)
+ return authors.AuthorStyle(commit.AuthorName)
}
pipeSets = graph.GetPipeSets(commits, getStyle)
pipeSetCache[cacheKey] = pipeSets
@@ -226,6 +232,8 @@ func getBisectStatusText(bisectStatus BisectStatus, bisectInfo *git_commands.Bis
return style.Sprintf("<-- skipped")
case BisectStatusCandidate:
return style.Sprintf("?")
+ case BisectStatusNone:
+ return ""
}
return ""
@@ -233,15 +241,16 @@ func getBisectStatusText(bisectStatus BisectStatus, bisectInfo *git_commands.Bis
func displayCommit(
commit *models.Commit,
- cherryPickedCommitShaMap map[string]bool,
+ cherryPickedCommitShaSet *set.Set[string],
diffName string,
+ timeFormat string,
parseEmoji bool,
graphLine string,
fullDescription bool,
bisectStatus BisectStatus,
bisectInfo *git_commands.BisectInfo,
) []string {
- shaColor := getShaColor(commit, diffName, cherryPickedCommitShaMap, bisectStatus, bisectInfo)
+ shaColor := getShaColor(commit, diffName, cherryPickedCommitShaSet, bisectStatus, bisectInfo)
bisectString := getBisectStatusText(bisectStatus, bisectInfo)
actionString := ""
@@ -270,16 +279,19 @@ func displayCommit(
authorFunc = authors.LongAuthor
}
- cols := make([]string, 0, 5)
+ cols := make([]string, 0, 7)
+ if icons.IsIconEnabled() {
+ cols = append(cols, shaColor.Sprint(icons.IconForCommit(commit)))
+ }
cols = append(cols, shaColor.Sprint(commit.ShortSha()))
cols = append(cols, bisectString)
if fullDescription {
- cols = append(cols, style.FgBlue.Sprint(utils.UnixToDate(commit.UnixTimestamp)))
+ cols = append(cols, style.FgBlue.Sprint(utils.UnixToDate(commit.UnixTimestamp, timeFormat)))
}
cols = append(
cols,
actionString,
- authorFunc(commit.Author),
+ authorFunc(commit.AuthorName),
graphLine+tagString+theme.DefaultTextColor.Sprint(name),
)
@@ -309,7 +321,7 @@ func getBisectStatusColor(status BisectStatus) style.TextStyle {
func getShaColor(
commit *models.Commit,
diffName string,
- cherryPickedCommitShaMap map[string]bool,
+ cherryPickedCommitShaSet *set.Set[string],
bisectStatus BisectStatus,
bisectInfo *git_commands.BisectInfo,
) style.TextStyle {
@@ -334,7 +346,7 @@ func getShaColor(
if diffed {
shaColor = theme.DiffTerminalColor
- } else if cherryPickedCommitShaMap[commit.Sha] {
+ } else if cherryPickedCommitShaSet.Includes(commit.Sha) {
shaColor = theme.CherryPickedCommitTextStyle
}
diff --git a/pkg/gui/presentation/commits_test.go b/pkg/gui/presentation/commits_test.go
index b7fd23468..158396c2f 100644
--- a/pkg/gui/presentation/commits_test.go
+++ b/pkg/gui/presentation/commits_test.go
@@ -1,10 +1,12 @@
package presentation
import (
+ "os"
"strings"
"testing"
"github.com/gookit/color"
+ "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/utils"
@@ -25,8 +27,9 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
testName string
commits []*models.Commit
fullDescription bool
- cherryPickedCommitShaMap map[string]bool
+ cherryPickedCommitShaSet *set.Set[string]
diffName string
+ timeFormat string
parseEmoji bool
selectedCommitSha string
startIdx int
@@ -37,13 +40,14 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
focus bool
}{
{
- testName: "no commits",
- commits: []*models.Commit{},
- startIdx: 0,
- length: 1,
- showGraph: false,
- bisectInfo: git_commands.NewNullBisectInfo(),
- expected: "",
+ testName: "no commits",
+ commits: []*models.Commit{},
+ startIdx: 0,
+ length: 1,
+ showGraph: false,
+ bisectInfo: git_commands.NewNullBisectInfo(),
+ cherryPickedCommitShaSet: set.New[string](),
+ expected: "",
},
{
testName: "some commits",
@@ -51,10 +55,11 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit1", Sha: "sha1"},
{Name: "commit2", Sha: "sha2"},
},
- startIdx: 0,
- length: 2,
- showGraph: false,
- bisectInfo: git_commands.NewNullBisectInfo(),
+ startIdx: 0,
+ length: 2,
+ showGraph: false,
+ bisectInfo: git_commands.NewNullBisectInfo(),
+ cherryPickedCommitShaSet: set.New[string](),
expected: formatExpected(`
sha1 commit1
sha2 commit2
@@ -69,10 +74,11 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}},
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
},
- startIdx: 0,
- length: 5,
- showGraph: true,
- bisectInfo: git_commands.NewNullBisectInfo(),
+ startIdx: 0,
+ length: 5,
+ showGraph: true,
+ bisectInfo: git_commands.NewNullBisectInfo(),
+ cherryPickedCommitShaSet: set.New[string](),
expected: formatExpected(`
sha1 ⏣─╮ commit1
sha2 ◯ │ commit2
@@ -90,10 +96,11 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}},
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
},
- startIdx: 0,
- length: 5,
- showGraph: true,
- bisectInfo: git_commands.NewNullBisectInfo(),
+ startIdx: 0,
+ length: 5,
+ showGraph: true,
+ bisectInfo: git_commands.NewNullBisectInfo(),
+ cherryPickedCommitShaSet: set.New[string](),
expected: formatExpected(`
sha1 pick commit1
sha2 pick commit2
@@ -111,10 +118,11 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}},
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
},
- startIdx: 1,
- length: 10,
- showGraph: true,
- bisectInfo: git_commands.NewNullBisectInfo(),
+ startIdx: 1,
+ length: 10,
+ showGraph: true,
+ bisectInfo: git_commands.NewNullBisectInfo(),
+ cherryPickedCommitShaSet: set.New[string](),
expected: formatExpected(`
sha2 pick commit2
sha3 â—Ż commit3
@@ -131,10 +139,11 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}},
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
},
- startIdx: 3,
- length: 2,
- showGraph: true,
- bisectInfo: git_commands.NewNullBisectInfo(),
+ startIdx: 3,
+ length: 2,
+ showGraph: true,
+ bisectInfo: git_commands.NewNullBisectInfo(),
+ cherryPickedCommitShaSet: set.New[string](),
expected: formatExpected(`
sha4 â—Ż commit4
sha5 â—Ż commit5
@@ -149,10 +158,11 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}},
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
},
- startIdx: 0,
- length: 2,
- showGraph: true,
- bisectInfo: git_commands.NewNullBisectInfo(),
+ startIdx: 0,
+ length: 2,
+ showGraph: true,
+ bisectInfo: git_commands.NewNullBisectInfo(),
+ cherryPickedCommitShaSet: set.New[string](),
expected: formatExpected(`
sha1 pick commit1
sha2 pick commit2
@@ -167,10 +177,11 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}},
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
},
- startIdx: 4,
- length: 2,
- showGraph: true,
- bisectInfo: git_commands.NewNullBisectInfo(),
+ startIdx: 4,
+ length: 2,
+ showGraph: true,
+ bisectInfo: git_commands.NewNullBisectInfo(),
+ cherryPickedCommitShaSet: set.New[string](),
expected: formatExpected(`
sha5 â—Ż commit5
`),
@@ -184,17 +195,38 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
{Name: "commit4", Sha: "sha4", Parents: []string{"sha5"}, Action: "pick"},
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
},
- startIdx: 0,
- length: 2,
- showGraph: true,
- bisectInfo: git_commands.NewNullBisectInfo(),
+ startIdx: 0,
+ length: 2,
+ showGraph: true,
+ bisectInfo: git_commands.NewNullBisectInfo(),
+ cherryPickedCommitShaSet: set.New[string](),
expected: formatExpected(`
sha1 pick commit1
sha2 pick commit2
`),
},
+ {
+ testName: "custom time format",
+ commits: []*models.Commit{
+ {Name: "commit1", Sha: "sha1", UnixTimestamp: 1652443200, AuthorName: "Jesse Duffield"},
+ {Name: "commit2", Sha: "sha2", UnixTimestamp: 1652529600, AuthorName: "Jesse Duffield"},
+ },
+ fullDescription: true,
+ timeFormat: "2006-01-02 15:04:05",
+ startIdx: 0,
+ length: 2,
+ showGraph: false,
+ bisectInfo: git_commands.NewNullBisectInfo(),
+ cherryPickedCommitShaSet: set.New[string](),
+ expected: formatExpected(`
+ sha1 2022-05-13 12:00:00 Jesse Duffield commit1
+ sha2 2022-05-14 12:00:00 Jesse Duffield commit2
+ `),
+ },
}
+ os.Setenv("TZ", "UTC")
+
focusing := false
for _, scenario := range scenarios {
if scenario.focus {
@@ -209,8 +241,9 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
result := GetCommitListDisplayStrings(
s.commits,
s.fullDescription,
- s.cherryPickedCommitShaMap,
+ s.cherryPickedCommitShaSet,
s.diffName,
+ s.timeFormat,
s.parseEmoji,
s.selectedCommitSha,
s.startIdx,
diff --git a/pkg/gui/presentation/files.go b/pkg/gui/presentation/files.go
index 116d4fc4b..394b39f73 100644
--- a/pkg/gui/presentation/files.go
+++ b/pkg/gui/presentation/files.go
@@ -1,96 +1,96 @@
package presentation
import (
- "fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
+ "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
)
-const EXPANDED_ARROW = "â–Ľ"
-const COLLAPSED_ARROW = "â–ş"
+const (
+ EXPANDED_ARROW = "â–Ľ"
+ COLLAPSED_ARROW = "â–ş"
+)
-const INNER_ITEM = "├─ "
-const LAST_ITEM = "└─ "
-const NESTED = "│ "
-const NOTHING = " "
+// keeping these here as individual constants in case later on people want the old tree shape
+const (
+ INNER_ITEM = " "
+ LAST_ITEM = " "
+ NESTED = " "
+ NOTHING = " "
+)
func RenderFileTree(
- fileMgr *filetree.FileTreeViewModel,
+ tree filetree.IFileTree,
diffName string,
submoduleConfigs []*models.SubmoduleConfig,
) []string {
- return renderAux(fileMgr.Tree(), fileMgr.CollapsedPaths(), "", -1, func(n filetree.INode, depth int) string {
- castN := n.(*filetree.FileNode)
- return getFileLine(castN.GetHasUnstagedChanges(), castN.GetHasStagedChanges(), castN.NameAtDepth(depth), diffName, submoduleConfigs, castN.File)
+ return renderAux(tree.GetRoot().Raw(), tree.CollapsedPaths(), "", -1, func(node *filetree.Node[models.File], depth int) string {
+ fileNode := filetree.NewFileNode(node)
+
+ return getFileLine(fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), fileNameAtDepth(node, depth), diffName, submoduleConfigs, node.File)
})
}
func RenderCommitFileTree(
- commitFileMgr *filetree.CommitFileTreeViewModel,
+ tree *filetree.CommitFileTreeViewModel,
diffName string,
patchManager *patch.PatchManager,
) []string {
- return renderAux(commitFileMgr.Tree(), commitFileMgr.CollapsedPaths(), "", -1, func(n filetree.INode, depth int) string {
- castN := n.(*filetree.CommitFileNode)
-
+ return renderAux(tree.GetRoot().Raw(), tree.CollapsedPaths(), "", -1, func(node *filetree.Node[models.CommitFile], depth int) string {
// This is a little convoluted because we're dealing with either a leaf or a non-leaf.
// But this code actually applies to both. If it's a leaf, the status will just
// be whatever status it is, but if it's a non-leaf it will determine its status
// based on the leaves of that subtree
var status patch.PatchStatus
- if castN.EveryFile(func(file *models.CommitFile) bool {
- return patchManager.GetFileStatus(file.Name, commitFileMgr.GetParent()) == patch.WHOLE
+ if node.EveryFile(func(file *models.CommitFile) bool {
+ return patchManager.GetFileStatus(file.Name, tree.GetRef().RefName()) == patch.WHOLE
}) {
status = patch.WHOLE
- } else if castN.EveryFile(func(file *models.CommitFile) bool {
- return patchManager.GetFileStatus(file.Name, commitFileMgr.GetParent()) == patch.UNSELECTED
+ } else if node.EveryFile(func(file *models.CommitFile) bool {
+ return patchManager.GetFileStatus(file.Name, tree.GetRef().RefName()) == patch.UNSELECTED
}) {
status = patch.UNSELECTED
} else {
status = patch.PART
}
- return getCommitFileLine(castN.NameAtDepth(depth), diffName, castN.File, status)
+ return getCommitFileLine(commitFileNameAtDepth(node, depth), diffName, node.File, status)
})
}
-func renderAux(
- s filetree.INode,
- collapsedPaths filetree.CollapsedPaths,
+func renderAux[T any](
+ node *filetree.Node[T],
+ collapsedPaths *filetree.CollapsedPaths,
prefix string,
depth int,
- renderLine func(filetree.INode, int) string,
+ renderLine func(*filetree.Node[T], int) string,
) []string {
- if s == nil || s.IsNil() {
+ if node == nil {
return []string{}
}
isRoot := depth == -1
- renderLineWithPrefix := func() string {
- return prefix + renderLine(s, depth)
- }
-
- if s.IsLeaf() {
+ if node.IsFile() {
if isRoot {
return []string{}
}
- return []string{renderLineWithPrefix()}
+ return []string{prefix + renderLine(node, depth)}
}
- if collapsedPaths.IsCollapsed(s.GetPath()) {
- return []string{fmt.Sprintf("%s %s", renderLineWithPrefix(), COLLAPSED_ARROW)}
+ if collapsedPaths.IsCollapsed(node.GetPath()) {
+ return []string{prefix + COLLAPSED_ARROW + " " + renderLine(node, depth)}
}
arr := []string{}
if !isRoot {
- arr = append(arr, fmt.Sprintf("%s %s", renderLineWithPrefix(), EXPANDED_ARROW))
+ arr = append(arr, prefix+EXPANDED_ARROW+" "+renderLine(node, depth))
}
newPrefix := prefix
@@ -100,8 +100,8 @@ func renderAux(
newPrefix = strings.TrimSuffix(prefix, INNER_ITEM) + NESTED
}
- for i, child := range s.GetChildren() {
- isLast := i == len(s.GetChildren())-1
+ for i, child := range node.Children {
+ isLast := i == len(node.Children)-1
var childPrefix string
if isRoot {
@@ -112,7 +112,7 @@ func renderAux(
childPrefix = newPrefix + INNER_ITEM
}
- arr = append(arr, renderAux(child, collapsedPaths, childPrefix, depth+1+s.GetCompressionLevel(), renderLine)...)
+ arr = append(arr, renderAux(child, collapsedPaths, childPrefix, depth+1+node.CompressionLevel, renderLine)...)
}
return arr
@@ -129,7 +129,7 @@ func getFileLine(hasUnstagedChanges bool, hasStagedChanges bool, name string, di
} else if file == nil && hasStagedChanges && hasUnstagedChanges {
restColor = partiallyModifiedColor
} else if hasUnstagedChanges {
- restColor = style.FgRed
+ restColor = theme.UnstagedChangesColor
}
output := ""
@@ -138,13 +138,13 @@ func getFileLine(hasUnstagedChanges bool, hasStagedChanges bool, name string, di
firstChar := file.ShortStatus[0:1]
firstCharCl := style.FgGreen
if firstChar == "?" {
- firstCharCl = style.FgRed
+ firstCharCl = theme.UnstagedChangesColor
} else if firstChar == " " {
firstCharCl = restColor
}
secondChar := file.ShortStatus[1:2]
- secondCharCl := style.FgRed
+ secondCharCl := theme.UnstagedChangesColor
if secondChar == " " {
secondCharCl = restColor
}
@@ -154,9 +154,16 @@ func getFileLine(hasUnstagedChanges bool, hasStagedChanges bool, name string, di
output += restColor.Sprint(" ")
}
+ isSubmodule := file != nil && file.IsSubmodule(submoduleConfigs)
+ isDirectory := file == nil
+
+ if icons.IsIconEnabled() {
+ output += restColor.Sprintf("%s ", icons.IconForFile(name, isSubmodule, isDirectory))
+ }
+
output += restColor.Sprint(utils.EscapeSpecialChars(name))
- if file != nil && file.IsSubmodule(submoduleConfigs) {
+ if isSubmodule {
output += theme.DefaultTextColor.Sprint(" (submodule)")
}
@@ -178,12 +185,22 @@ func getCommitFileLine(name string, diffName string, commitFile *models.CommitFi
}
}
+ output := ""
+
name = utils.EscapeSpecialChars(name)
- if commitFile == nil {
- return colour.Sprint(name)
+ if commitFile != nil {
+ output += getColorForChangeStatus(commitFile.ChangeStatus).Sprint(commitFile.ChangeStatus) + " "
}
- return getColorForChangeStatus(commitFile.ChangeStatus).Sprint(commitFile.ChangeStatus) + " " + colour.Sprint(name)
+ isSubmodule := false
+ isDirectory := commitFile == nil
+
+ if icons.IsIconEnabled() {
+ output += colour.Sprintf("%s ", icons.IconForFile(name, isSubmodule, isDirectory))
+ }
+
+ output += colour.Sprint(name)
+ return output
}
func getColorForChangeStatus(changeStatus string) style.TextStyle {
@@ -193,7 +210,7 @@ func getColorForChangeStatus(changeStatus string) style.TextStyle {
case "M", "R":
return style.FgYellow
case "D":
- return style.FgRed
+ return theme.UnstagedChangesColor
case "C":
return style.FgCyan
case "T":
@@ -202,3 +219,39 @@ func getColorForChangeStatus(changeStatus string) style.TextStyle {
return theme.DefaultTextColor
}
}
+
+func fileNameAtDepth(node *filetree.Node[models.File], depth int) string {
+ splitName := split(node.Path)
+ name := join(splitName[depth:])
+
+ if node.File != nil && node.File.IsRename() {
+ splitPrevName := split(node.File.PreviousName)
+
+ prevName := node.File.PreviousName
+ // if the file has just been renamed inside the same directory, we can shave off
+ // the prefix for the previous path too. Otherwise we'll keep it unchanged
+ sameParentDir := len(splitName) == len(splitPrevName) && join(splitName[0:depth]) == join(splitPrevName[0:depth])
+ if sameParentDir {
+ prevName = join(splitPrevName[depth:])
+ }
+
+ return prevName + " → " + name
+ }
+
+ return name
+}
+
+func commitFileNameAtDepth(node *filetree.Node[models.CommitFile], depth int) string {
+ splitName := split(node.Path)
+ name := join(splitName[depth:])
+
+ return name
+}
+
+func split(str string) []string {
+ return strings.Split(str, "/")
+}
+
+func join(strs []string) string {
+ return strings.Join(strs, "/")
+}
diff --git a/pkg/gui/presentation/files_test.go b/pkg/gui/presentation/files_test.go
index c40f6247e..17e061012 100644
--- a/pkg/gui/presentation/files_test.go
+++ b/pkg/gui/presentation/files_test.go
@@ -53,12 +53,12 @@ func TestRenderFileTree(t *testing.T) {
},
expected: toStringSlice(
`
-dir1 â–ş
-dir2 â–Ľ
-├─ dir2 ▼
-│ ├─ M file3
-│ └─ M file4
-└─ M file5
+â–ş dir1
+â–Ľ dir2
+ â–Ľ dir2
+ M file3
+ M file4
+ M file5
M file1
`,
),
@@ -69,7 +69,8 @@ M file1
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
- viewModel := filetree.NewFileTreeViewModel(s.files, utils.NewDummyLog(), true)
+ viewModel := filetree.NewFileTree(func() []*models.File { return s.files }, utils.NewDummyLog(), true)
+ viewModel.SetTree()
for _, path := range s.collapsedPaths {
viewModel.ToggleCollapsed(path)
}
@@ -111,12 +112,12 @@ func TestRenderCommitFileTree(t *testing.T) {
},
expected: toStringSlice(
`
-dir1 â–ş
-dir2 â–Ľ
-├─ dir2 ▼
-│ ├─ D file3
-│ └─ M file4
-└─ M file5
+â–ş dir1
+â–Ľ dir2
+ â–Ľ dir2
+ D file3
+ M file4
+ M file5
M file1
`,
),
@@ -127,7 +128,9 @@ M file1
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
- viewModel := filetree.NewCommitFileTreeViewModel(s.files, utils.NewDummyLog(), true)
+ viewModel := filetree.NewCommitFileTreeViewModel(func() []*models.CommitFile { return s.files }, utils.NewDummyLog(), true)
+ viewModel.SetRef(&models.Commit{})
+ viewModel.SetTree()
for _, path := range s.collapsedPaths {
viewModel.ToggleCollapsed(path)
}
diff --git a/pkg/gui/presentation/graph/cell.go b/pkg/gui/presentation/graph/cell.go
index e970c6dd2..cc2ad53c3 100644
--- a/pkg/gui/presentation/graph/cell.go
+++ b/pkg/gui/presentation/graph/cell.go
@@ -8,8 +8,10 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/style"
)
-const MergeSymbol = '⏣'
-const CommitSymbol = 'â—Ż'
+const (
+ MergeSymbol = '⏣'
+ CommitSymbol = 'â—Ż'
+)
type cellType int
@@ -66,8 +68,10 @@ type rgbCacheKey struct {
str string
}
-var rgbCache = make(map[rgbCacheKey]string)
-var rgbCacheMutex sync.RWMutex
+var (
+ rgbCache = make(map[rgbCacheKey]string)
+ rgbCacheMutex sync.RWMutex
+)
func cachedSprint(style style.TextStyle, str string) string {
switch v := style.Style.(type) {
diff --git a/pkg/gui/presentation/graph/graph.go b/pkg/gui/presentation/graph/graph.go
index 0e193cba8..392af8984 100644
--- a/pkg/gui/presentation/graph/graph.go
+++ b/pkg/gui/presentation/graph/graph.go
@@ -2,13 +2,15 @@ package graph
import (
"runtime"
- "sort"
"strings"
"sync"
+ "github.com/jesseduffield/generics/set"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/samber/lo"
)
type PipeKind uint8
@@ -65,19 +67,15 @@ func GetPipeSets(commits []*models.Commit, getStyle func(c *models.Commit) style
pipes := []*Pipe{{fromPos: 0, toPos: 0, fromSha: "START", toSha: commits[0].Sha, kind: STARTS, style: style.FgDefault}}
- pipeSets := [][]*Pipe{}
- for _, commit := range commits {
+ return slices.Map(commits, func(commit *models.Commit) []*Pipe {
pipes = getNextPipes(pipes, commit, getStyle)
- pipeSets = append(pipeSets, pipes)
- }
-
- return pipeSets
+ return pipes
+ })
}
func RenderAux(pipeSets [][]*Pipe, commits []*models.Commit, selectedCommitSha string) []string {
maxProcs := runtime.GOMAXPROCS(0)
- lines := make([]string, 0, len(pipeSets))
// splitting up the rendering of the graph into multiple goroutines allows us to render the graph in parallel
chunks := make([][]string, maxProcs)
perProc := len(pipeSets) / maxProcs
@@ -110,24 +108,19 @@ func RenderAux(pipeSets [][]*Pipe, commits []*models.Commit, selectedCommitSha s
wg.Wait()
- for _, chunk := range chunks {
- lines = append(lines, chunk...)
- }
-
- return lines
+ return slices.Flatten(chunks)
}
func getNextPipes(prevPipes []*Pipe, commit *models.Commit, getStyle func(c *models.Commit) style.TextStyle) []*Pipe {
- currentPipes := make([]*Pipe, 0, len(prevPipes))
- maxPos := 0
- for _, pipe := range prevPipes {
- // a pipe that terminated in the previous line has no bearing on the current line
- // so we'll filter those out
- if pipe.kind != TERMINATES {
- currentPipes = append(currentPipes, pipe)
- }
- maxPos = utils.Max(maxPos, pipe.toPos)
- }
+ maxPos := slices.MaxBy(prevPipes, func(pipe *Pipe) int {
+ return pipe.toPos
+ })
+
+ // a pipe that terminated in the previous line has no bearing on the current line
+ // so we'll filter those out
+ currentPipes := slices.Filter(prevPipes, func(pipe *Pipe) bool {
+ return pipe.kind != TERMINATES
+ })
newPipes := make([]*Pipe, 0, len(currentPipes)+len(commit.Parents))
// start by assuming that we've got a brand new commit not related to any preceding commit.
@@ -142,9 +135,9 @@ func getNextPipes(prevPipes []*Pipe, commit *models.Commit, getStyle func(c *mod
}
// a taken spot is one where a current pipe is ending on
- takenSpots := make(map[int]bool)
+ takenSpots := set.New[int]()
// a traversed spot is one where a current pipe is starting on, ending on, or passing through
- traversedSpots := make(map[int]bool)
+ traversedSpots := set.New[int]()
if len(commit.Parents) > 0 {
newPipes = append(newPipes, &Pipe{
@@ -157,17 +150,17 @@ func getNextPipes(prevPipes []*Pipe, commit *models.Commit, getStyle func(c *mod
})
}
- traversedSpotsForContinuingPipes := make(map[int]bool)
+ traversedSpotsForContinuingPipes := set.New[int]()
for _, pipe := range currentPipes {
if !equalHashes(pipe.toSha, commit.Sha) {
- traversedSpotsForContinuingPipes[pipe.toPos] = true
+ traversedSpotsForContinuingPipes.Add(pipe.toPos)
}
}
getNextAvailablePosForContinuingPipe := func() int {
i := 0
for {
- if !traversedSpots[i] {
+ if !traversedSpots.Includes(i) {
return i
}
i++
@@ -179,7 +172,7 @@ func getNextPipes(prevPipes []*Pipe, commit *models.Commit, getStyle func(c *mod
for {
// a newly created pipe is not allowed to end on a spot that's already taken,
// nor on a spot that's been traversed by a continuing pipe.
- if !takenSpots[i] && !traversedSpotsForContinuingPipes[i] {
+ if !takenSpots.Includes(i) && !traversedSpotsForContinuingPipes.Includes(i) {
return i
}
i++
@@ -192,9 +185,9 @@ func getNextPipes(prevPipes []*Pipe, commit *models.Commit, getStyle func(c *mod
left, right = right, left
}
for i := left; i <= right; i++ {
- traversedSpots[i] = true
+ traversedSpots.Add(i)
}
- takenSpots[to] = true
+ takenSpots.Add(to)
}
for _, pipe := range currentPipes {
@@ -237,7 +230,7 @@ func getNextPipes(prevPipes []*Pipe, commit *models.Commit, getStyle func(c *mod
style: getStyle(commit),
})
- takenSpots[availablePos] = true
+ takenSpots.Add(availablePos)
}
}
@@ -246,7 +239,7 @@ func getNextPipes(prevPipes []*Pipe, commit *models.Commit, getStyle func(c *mod
// continuing on, potentially moving left to fill in a blank spot
last := pipe.toPos
for i := pipe.toPos; i > pos; i-- {
- if takenSpots[i] || traversedSpots[i] {
+ if takenSpots.Includes(i) || traversedSpots.Includes(i) {
break
} else {
last = i
@@ -265,11 +258,11 @@ func getNextPipes(prevPipes []*Pipe, commit *models.Commit, getStyle func(c *mod
}
// not efficient but doing it for now: sorting my pipes by toPos, then by kind
- sort.Slice(newPipes, func(i, j int) bool {
- if newPipes[i].toPos == newPipes[j].toPos {
- return newPipes[i].kind < newPipes[j].kind
+ slices.SortFunc(newPipes, func(a, b *Pipe) bool {
+ if a.toPos == b.toPos {
+ return a.kind < b.kind
}
- return newPipes[i].toPos < newPipes[j].toPos
+ return a.toPos < b.toPos
})
return newPipes
@@ -297,10 +290,9 @@ func renderPipeSet(
}
isMerge := startCount > 1
- cells := make([]*Cell, maxPos+1)
- for i := range cells {
- cells[i] = &Cell{cellType: CONNECTION, style: style.FgDefault}
- }
+ cells := slices.Map(lo.Range(maxPos+1), func(i int) *Cell {
+ return &Cell{cellType: CONNECTION, style: style.FgDefault}
+ })
renderPipe := func(pipe *Pipe, style style.TextStyle, overrideRightStyle bool) {
left := pipe.left()
@@ -336,17 +328,9 @@ func renderPipeSet(
// so we have our commit pos again, now it's time to build the cells.
// we'll handle the one that's sourced from our selected commit last so that it can override the other cells.
- selectedPipes := []*Pipe{}
- // pre-allocating this one because most of the time we'll only have non-selected pipes
- nonSelectedPipes := make([]*Pipe, 0, len(pipes))
-
- for _, pipe := range pipes {
- if highlight && equalHashes(pipe.fromSha, selectedCommitSha) {
- selectedPipes = append(selectedPipes, pipe)
- } else {
- nonSelectedPipes = append(nonSelectedPipes, pipe)
- }
- }
+ selectedPipes, nonSelectedPipes := slices.Partition(pipes, func(pipe *Pipe) bool {
+ return highlight && equalHashes(pipe.fromSha, selectedCommitSha)
+ })
for _, pipe := range nonSelectedPipes {
if pipe.kind == STARTS {
diff --git a/pkg/gui/presentation/graph/graph_test.go b/pkg/gui/presentation/graph/graph_test.go
index 300042558..a6c60acf5 100644
--- a/pkg/gui/presentation/graph/graph_test.go
+++ b/pkg/gui/presentation/graph/graph_test.go
@@ -528,7 +528,7 @@ func TestGetNextPipes(t *testing.T) {
func BenchmarkRenderCommitGraph(b *testing.B) {
commits := generateCommits(50)
getStyle := func(commit *models.Commit) style.TextStyle {
- return authors.AuthorStyle(commit.Author)
+ return authors.AuthorStyle(commit.AuthorName)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -538,7 +538,7 @@ func BenchmarkRenderCommitGraph(b *testing.B) {
func generateCommits(count int) []*models.Commit {
rand.Seed(1234)
- pool := []*models.Commit{{Sha: "a", Author: "A"}}
+ pool := []*models.Commit{{Sha: "a", AuthorName: "A"}}
commits := make([]*models.Commit, 0, count)
authorPool := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"}
for len(commits) < count {
@@ -555,8 +555,8 @@ func generateCommits(count int) []*models.Commit {
newParent = pool[j]
} else {
newParent = &models.Commit{
- Sha: fmt.Sprintf("%s%d", currentCommit.Sha, j),
- Author: authorPool[rand.Intn(len(authorPool))],
+ Sha: fmt.Sprintf("%s%d", currentCommit.Sha, j),
+ AuthorName: authorPool[rand.Intn(len(authorPool))],
}
pool = append(pool, newParent)
}
diff --git a/pkg/gui/presentation/icons/file_icons.go b/pkg/gui/presentation/icons/file_icons.go
new file mode 100644
index 000000000..e3f2b68eb
--- /dev/null
+++ b/pkg/gui/presentation/icons/file_icons.go
@@ -0,0 +1,331 @@
+package icons
+
+import (
+ "path/filepath"
+)
+
+// https://github.com/ogham/exa/blob/master/src/output/icons.rs
+const (
+ DEFAULT_FILE_ICON = "\uf15b" // ď…›
+ DEFAULT_SUBMODULE_ICON = "\uf1d3" // 
+ DEFAULT_DIRECTORY_ICON = "\uf114" // ď„”
+)
+
+var nameIconMap = map[string]string{
+ ".Trash": "\uf1f8", // 
+ ".atom": "\ue764", // 
+ ".bashprofile": "\ue615", // î•
+ ".bashrc": "\uf489", // ď’‰
+ ".idea": "\ue7b5", // îžµ
+ ".git": "\uf1d3", // 
+ ".gitattributes": "\uf1d3", // 
+ ".gitconfig": "\uf1d3", // 
+ ".github": "\uf408", // ď
+ ".gitignore": "\uf1d3", // 
+ ".gitmodules": "\uf1d3", // 
+ ".rvm": "\ue21e", // îž
+ ".vimrc": "\ue62b", // î«
+ ".vscode": "\ue70c", // 
+ ".zshrc": "\uf489", // ď’‰
+ "Cargo.lock": "\ue7a8", // 
+ "Cargo.toml": "\ue7a8", // 
+ "bin": "\ue5fc", // î—Ľ
+ "config": "\ue5fc", // î—Ľ
+ "docker-compose.yml": "\uf308", // ďŚ
+ "Dockerfile": "\uf308", // ďŚ
+ "ds_store": "\uf179", // ď…ą
+ "gitignore_global": "\uf1d3", // 
+ "go.mod": "\ue626", // î¦
+ "go.sum": "\ue626", // î¦
+ "gradle": "\ue256", // 
+ "gruntfile.coffee": "\ue611", // î‘
+ "gruntfile.js": "\ue611", // î‘
+ "gruntfile.ls": "\ue611", // î‘
+ "gulpfile.coffee": "\ue610", // î
+ "gulpfile.js": "\ue610", // î
+ "gulpfile.ls": "\ue610", // î
+ "hidden": "\uf023", // 
+ "include": "\ue5fc", // î—Ľ
+ "lib": "\uf121", // 
+ "localized": "\uf179", // ď…ą
+ "Makefile": "\uf489", // ď’‰
+ "node_modules": "\ue718", // îś
+ "npmignore": "\ue71e", // îśž
+ "PKGBUILD": "\uf303", // ďŚ
+ "rubydoc": "\ue73b", // îś»
+ "yarn.lock": "\ue718", // îś
+}
+
+var extIconMap = map[string]string{
+ ".ai": "\ue7b4", // îž´
+ ".android": "\ue70e", // 
+ ".apk": "\ue70e", // 
+ ".apple": "\uf179", // ď…ą
+ ".avi": "\uf03d", // 
+ ".avif": "\uf1c5", // 
+ ".avro": "\ue60b", // î‹
+ ".awk": "\uf489", // ď’‰
+ ".bash": "\uf489", // ď’‰
+ ".bash_history": "\uf489", // ď’‰
+ ".bash_profile": "\uf489", // ď’‰
+ ".bashrc": "\uf489", // ď’‰
+ ".bat": "\uf17a", // ď…ş
+ ".bats": "\uf489", // ď’‰
+ ".bmp": "\uf1c5", // 
+ ".bz": "\uf410", // ď
+ ".bz2": "\uf410", // ď
+ ".c": "\ue61e", // îž
+ ".c++": "\ue61d", // îť
+ ".cab": "\ue70f", // 
+ ".cc": "\ue61d", // îť
+ ".cfg": "\ue615", // î•
+ ".class": "\ue256", // 
+ ".clj": "\ue768", // 
+ ".cljs": "\ue76a", // 
+ ".cls": "\uf034", // 
+ ".cmd": "\ue70f", // 
+ ".coffee": "\uf0f4", // ď´
+ ".conf": "\ue615", // î•
+ ".cp": "\ue61d", // îť
+ ".cpio": "\uf410", // ď
+ ".cpp": "\ue61d", // îť
+ ".cs": "\uf81a", // ď š
+ ".csh": "\uf489", // ď’‰
+ ".cshtml": "\uf1fa", // 
+ ".csproj": "\uf81a", // ď š
+ ".css": "\ue749", // 
+ ".csv": "\uf1c3", // ď‡
+ ".csx": "\uf81a", // ď š
+ ".cxx": "\ue61d", // îť
+ ".d": "\ue7af", // 
+ ".dart": "\ue798", // îž
+ ".db": "\uf1c0", // 
+ ".deb": "\ue77d", // îť˝
+ ".diff": "\uf440", // ď‘€
+ ".djvu": "\uf02d", // ď€
+ ".dll": "\ue70f", // 
+ ".doc": "\uf1c2", // 
+ ".docx": "\uf1c2", // 
+ ".ds_store": "\uf179", // ď…ą
+ ".DS_store": "\uf179", // ď…ą
+ ".dump": "\uf1c0", // 
+ ".ebook": "\ue28b", // 
+ ".ebuild": "\uf30d", // 
+ ".editorconfig": "\ue615", // î•
+ ".ejs": "\ue618", // î
+ ".elm": "\ue62c", // î¬
+ ".env": "\uf462", // 
+ ".eot": "\uf031", // 
+ ".epub": "\ue28a", // 
+ ".erb": "\ue73b", // îś»
+ ".erl": "\ue7b1", // îž±
+ ".ex": "\ue62d", // î
+ ".exe": "\uf17a", // ď…ş
+ ".exs": "\ue62d", // î
+ ".fish": "\uf489", // ď’‰
+ ".flac": "\uf001", // ď€
+ ".flv": "\uf03d", // 
+ ".font": "\uf031", // 
+ ".fs": "\ue7a7", // îž§
+ ".fsi": "\ue7a7", // îž§
+ ".fsx": "\ue7a7", // îž§
+ ".gdoc": "\uf1c2", // 
+ ".gem": "\ue21e", // îž
+ ".gemfile": "\ue21e", // îž
+ ".gemspec": "\ue21e", // îž
+ ".gform": "\uf298", // ďŠ
+ ".gif": "\uf1c5", // 
+ ".git": "\uf1d3", // 
+ ".gitattributes": "\uf1d3", // 
+ ".gitignore": "\uf1d3", // 
+ ".gitmodules": "\uf1d3", // 
+ ".go": "\ue626", // î¦
+ ".gradle": "\ue256", // 
+ ".groovy": "\ue775", // îťµ
+ ".gsheet": "\uf1c3", // ď‡
+ ".gslides": "\uf1c4", // 
+ ".guardfile": "\ue21e", // îž
+ ".gz": "\uf410", // ď
+ ".h": "\uf0fd", // ď˝
+ ".hbs": "\ue60f", // îŹ
+ ".hpp": "\uf0fd", // ď˝
+ ".hs": "\ue777", // îť·
+ ".htm": "\uf13b", // ď„»
+ ".html": "\uf13b", // ď„»
+ ".hxx": "\uf0fd", // ď˝
+ ".ico": "\uf1c5", // 
+ ".image": "\uf1c5", // 
+ ".iml": "\ue7b5", // îžµ
+ ".ini": "\uf17a", // ď…ş
+ ".ipynb": "\ue606", // î†
+ ".iso": "\ue271", // 
+ ".j2c": "\uf1c5", // 
+ ".j2k": "\uf1c5", // 
+ ".jad": "\ue256", // 
+ ".jar": "\ue256", // 
+ ".java": "\ue256", // 
+ ".jfi": "\uf1c5", // 
+ ".jfif": "\uf1c5", // 
+ ".jif": "\uf1c5", // 
+ ".jl": "\ue624", // î¤
+ ".jmd": "\uf48a", // ď’Š
+ ".jp2": "\uf1c5", // 
+ ".jpe": "\uf1c5", // 
+ ".jpeg": "\uf1c5", // 
+ ".jpg": "\uf1c5", // 
+ ".jpx": "\uf1c5", // 
+ ".js": "\ue74e", // 
+ ".json": "\ue60b", // î‹
+ ".jsx": "\ue7ba", // îžş
+ ".jxl": "\uf1c5", // 
+ ".ksh": "\uf489", // ď’‰
+ ".latex": "\uf034", // 
+ ".less": "\ue758", // îť
+ ".lhs": "\ue777", // îť·
+ ".license": "\uf718", // ďś
+ ".localized": "\uf179", // ď…ą
+ ".lock": "\uf023", // 
+ ".log": "\uf18d", // 
+ ".lua": "\ue620", // î
+ ".lz": "\uf410", // ď
+ ".lz4": "\uf410", // ď
+ ".lzh": "\uf410", // ď
+ ".lzma": "\uf410", // ď
+ ".lzo": "\uf410", // ď
+ ".m": "\ue61e", // îž
+ ".mm": "\ue61d", // îť
+ ".m4a": "\uf001", // ď€
+ ".markdown": "\uf48a", // ď’Š
+ ".md": "\uf48a", // ď’Š
+ ".mjs": "\ue74e", // 
+ ".mk": "\uf489", // ď’‰
+ ".mkd": "\uf48a", // ď’Š
+ ".mkv": "\uf03d", // 
+ ".mobi": "\ue28b", // 
+ ".mov": "\uf03d", // 
+ ".mp3": "\uf001", // ď€
+ ".mp4": "\uf03d", // 
+ ".msi": "\ue70f", // 
+ ".mustache": "\ue60f", // îŹ
+ ".nix": "\uf313", // 
+ ".node": "\uf898", // ď˘
+ ".npmignore": "\ue71e", // îśž
+ ".odp": "\uf1c4", // 
+ ".ods": "\uf1c3", // ď‡
+ ".odt": "\uf1c2", // 
+ ".ogg": "\uf001", // ď€
+ ".ogv": "\uf03d", // 
+ ".otf": "\uf031", // 
+ ".part": "\uf43a", // ďş
+ ".patch": "\uf440", // ď‘€
+ ".pdf": "\uf1c1", // ď‡
+ ".php": "\ue73d", // îś˝
+ ".pl": "\ue769", // îť©
+ ".png": "\uf1c5", // 
+ ".ppt": "\uf1c4", // 
+ ".pptx": "\uf1c4", // 
+ ".procfile": "\ue21e", // îž
+ ".properties": "\ue60b", // î‹
+ ".ps1": "\uf489", // ď’‰
+ ".psd": "\ue7b8", // 
+ ".pxm": "\uf1c5", // 
+ ".py": "\ue606", // î†
+ ".pyc": "\ue606", // î†
+ ".r": "\uf25d", // 
+ ".rakefile": "\ue21e", // îž
+ ".rar": "\uf410", // ď
+ ".razor": "\uf1fa", // 
+ ".rb": "\ue21e", // îž
+ ".rdata": "\uf25d", // 
+ ".rdb": "\ue76d", // îť
+ ".rdoc": "\uf48a", // ď’Š
+ ".rds": "\uf25d", // 
+ ".readme": "\uf48a", // ď’Š
+ ".rlib": "\ue7a8", // 
+ ".rmd": "\uf48a", // ď’Š
+ ".rpm": "\ue7bb", // îž»
+ ".rs": "\ue7a8", // 
+ ".rspec": "\ue21e", // îž
+ ".rspec_parallel": "\ue21e", // îž
+ ".rspec_status": "\ue21e", // îž
+ ".rss": "\uf09e", // 
+ ".rtf": "\uf718", // ďś
+ ".ru": "\ue21e", // îž
+ ".rubydoc": "\ue73b", // îś»
+ ".sass": "\ue603", // î
+ ".scala": "\ue737", // îś·
+ ".scss": "\ue749", // 
+ ".sh": "\uf489", // ď’‰
+ ".shell": "\uf489", // ď’‰
+ ".slim": "\ue73b", // îś»
+ ".sln": "\ue70c", // 
+ ".so": "\uf17c", // ď…Ľ
+ ".sql": "\uf1c0", // 
+ ".sqlite3": "\ue7c4", // îź„
+ ".sty": "\uf034", // 
+ ".styl": "\ue600", // î€
+ ".stylus": "\ue600", // î€
+ ".svg": "\uf1c5", // 
+ ".swift": "\ue755", // îť•
+ ".tar": "\uf410", // ď
+ ".taz": "\uf410", // ď
+ ".tbz": "\uf410", // ď
+ ".tbz2": "\uf410", // ď
+ ".tex": "\uf034", // 
+ ".tgz": "\uf410", // ď
+ ".tiff": "\uf1c5", // 
+ ".tlz": "\uf410", // ď
+ ".toml": "\ue615", // î•
+ ".torrent": "\ue275", // 
+ ".ts": "\ue628", // î¨
+ ".tsv": "\uf1c3", // ď‡
+ ".tsx": "\ue7ba", // îžş
+ ".ttf": "\uf031", // 
+ ".twig": "\ue61c", // îś
+ ".txt": "\uf15c", // ď…ś
+ ".txz": "\uf410", // ď
+ ".tz": "\uf410", // ď
+ ".tzo": "\uf410", // ď
+ ".video": "\uf03d", // 
+ ".vim": "\ue62b", // î«
+ ".vue": "\ufd42", // ﵂
+ ".war": "\ue256", // 
+ ".wav": "\uf001", // ď€
+ ".webm": "\uf03d", // 
+ ".webp": "\uf1c5", // 
+ ".windows": "\uf17a", // ď…ş
+ ".woff": "\uf031", // 
+ ".woff2": "\uf031", // 
+ ".xhtml": "\uf13b", // ď„»
+ ".xls": "\uf1c3", // ď‡
+ ".xlsx": "\uf1c3", // ď‡
+ ".xml": "\uf121", // 
+ ".xul": "\uf121", // 
+ ".xz": "\uf410", // ď
+ ".yaml": "\uf481", // ď’
+ ".yml": "\uf481", // ď’
+ ".zip": "\uf410", // ď
+ ".zsh": "\uf489", // ď’‰
+ ".zsh-theme": "\uf489", // ď’‰
+ ".zshrc": "\uf489", // ď’‰
+ ".zst": "\uf410", // ď
+}
+
+func IconForFile(name string, isSubmodule bool, isDirectory bool) string {
+ base := filepath.Base(name)
+ if icon, ok := nameIconMap[base]; ok {
+ return icon
+ }
+
+ ext := filepath.Ext(name)
+ if icon, ok := extIconMap[ext]; ok {
+ return icon
+ }
+
+ if isSubmodule {
+ return DEFAULT_SUBMODULE_ICON
+ } else if isDirectory {
+ return DEFAULT_DIRECTORY_ICON
+ }
+ return DEFAULT_FILE_ICON
+}
diff --git a/pkg/gui/presentation/icons/git_icons.go b/pkg/gui/presentation/icons/git_icons.go
new file mode 100644
index 000000000..0f891d7bd
--- /dev/null
+++ b/pkg/gui/presentation/icons/git_icons.go
@@ -0,0 +1,61 @@
+package icons
+
+import (
+ "strings"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+)
+
+const (
+ BRANCH_ICON = "\ufb2b" // שׂ
+ DETACHED_HEAD_ICON = "\ue729" // îś©
+ TAG_ICON = "\uf02b" // 
+ COMMIT_ICON = "\ufc16" // ď°–
+ MERGE_COMMIT_ICON = "\ufb2c" // שּׁ
+ DEFAULT_REMOTE_ICON = "\uf7a1" // 
+)
+
+type remoteIcon struct {
+ domain string
+ icon string
+}
+
+var remoteIcons = []remoteIcon{
+ {domain: "github.com", icon: "\ue709"}, // 
+ {domain: "bitbucket.org", icon: "\ue703"}, // îś
+ {domain: "gitlab.com", icon: "\uf296"}, // 
+ {domain: "dev.azure.com", icon: "\ufd03"}, // ď´
+}
+
+func IconForBranch(branch *models.Branch) string {
+ if branch.DisplayName != "" {
+ return DETACHED_HEAD_ICON
+ }
+ return BRANCH_ICON
+}
+
+func IconForRemoteBranch(branch *models.RemoteBranch) string {
+ return BRANCH_ICON
+}
+
+func IconForTag(tag *models.Tag) string {
+ return TAG_ICON
+}
+
+func IconForCommit(commit *models.Commit) string {
+ if len(commit.Parents) > 1 {
+ return MERGE_COMMIT_ICON
+ }
+ return COMMIT_ICON
+}
+
+func IconForRemote(remote *models.Remote) string {
+ for _, r := range remoteIcons {
+ for _, url := range remote.Urls {
+ if strings.Contains(url, r.domain) {
+ return r.icon
+ }
+ }
+ }
+ return DEFAULT_REMOTE_ICON
+}
diff --git a/pkg/gui/presentation/icons/icons.go b/pkg/gui/presentation/icons/icons.go
new file mode 100644
index 000000000..81b16108b
--- /dev/null
+++ b/pkg/gui/presentation/icons/icons.go
@@ -0,0 +1,11 @@
+package icons
+
+var isIconEnabled = false
+
+func IsIconEnabled() bool {
+ return isIconEnabled
+}
+
+func SetIconEnabled(showIcons bool) {
+ isIconEnabled = showIcons
+}
diff --git a/pkg/gui/presentation/menu.go b/pkg/gui/presentation/menu.go
new file mode 100644
index 000000000..c43896c22
--- /dev/null
+++ b/pkg/gui/presentation/menu.go
@@ -0,0 +1,7 @@
+package presentation
+
+import "github.com/jesseduffield/lazygit/pkg/gui/style"
+
+func OpensMenuStyle(str string) string {
+ return style.FgMagenta.Sprintf("%s...", str)
+}
diff --git a/pkg/gui/presentation/reflog_commits.go b/pkg/gui/presentation/reflog_commits.go
index fd843d884..5a49f0374 100644
--- a/pkg/gui/presentation/reflog_commits.go
+++ b/pkg/gui/presentation/reflog_commits.go
@@ -1,6 +1,8 @@
package presentation
import (
+ "github.com/jesseduffield/generics/set"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/theme"
@@ -8,59 +10,68 @@ import (
"github.com/kyokomi/emoji/v2"
)
-func GetReflogCommitListDisplayStrings(commits []*models.Commit, fullDescription bool, cherryPickedCommitShaMap map[string]bool, diffName string, parseEmoji bool) [][]string {
- lines := make([][]string, len(commits))
-
- var displayFunc func(*models.Commit, map[string]bool, bool, bool) []string
+func GetReflogCommitListDisplayStrings(commits []*models.Commit, fullDescription bool, cherryPickedCommitShaSet *set.Set[string], diffName string, timeFormat string, parseEmoji bool) [][]string {
+ var displayFunc func(*models.Commit, reflogCommitDisplayAttributes) []string
if fullDescription {
displayFunc = getFullDescriptionDisplayStringsForReflogCommit
} else {
displayFunc = getDisplayStringsForReflogCommit
}
- for i := range commits {
- diffed := commits[i].Sha == diffName
- lines[i] = displayFunc(commits[i], cherryPickedCommitShaMap, diffed, parseEmoji)
- }
-
- return lines
+ return slices.Map(commits, func(commit *models.Commit) []string {
+ diffed := commit.Sha == diffName
+ cherryPicked := cherryPickedCommitShaSet.Includes(commit.Sha)
+ return displayFunc(commit,
+ reflogCommitDisplayAttributes{
+ cherryPicked: cherryPicked,
+ diffed: diffed,
+ parseEmoji: parseEmoji,
+ timeFormat: timeFormat,
+ })
+ })
}
-func coloredReflogSha(c *models.Commit, cherryPickedCommitShaMap map[string]bool) string {
+func reflogShaColor(cherryPicked, diffed bool) style.TextStyle {
+ if diffed {
+ return theme.DiffTerminalColor
+ }
+
shaColor := style.FgBlue
- if cherryPickedCommitShaMap[c.Sha] {
+ if cherryPicked {
shaColor = theme.CherryPickedCommitTextStyle
}
- return shaColor.Sprint(c.ShortSha())
+ return shaColor
}
-func getFullDescriptionDisplayStringsForReflogCommit(c *models.Commit, cherryPickedCommitShaMap map[string]bool, diffed, parseEmoji bool) []string {
- colorAttr := theme.DefaultTextColor
- if diffed {
- colorAttr = theme.DiffTerminalColor
- }
+type reflogCommitDisplayAttributes struct {
+ cherryPicked bool
+ diffed bool
+ parseEmoji bool
+ timeFormat string
+}
+func getFullDescriptionDisplayStringsForReflogCommit(c *models.Commit, attrs reflogCommitDisplayAttributes) []string {
name := c.Name
- if parseEmoji {
+ if attrs.parseEmoji {
name = emoji.Sprint(name)
}
return []string{
- coloredReflogSha(c, cherryPickedCommitShaMap),
- style.FgMagenta.Sprint(utils.UnixToDate(c.UnixTimestamp)),
- colorAttr.Sprint(name),
- }
-}
-
-func getDisplayStringsForReflogCommit(c *models.Commit, cherryPickedCommitShaMap map[string]bool, diffed, parseEmoji bool) []string {
- name := c.Name
- if parseEmoji {
- name = emoji.Sprint(name)
- }
-
- return []string{
- coloredReflogSha(c, cherryPickedCommitShaMap),
+ reflogShaColor(attrs.cherryPicked, attrs.diffed).Sprint(c.ShortSha()),
+ style.FgMagenta.Sprint(utils.UnixToDate(c.UnixTimestamp, attrs.timeFormat)),
+ theme.DefaultTextColor.Sprint(name),
+ }
+}
+
+func getDisplayStringsForReflogCommit(c *models.Commit, attrs reflogCommitDisplayAttributes) []string {
+ name := c.Name
+ if attrs.parseEmoji {
+ name = emoji.Sprint(name)
+ }
+
+ return []string{
+ reflogShaColor(attrs.cherryPicked, attrs.diffed).Sprint(c.ShortSha()),
theme.DefaultTextColor.Sprint(name),
}
}
diff --git a/pkg/gui/presentation/remote_branches.go b/pkg/gui/presentation/remote_branches.go
index d8439acfe..4052d3fed 100644
--- a/pkg/gui/presentation/remote_branches.go
+++ b/pkg/gui/presentation/remote_branches.go
@@ -1,19 +1,17 @@
package presentation
import (
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/theme"
)
func GetRemoteBranchListDisplayStrings(branches []*models.RemoteBranch, diffName string) [][]string {
- lines := make([][]string, len(branches))
-
- for i := range branches {
- diffed := branches[i].FullName() == diffName
- lines[i] = getRemoteBranchDisplayStrings(branches[i], diffed)
- }
-
- return lines
+ return slices.Map(branches, func(branch *models.RemoteBranch) []string {
+ diffed := branch.FullName() == diffName
+ return getRemoteBranchDisplayStrings(branch, diffed)
+ })
}
// getRemoteBranchDisplayStrings returns the display string of branch
@@ -23,5 +21,10 @@ func getRemoteBranchDisplayStrings(b *models.RemoteBranch, diffed bool) []string
textStyle = theme.DiffTerminalColor
}
- return []string{textStyle.Sprint(b.Name)}
+ res := make([]string, 0, 2)
+ if icons.IsIconEnabled() {
+ res = append(res, textStyle.Sprint(icons.IconForRemoteBranch(b)))
+ }
+ res = append(res, textStyle.Sprint(b.Name))
+ return res
}
diff --git a/pkg/gui/presentation/remotes.go b/pkg/gui/presentation/remotes.go
index a1e50fe2f..7f82fe970 100644
--- a/pkg/gui/presentation/remotes.go
+++ b/pkg/gui/presentation/remotes.go
@@ -1,20 +1,18 @@
package presentation
import (
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/theme"
)
func GetRemoteListDisplayStrings(remotes []*models.Remote, diffName string) [][]string {
- lines := make([][]string, len(remotes))
-
- for i := range remotes {
- diffed := remotes[i].Name == diffName
- lines[i] = getRemoteDisplayStrings(remotes[i], diffed)
- }
-
- return lines
+ return slices.Map(remotes, func(remote *models.Remote) []string {
+ diffed := remote.Name == diffName
+ return getRemoteDisplayStrings(remote, diffed)
+ })
}
// getRemoteDisplayStrings returns the display string of branch
@@ -26,5 +24,10 @@ func getRemoteDisplayStrings(r *models.Remote, diffed bool) []string {
textStyle = theme.DiffTerminalColor
}
- return []string{textStyle.Sprint(r.Name), style.FgBlue.Sprintf("%d branches", branchCount)}
+ res := make([]string, 0, 3)
+ if icons.IsIconEnabled() {
+ res = append(res, textStyle.Sprint(icons.IconForRemote(r)))
+ }
+ res = append(res, textStyle.Sprint(r.Name), style.FgBlue.Sprintf("%d branches", branchCount))
+ return res
}
diff --git a/pkg/gui/presentation/stash_entries.go b/pkg/gui/presentation/stash_entries.go
index f15b35a9c..54b39c636 100644
--- a/pkg/gui/presentation/stash_entries.go
+++ b/pkg/gui/presentation/stash_entries.go
@@ -1,19 +1,16 @@
package presentation
import (
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/theme"
)
func GetStashEntryListDisplayStrings(stashEntries []*models.StashEntry, diffName string) [][]string {
- lines := make([][]string, len(stashEntries))
-
- for i := range stashEntries {
- diffed := stashEntries[i].RefName() == diffName
- lines[i] = getStashEntryDisplayStrings(stashEntries[i], diffed)
- }
-
- return lines
+ return slices.Map(stashEntries, func(stashEntry *models.StashEntry) []string {
+ diffed := stashEntry.RefName() == diffName
+ return getStashEntryDisplayStrings(stashEntry, diffed)
+ })
}
// getStashEntryDisplayStrings returns the display string of branch
diff --git a/pkg/gui/presentation/submodules.go b/pkg/gui/presentation/submodules.go
index 2d131ed8f..0fb057ef0 100644
--- a/pkg/gui/presentation/submodules.go
+++ b/pkg/gui/presentation/submodules.go
@@ -1,18 +1,15 @@
package presentation
import (
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/theme"
)
func GetSubmoduleListDisplayStrings(submodules []*models.SubmoduleConfig) [][]string {
- lines := make([][]string, len(submodules))
-
- for i := range submodules {
- lines[i] = getSubmoduleDisplayStrings(submodules[i])
- }
-
- return lines
+ return slices.Map(submodules, func(submodule *models.SubmoduleConfig) []string {
+ return getSubmoduleDisplayStrings(submodule)
+ })
}
func getSubmoduleDisplayStrings(s *models.SubmoduleConfig) []string {
diff --git a/pkg/gui/presentation/suggestions.go b/pkg/gui/presentation/suggestions.go
index 81c6a3a3d..5319b40f7 100644
--- a/pkg/gui/presentation/suggestions.go
+++ b/pkg/gui/presentation/suggestions.go
@@ -1,17 +1,14 @@
package presentation
import (
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
func GetSuggestionListDisplayStrings(suggestions []*types.Suggestion) [][]string {
- lines := make([][]string, len(suggestions))
-
- for i := range suggestions {
- lines[i] = getSuggestionDisplayStrings(suggestions[i])
- }
-
- return lines
+ return slices.Map(suggestions, func(suggestion *types.Suggestion) []string {
+ return getSuggestionDisplayStrings(suggestion)
+ })
}
func getSuggestionDisplayStrings(suggestion *types.Suggestion) []string {
diff --git a/pkg/gui/presentation/tags.go b/pkg/gui/presentation/tags.go
index 4754c4bef..2996db18d 100644
--- a/pkg/gui/presentation/tags.go
+++ b/pkg/gui/presentation/tags.go
@@ -1,19 +1,17 @@
package presentation
import (
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/theme"
)
func GetTagListDisplayStrings(tags []*models.Tag, diffName string) [][]string {
- lines := make([][]string, len(tags))
-
- for i := range tags {
- diffed := tags[i].Name == diffName
- lines[i] = getTagDisplayStrings(tags[i], diffed)
- }
-
- return lines
+ return slices.Map(tags, func(tag *models.Tag) []string {
+ diffed := tag.Name == diffName
+ return getTagDisplayStrings(tag, diffed)
+ })
}
// getTagDisplayStrings returns the display string of branch
@@ -22,5 +20,10 @@ func getTagDisplayStrings(t *models.Tag, diffed bool) []string {
if diffed {
textStyle = theme.DiffTerminalColor
}
- return []string{textStyle.Sprint(t.Name)}
+ res := make([]string, 0, 2)
+ if icons.IsIconEnabled() {
+ res = append(res, textStyle.Sprint(icons.IconForTag(t)))
+ }
+ res = append(res, textStyle.Sprint(t.Name))
+ return res
}
diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go
index b6c3069f2..4a87c98da 100644
--- a/pkg/gui/pty.go
+++ b/pkg/gui/pty.go
@@ -5,11 +5,13 @@ package gui
import (
"io"
+ "os"
"os/exec"
"strings"
"github.com/creack/pty"
"github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/utils"
)
func (gui *Gui) desiredPtySize() *pty.Winsize {
@@ -19,15 +21,16 @@ func (gui *Gui) desiredPtySize() *pty.Winsize {
}
func (gui *Gui) onResize() error {
- if gui.State.Ptmx == nil {
- return nil
- }
+ gui.Mutexes.PtyMutex.Lock()
+ defer gui.Mutexes.PtyMutex.Unlock()
- // TODO: handle resizing properly: we need to actually clear the main view
- // and re-read the output from our pty. Or we could just re-run the original
- // command from scratch
- if err := pty.Setsize(gui.State.Ptmx, gui.desiredPtySize()); err != nil {
- return err
+ for _, ptmx := range gui.viewPtmxMap {
+ // TODO: handle resizing properly: we need to actually clear the main view
+ // and re-read the output from our pty. Or we could just re-run the original
+ // command from scratch
+ if err := pty.Setsize(ptmx, gui.desiredPtySize()); err != nil {
+ return utils.WrapError(err)
+ }
}
return nil
@@ -41,7 +44,7 @@ func (gui *Gui) onResize() error {
// command.
func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error {
width, _ := gui.Views.Main.Size()
- pager := gui.Git.Config.GetPager(width)
+ pager := gui.git.Config.GetPager(width)
if pager == "" {
// if we're not using a custom pager we don't need to use a pty
@@ -57,20 +60,26 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
manager := gui.getManager(view)
+ var ptmx *os.File
start := func() (*exec.Cmd, io.Reader) {
- ptmx, err := pty.StartWithSize(cmd, gui.desiredPtySize())
+ var err error
+ ptmx, err = pty.StartWithSize(cmd, gui.desiredPtySize())
if err != nil {
- gui.Log.Error(err)
+ gui.c.Log.Error(err)
}
- gui.State.Ptmx = ptmx
+ gui.Mutexes.PtyMutex.Lock()
+ gui.viewPtmxMap[view.Name()] = ptmx
+ gui.Mutexes.PtyMutex.Unlock()
return cmd, ptmx
}
onClose := func() {
- gui.State.Ptmx.Close()
- gui.State.Ptmx = nil
+ gui.Mutexes.PtyMutex.Lock()
+ ptmx.Close()
+ delete(gui.viewPtmxMap, view.Name())
+ gui.Mutexes.PtyMutex.Unlock()
}
if err := manager.NewTask(manager.NewCmdTask(start, prefix, height+oy+10, onClose), cmdStr); err != nil {
diff --git a/pkg/gui/quitting.go b/pkg/gui/quitting.go
index c3fd2ce2f..cdb1ff09a 100644
--- a/pkg/gui/quitting.go
+++ b/pkg/gui/quitting.go
@@ -4,6 +4,7 @@ import (
"os"
"github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
// when a user runs lazygit with the LAZYGIT_NEW_DIR_FILE env variable defined
@@ -11,26 +12,29 @@ import (
// shell can then change to that directory. That means you don't get kicked
// back to the directory that you started with.
func (gui *Gui) recordCurrentDirectory() error {
- if os.Getenv("LAZYGIT_NEW_DIR_FILE") == "" {
- return nil
- }
-
// determine current directory, set it in LAZYGIT_NEW_DIR_FILE
dirName, err := os.Getwd()
if err != nil {
return err
}
+ return gui.recordDirectory(dirName)
+}
- return gui.OSCommand.CreateFileWithContent(os.Getenv("LAZYGIT_NEW_DIR_FILE"), dirName)
+func (gui *Gui) recordDirectory(dirName string) error {
+ newDirFilePath := os.Getenv("LAZYGIT_NEW_DIR_FILE")
+ if newDirFilePath == "" {
+ return nil
+ }
+ return gui.os.CreateFileWithContent(newDirFilePath, dirName)
}
func (gui *Gui) handleQuitWithoutChangingDirectory() error {
- gui.State.RetainOriginalDir = true
+ gui.RetainOriginalDir = true
return gui.quit()
}
func (gui *Gui) handleQuit() error {
- gui.State.RetainOriginalDir = false
+ gui.RetainOriginalDir = false
return gui.quit()
}
@@ -40,7 +44,7 @@ func (gui *Gui) handleTopLevelReturn() error {
parentContext, hasParent := currentContext.GetParentContext()
if hasParent && currentContext != nil && parentContext != nil {
// TODO: think about whether this should be marked as a return rather than adding to the stack
- return gui.pushContext(parentContext)
+ return gui.c.PushContext(parentContext)
}
for _, mode := range gui.modeStatuses() {
@@ -50,17 +54,13 @@ func (gui *Gui) handleTopLevelReturn() error {
}
repoPathStack := gui.RepoPathStack
- if len(repoPathStack) > 0 {
- n := len(repoPathStack) - 1
-
- path := repoPathStack[n]
-
- gui.RepoPathStack = repoPathStack[:n]
+ if !repoPathStack.IsEmpty() {
+ path := repoPathStack.Pop()
return gui.dispatchSwitchToRepo(path, true)
}
- if gui.UserConfig.QuitOnTopLevelReturn {
+ if gui.c.UserConfig.QuitOnTopLevelReturn {
return gui.handleQuit()
}
@@ -72,11 +72,11 @@ func (gui *Gui) quit() error {
return gui.createUpdateQuitConfirmation()
}
- if gui.UserConfig.ConfirmOnQuit {
- return gui.ask(askOpts{
- title: "",
- prompt: gui.Tr.ConfirmQuit,
- handleConfirm: func() error {
+ if gui.c.UserConfig.ConfirmOnQuit {
+ return gui.c.Confirm(types.ConfirmOpts{
+ Title: "",
+ Prompt: gui.c.Tr.ConfirmQuit,
+ HandleConfirm: func() error {
return gocui.ErrQuit
},
})
diff --git a/pkg/gui/rebase_options_panel.go b/pkg/gui/rebase_options_panel.go
deleted file mode 100644
index a9e7d9317..000000000
--- a/pkg/gui/rebase_options_panel.go
+++ /dev/null
@@ -1,155 +0,0 @@
-package gui
-
-import (
- "fmt"
- "strings"
-
- "github.com/jesseduffield/lazygit/pkg/commands/types/enums"
-)
-
-type RebaseOption string
-
-const (
- REBASE_OPTION_CONTINUE = "continue"
- REBASE_OPTION_ABORT = "abort"
- REBASE_OPTION_SKIP = "skip"
-)
-
-func (gui *Gui) handleCreateRebaseOptionsMenu() error {
- options := []string{REBASE_OPTION_CONTINUE, REBASE_OPTION_ABORT}
-
- if gui.Git.Status.WorkingTreeState() == enums.REBASE_MODE_REBASING {
- options = append(options, REBASE_OPTION_SKIP)
- }
-
- menuItems := make([]*menuItem, len(options))
- for i, option := range options {
- // note to self. Never, EVER, close over loop variables in a function
- option := option
- menuItems[i] = &menuItem{
- displayString: option,
- onPress: func() error {
- return gui.genericMergeCommand(option)
- },
- }
- }
-
- var title string
- if gui.Git.Status.WorkingTreeState() == enums.REBASE_MODE_MERGING {
- title = gui.Tr.MergeOptionsTitle
- } else {
- title = gui.Tr.RebaseOptionsTitle
- }
-
- return gui.createMenu(title, menuItems, createMenuOptions{showCancel: true})
-}
-
-func (gui *Gui) genericMergeCommand(command string) error {
- status := gui.Git.Status.WorkingTreeState()
-
- if status != enums.REBASE_MODE_MERGING && status != enums.REBASE_MODE_REBASING {
- return gui.createErrorPanel(gui.Tr.NotMergingOrRebasing)
- }
-
- gui.logAction(fmt.Sprintf("Merge/Rebase: %s", command))
-
- commandType := ""
- switch status {
- case enums.REBASE_MODE_MERGING:
- commandType = "merge"
- case enums.REBASE_MODE_REBASING:
- commandType = "rebase"
- default:
- // shouldn't be possible to land here
- }
-
- // we should end up with a command like 'git merge --continue'
-
- // it's impossible for a rebase to require a commit so we'll use a subprocess only if it's a merge
- if status == enums.REBASE_MODE_MERGING && command != REBASE_OPTION_ABORT && gui.UserConfig.Git.Merging.ManualCommit {
- // TODO: see if we should be calling more of the code from gui.Git.Rebase.GenericMergeOrRebaseAction
- return gui.runSubprocessWithSuspenseAndRefresh(
- gui.Git.Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command),
- )
- }
- result := gui.Git.Rebase.GenericMergeOrRebaseAction(commandType, command)
- if err := gui.handleGenericMergeCommandResult(result); err != nil {
- return err
- }
- return nil
-}
-
-var conflictStrings = []string{
- "Failed to merge in the changes",
- "When you have resolved this problem",
- "fix conflicts",
- "Resolve all conflicts manually",
-}
-
-func isMergeConflictErr(errStr string) bool {
- for _, str := range conflictStrings {
- if strings.Contains(errStr, str) {
- return true
- }
- }
-
- return false
-}
-
-func (gui *Gui) handleGenericMergeCommandResult(result error) error {
- if err := gui.refreshSidePanels(refreshOptions{mode: ASYNC}); err != nil {
- return err
- }
- if result == nil {
- return nil
- } else if strings.Contains(result.Error(), "No changes - did you forget to use") {
- return gui.genericMergeCommand(REBASE_OPTION_SKIP)
- } else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") {
- return gui.genericMergeCommand(REBASE_OPTION_CONTINUE)
- } else if strings.Contains(result.Error(), "No rebase in progress?") {
- // assume in this case that we're already done
- return nil
- } else if isMergeConflictErr(result.Error()) {
- return gui.ask(askOpts{
- title: gui.Tr.FoundConflictsTitle,
- prompt: gui.Tr.FoundConflicts,
- handlersManageFocus: true,
- handleConfirm: func() error {
- return gui.pushContext(gui.State.Contexts.Files)
- },
- handleClose: func() error {
- if err := gui.returnFromContext(); err != nil {
- return err
- }
-
- return gui.genericMergeCommand(REBASE_OPTION_ABORT)
- },
- })
- } else {
- return gui.createErrorPanel(result.Error())
- }
-}
-
-func (gui *Gui) abortMergeOrRebaseWithConfirm() error {
- // prompt user to confirm that they want to abort, then do it
- mode := gui.workingTreeStateNoun()
- return gui.ask(askOpts{
- title: fmt.Sprintf(gui.Tr.AbortTitle, mode),
- prompt: fmt.Sprintf(gui.Tr.AbortPrompt, mode),
- handleConfirm: func() error {
- return gui.genericMergeCommand(REBASE_OPTION_ABORT)
- },
- })
-}
-
-func (gui *Gui) workingTreeStateNoun() string {
- workingTreeState := gui.Git.Status.WorkingTreeState()
- switch workingTreeState {
- case enums.REBASE_MODE_NONE:
- return ""
- case enums.REBASE_MODE_MERGING:
- return "merge"
- default:
- return "rebase"
- }
-}
diff --git a/pkg/gui/recent_repos_panel.go b/pkg/gui/recent_repos_panel.go
index 7bf6b068c..705461726 100644
--- a/pkg/gui/recent_repos_panel.go
+++ b/pkg/gui/recent_repos_panel.go
@@ -1,49 +1,120 @@
package gui
import (
+ "fmt"
+ "io/ioutil"
"os"
"path/filepath"
+ "strings"
+ "sync"
+ "github.com/jesseduffield/generics/slices"
+ appTypes "github.com/jesseduffield/lazygit/pkg/app/types"
"github.com/jesseduffield/lazygit/pkg/commands"
- "github.com/jesseduffield/lazygit/pkg/commands/git_config"
"github.com/jesseduffield/lazygit/pkg/env"
+ "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
-func (gui *Gui) handleCreateRecentReposMenu() error {
- recentRepoPaths := gui.Config.GetAppState().RecentRepos
- reposCount := utils.Min(len(recentRepoPaths), 20)
+func (gui *Gui) getCurrentBranch(path string) string {
+ readHeadFile := func(path string) (string, error) {
+ headFile, err := ioutil.ReadFile(filepath.Join(path, "HEAD"))
+ if err == nil {
+ content := strings.TrimSpace(string(headFile))
+ refsPrefix := "ref: refs/heads/"
+ branchDisplay := ""
+ if strings.HasPrefix(content, refsPrefix) {
+ // is a branch
+ branchDisplay = strings.TrimPrefix(content, refsPrefix)
+ } else {
+ // detached HEAD state, displaying short SHA
+ branchDisplay = utils.ShortSha(content)
+ }
+ return branchDisplay, nil
+ }
+ return "", err
+ }
- // we won't show the current repo hence the -1
- menuItems := make([]*menuItem, reposCount-1)
- for i, path := range recentRepoPaths[1:reposCount] {
- path := path // cos we're closing over the loop variable
- menuItems[i] = &menuItem{
- displayStrings: []string{
- filepath.Base(path),
- style.FgMagenta.Sprint(path),
- },
- onPress: func() error {
- // if we were in a submodule, we want to forget about that stack of repos
- // so that hitting escape in the new repo does nothing
- gui.RepoPathStack = []string{}
- return gui.dispatchSwitchToRepo(path, false)
- },
+ gitDirPath := filepath.Join(path, ".git")
+
+ if gitDir, err := os.Stat(gitDirPath); err == nil {
+ if gitDir.IsDir() {
+ // ordinary repo
+ if branch, err := readHeadFile(gitDirPath); err == nil {
+ return branch
+ }
+ } else {
+ // worktree
+ if worktreeGitDir, err := ioutil.ReadFile(gitDirPath); err == nil {
+ content := strings.TrimSpace(string(worktreeGitDir))
+ worktreePath := strings.TrimPrefix(content, "gitdir: ")
+ if branch, err := readHeadFile(worktreePath); err == nil {
+ return branch
+ }
+ }
}
}
- return gui.createMenu(gui.Tr.RecentRepos, menuItems, createMenuOptions{showCancel: true})
+ return gui.c.Tr.LcBranchUnknown
+}
+
+func (gui *Gui) handleCreateRecentReposMenu() error {
+ // we'll show an empty panel if there are no recent repos
+ recentRepoPaths := []string{}
+ if len(gui.c.GetAppState().RecentRepos) > 0 {
+ // we skip the first one because we're currently in it
+ recentRepoPaths = gui.c.GetAppState().RecentRepos[1:]
+ }
+
+ currentBranches := sync.Map{}
+
+ wg := sync.WaitGroup{}
+ wg.Add(len(recentRepoPaths))
+
+ for _, path := range recentRepoPaths {
+ go func(path string) {
+ defer wg.Done()
+ currentBranches.Store(path, gui.getCurrentBranch(path))
+ }(path)
+ }
+
+ wg.Wait()
+
+ menuItems := slices.Map(recentRepoPaths, func(path string) *types.MenuItem {
+ branchName, _ := currentBranches.Load(path)
+ if icons.IsIconEnabled() {
+ branchName = icons.BRANCH_ICON + " " + fmt.Sprintf("%v", branchName)
+ }
+
+ return &types.MenuItem{
+ LabelColumns: []string{
+ filepath.Base(path),
+ style.FgCyan.Sprint(branchName),
+ style.FgMagenta.Sprint(path),
+ },
+ OnPress: func() error {
+ // if we were in a submodule, we want to forget about that stack of repos
+ // so that hitting escape in the new repo does nothing
+ gui.RepoPathStack.Clear()
+ return gui.dispatchSwitchToRepo(path, false)
+ },
+ }
+ })
+
+ return gui.c.Menu(types.CreateMenuOptions{Title: gui.c.Tr.RecentRepos, Items: menuItems})
}
func (gui *Gui) handleShowAllBranchLogs() error {
- cmdObj := gui.Git.Branch.AllBranchesLogCmdObj()
- task := NewRunPtyTask(cmdObj.GetCmd())
+ cmdObj := gui.git.Branch.AllBranchesLogCmdObj()
+ task := types.NewRunPtyTask(cmdObj.GetCmd())
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "Log",
- task: task,
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: gui.c.Tr.LogTitle,
+ Task: task,
},
})
}
@@ -57,12 +128,12 @@ func (gui *Gui) dispatchSwitchToRepo(path string, reuse bool) error {
if err := os.Chdir(path); err != nil {
if os.IsNotExist(err) {
- return gui.createErrorPanel(gui.Tr.ErrRepositoryMovedOrDeleted)
+ return gui.c.ErrorMsg(gui.c.Tr.ErrRepositoryMovedOrDeleted)
}
return err
}
- if err := commands.VerifyInGitRepo(gui.OSCommand); err != nil {
+ if err := commands.VerifyInGitRepo(gui.os); err != nil {
if err := os.Chdir(originalPath); err != nil {
return err
}
@@ -70,45 +141,41 @@ func (gui *Gui) dispatchSwitchToRepo(path string, reuse bool) error {
return err
}
- newGitCommand, err := commands.NewGitCommand(gui.Common, gui.OSCommand, git_config.NewStdCachedGitConfig(gui.Log))
- if err != nil {
+ if err := gui.recordCurrentDirectory(); err != nil {
return err
}
- gui.Git = newGitCommand
// these two mutexes are used by our background goroutines (triggered via `gui.goEvery`. We don't want to
// switch to a repo while one of these goroutines is in the process of updating something
- gui.Mutexes.FetchMutex.Lock()
- defer gui.Mutexes.FetchMutex.Unlock()
+ gui.Mutexes.SyncMutex.Lock()
+ defer gui.Mutexes.SyncMutex.Unlock()
gui.Mutexes.RefreshingFilesMutex.Lock()
defer gui.Mutexes.RefreshingFilesMutex.Unlock()
- gui.resetState("", reuse)
-
- return nil
+ return gui.onNewRepo(appTypes.StartArgs{}, reuse)
}
// updateRecentRepoList registers the fact that we opened lazygit in this repo,
// so that we can open the same repo via the 'recent repos' menu
func (gui *Gui) updateRecentRepoList() error {
- if gui.Git.Status.IsBareRepo() {
+ if gui.git.Status.IsBareRepo() {
// we could totally do this but it would require storing both the git-dir and the
// worktree in our recent repos list, which is a change that would need to be
// backwards compatible
- gui.Log.Info("Not appending bare repo to recent repo list")
+ gui.c.Log.Info("Not appending bare repo to recent repo list")
return nil
}
- recentRepos := gui.Config.GetAppState().RecentRepos
+ recentRepos := gui.c.GetAppState().RecentRepos
currentRepo, err := os.Getwd()
if err != nil {
return err
}
known, recentRepos := newRecentReposList(recentRepos, currentRepo)
gui.IsNewRepo = known
- gui.Config.GetAppState().RecentRepos = recentRepos
- return gui.Config.SaveAppState()
+ gui.c.GetAppState().RecentRepos = recentRepos
+ return gui.c.SaveAppState()
}
// newRecentReposList returns a new repo list with a new entry but only when it doesn't exist yet
@@ -117,6 +184,9 @@ func newRecentReposList(recentRepos []string, currentRepo string) (bool, []strin
newRepos := []string{currentRepo}
for _, repo := range recentRepos {
if repo != currentRepo {
+ if _, err := os.Stat(filepath.Join(repo, ".git")); err != nil {
+ continue
+ }
newRepos = append(newRepos, repo)
} else {
isNew = false
diff --git a/pkg/gui/recording.go b/pkg/gui/recording.go
deleted file mode 100644
index 0a7f723df..000000000
--- a/pkg/gui/recording.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package gui
-
-import (
- "encoding/json"
- "io/ioutil"
- "log"
- "os"
- "strconv"
-
- "github.com/jesseduffield/gocui"
-)
-
-func recordingEvents() bool {
- return recordEventsTo() != ""
-}
-
-func recordEventsTo() string {
- return os.Getenv("RECORD_EVENTS_TO")
-}
-
-func replaying() bool {
- return os.Getenv("REPLAY_EVENTS_FROM") != ""
-}
-
-func headless() bool {
- return os.Getenv("HEADLESS") != ""
-}
-
-func getRecordingSpeed() float64 {
- // humans are slow so this speeds things up.
- speed := 1.0
- envReplaySpeed := os.Getenv("SPEED")
- if envReplaySpeed != "" {
- var err error
- speed, err = strconv.ParseFloat(envReplaySpeed, 64)
- if err != nil {
- log.Fatal(err)
- }
- }
- return speed
-}
-
-func (gui *Gui) loadRecording() (*gocui.Recording, error) {
- path := os.Getenv("REPLAY_EVENTS_FROM")
-
- data, err := ioutil.ReadFile(path)
- if err != nil {
- return nil, err
- }
-
- recording := &gocui.Recording{}
-
- err = json.Unmarshal(data, &recording)
- if err != nil {
- return nil, err
- }
-
- return recording, nil
-}
-
-func (gui *Gui) saveRecording(recording *gocui.Recording) error {
- if !recordingEvents() {
- return nil
- }
-
- jsonEvents, err := json.Marshal(recording)
- if err != nil {
- return err
- }
-
- path := recordEventsTo()
-
- return ioutil.WriteFile(path, jsonEvents, 0600)
-}
diff --git a/pkg/gui/reflog_panel.go b/pkg/gui/reflog_panel.go
index af8e8092c..cb84177da 100644
--- a/pkg/gui/reflog_panel.go
+++ b/pkg/gui/reflog_panel.go
@@ -1,120 +1,23 @@
package gui
-import (
- "github.com/jesseduffield/lazygit/pkg/commands/models"
-)
-
-// list panel functions
-
-func (gui *Gui) getSelectedReflogCommit() *models.Commit {
- selectedLine := gui.State.Panels.ReflogCommits.SelectedLineIdx
- reflogComits := gui.State.FilteredReflogCommits
- if selectedLine == -1 || len(reflogComits) == 0 {
- return nil
- }
-
- return reflogComits[selectedLine]
-}
+import "github.com/jesseduffield/lazygit/pkg/gui/types"
func (gui *Gui) reflogCommitsRenderToMain() error {
- commit := gui.getSelectedReflogCommit()
- var task updateTask
+ commit := gui.State.Contexts.ReflogCommits.GetSelected()
+ var task types.UpdateTask
if commit == nil {
- task = NewRenderStringTask("No reflog history")
+ task = types.NewRenderStringTask("No reflog history")
} else {
- cmdObj := gui.Git.Commit.ShowCmdObj(commit.Sha, gui.State.Modes.Filtering.GetPath())
+ cmdObj := gui.git.Commit.ShowCmdObj(commit.Sha, gui.State.Modes.Filtering.GetPath())
- task = NewRunPtyTask(cmdObj.GetCmd())
+ task = types.NewRunPtyTask(cmdObj.GetCmd())
}
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "Reflog Entry",
- task: task,
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: "Reflog Entry",
+ Task: task,
},
})
}
-
-// the reflogs panel is the only panel where we cache data, in that we only
-// load entries that have been created since we last ran the call. This means
-// we need to be more careful with how we use this, and to ensure we're emptying
-// the reflogs array when changing contexts.
-// This method also manages two things: ReflogCommits and FilteredReflogCommits.
-// FilteredReflogCommits are rendered in the reflogs panel, and ReflogCommits
-// are used by the branches panel to obtain recency values for sorting.
-func (gui *Gui) refreshReflogCommits() error {
- // pulling state into its own variable incase it gets swapped out for another state
- // and we get an out of bounds exception
- state := gui.State
- var lastReflogCommit *models.Commit
- if len(state.ReflogCommits) > 0 {
- lastReflogCommit = state.ReflogCommits[0]
- }
-
- refresh := func(stateCommits *[]*models.Commit, filterPath string) error {
- commits, onlyObtainedNewReflogCommits, err := gui.Git.Loaders.ReflogCommits.
- GetReflogCommits(lastReflogCommit, filterPath)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- if onlyObtainedNewReflogCommits {
- *stateCommits = append(commits, *stateCommits...)
- } else {
- *stateCommits = commits
- }
- return nil
- }
-
- if err := refresh(&state.ReflogCommits, ""); err != nil {
- return err
- }
-
- if gui.State.Modes.Filtering.Active() {
- if err := refresh(&state.FilteredReflogCommits, state.Modes.Filtering.GetPath()); err != nil {
- return err
- }
- } else {
- state.FilteredReflogCommits = state.ReflogCommits
- }
-
- return gui.postRefreshUpdate(gui.State.Contexts.ReflogCommits)
-}
-
-func (gui *Gui) handleCheckoutReflogCommit() error {
- commit := gui.getSelectedReflogCommit()
- if commit == nil {
- return nil
- }
-
- err := gui.ask(askOpts{
- title: gui.Tr.LcCheckoutCommit,
- prompt: gui.Tr.SureCheckoutThisCommit,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.CheckoutReflogCommit)
- return gui.handleCheckoutRef(commit.Sha, handleCheckoutRefOptions{})
- },
- })
- if err != nil {
- return err
- }
-
- gui.State.Panels.ReflogCommits.SelectedLineIdx = 0
-
- return nil
-}
-
-func (gui *Gui) handleCreateReflogResetMenu() error {
- commit := gui.getSelectedReflogCommit()
-
- return gui.createResetMenu(commit.Sha)
-}
-
-func (gui *Gui) handleViewReflogCommitFiles() error {
- commit := gui.getSelectedReflogCommit()
- if commit == nil {
- return nil
- }
-
- return gui.switchToCommitFilesContext(commit.Sha, false, gui.State.Contexts.ReflogCommits, "commits")
-}
diff --git a/pkg/gui/refresh.go b/pkg/gui/refresh.go
new file mode 100644
index 000000000..d09583389
--- /dev/null
+++ b/pkg/gui/refresh.go
@@ -0,0 +1,713 @@
+package gui
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+
+ "github.com/jesseduffield/generics/set"
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands/loaders"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/commands/types/enums"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/filetree"
+ "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
+ "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
+ "github.com/jesseduffield/lazygit/pkg/gui/presentation"
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+func getScopeNames(scopes []types.RefreshableView) []string {
+ scopeNameMap := map[types.RefreshableView]string{
+ types.COMMITS: "commits",
+ types.BRANCHES: "branches",
+ types.FILES: "files",
+ types.SUBMODULES: "submodules",
+ types.STASH: "stash",
+ types.REFLOG: "reflog",
+ types.TAGS: "tags",
+ types.REMOTES: "remotes",
+ types.STATUS: "status",
+ types.BISECT_INFO: "bisect",
+ types.STAGING: "staging",
+ types.MERGE_CONFLICTS: "mergeConflicts",
+ }
+
+ return slices.Map(scopes, func(scope types.RefreshableView) string {
+ return scopeNameMap[scope]
+ })
+}
+
+func getModeName(mode types.RefreshMode) string {
+ switch mode {
+ case types.SYNC:
+ return "sync"
+ case types.ASYNC:
+ return "async"
+ case types.BLOCK_UI:
+ return "block-ui"
+ default:
+ return "unknown mode"
+ }
+}
+
+func (gui *Gui) Refresh(options types.RefreshOptions) error {
+ if options.Scope == nil {
+ gui.c.Log.Infof(
+ "refreshing all scopes in %s mode",
+ getModeName(options.Mode),
+ )
+ } else {
+ gui.c.Log.Infof(
+ "refreshing the following scopes in %s mode: %s",
+ getModeName(options.Mode),
+ strings.Join(getScopeNames(options.Scope), ","),
+ )
+ }
+
+ wg := sync.WaitGroup{}
+
+ f := func() {
+ 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,
+ types.FILES,
+ types.STASH,
+ types.REFLOG,
+ types.TAGS,
+ types.REMOTES,
+ types.STATUS,
+ types.BISECT_INFO,
+ })
+ } else {
+ scopeSet = set.NewFromSlice(options.Scope)
+ }
+
+ refresh := func(f func()) {
+ wg.Add(1)
+ func() {
+ if options.Mode == types.ASYNC {
+ go utils.Safe(f)
+ } else {
+ f()
+ }
+ wg.Done()
+ }()
+ }
+
+ if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) || scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
+ refresh(gui.refreshCommits)
+ } else if scopeSet.Includes(types.REBASE_COMMITS) {
+ // the above block handles rebase commits so we only need to call this one
+ // if we've asked specifically for rebase commits and not those other things
+ refresh(func() { _ = gui.refreshRebaseCommits() })
+ }
+
+ // reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway
+ if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) {
+ refresh(func() { _ = gui.refreshCommitFilesContext() })
+ }
+
+ if scopeSet.Includes(types.FILES) || scopeSet.Includes(types.SUBMODULES) {
+ refresh(func() { _ = gui.refreshFilesAndSubmodules() })
+ }
+
+ if scopeSet.Includes(types.STASH) {
+ refresh(func() { _ = gui.refreshStashEntries() })
+ }
+
+ if scopeSet.Includes(types.TAGS) {
+ refresh(func() { _ = gui.refreshTags() })
+ }
+
+ if scopeSet.Includes(types.REMOTES) {
+ refresh(func() { _ = gui.refreshRemotes() })
+ }
+
+ if scopeSet.Includes(types.STAGING) {
+ refresh(func() { _ = gui.refreshStagingPanel(types.OnFocusOpts{}) })
+ }
+
+ if scopeSet.Includes(types.PATCH_BUILDING) {
+ refresh(func() { _ = gui.refreshPatchBuildingPanel(types.OnFocusOpts{}) })
+ }
+
+ if scopeSet.Includes(types.MERGE_CONFLICTS) || scopeSet.Includes(types.FILES) {
+ refresh(func() { _ = gui.refreshMergeState() })
+ }
+
+ wg.Wait()
+
+ gui.refreshStatus()
+
+ if options.Then != nil {
+ options.Then()
+ }
+ }
+
+ if options.Mode == types.BLOCK_UI {
+ gui.c.OnUIThread(func() error {
+ f()
+ return nil
+ })
+ } else {
+ f()
+ }
+
+ return nil
+}
+
+// during startup, the bottleneck is fetching the reflog entries. We need these
+// on startup to sort the branches by recency. So we have two phases: INITIAL, and COMPLETE.
+// In the initial phase we don't get any reflog commits, but we asynchronously get them
+// and refresh the branches after that
+func (gui *Gui) refreshReflogCommitsConsideringStartup() {
+ switch gui.State.StartupStage {
+ case INITIAL:
+ go utils.Safe(func() {
+ _ = gui.refreshReflogCommits()
+ gui.refreshBranches()
+ gui.State.StartupStage = COMPLETE
+ })
+
+ case COMPLETE:
+ _ = gui.refreshReflogCommits()
+ }
+}
+
+// whenever we change commits, we should update branches because the upstream/downstream
+// counts can change. Whenever we change branches we should probably also change commits
+// e.g. in the case of switching branches.
+func (gui *Gui) refreshCommits() {
+ wg := sync.WaitGroup{}
+ wg.Add(2)
+
+ go utils.Safe(func() {
+ gui.refreshReflogCommitsConsideringStartup()
+
+ gui.refreshBranches()
+ wg.Done()
+ })
+
+ go utils.Safe(func() {
+ _ = gui.refreshCommitsWithLimit()
+ ctx, ok := gui.State.Contexts.CommitFiles.GetParentContext()
+ if ok && ctx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY {
+ // This makes sense when we've e.g. just amended a commit, meaning we get a new commit SHA at the same position.
+ // However if we've just added a brand new commit, it pushes the list down by one and so we would end up
+ // showing the contents of a different commit than the one we initially entered.
+ // Ideally we would know when to refresh the commit files context and when not to,
+ // or perhaps we could just pop that context off the stack whenever cycling windows.
+ // For now the awkwardness remains.
+ commit := gui.getSelectedLocalCommit()
+ if commit != nil {
+ gui.State.Contexts.CommitFiles.SetRef(commit)
+ gui.State.Contexts.CommitFiles.SetTitleRef(commit.RefName())
+ _ = gui.refreshCommitFilesContext()
+ }
+ }
+ wg.Done()
+ })
+
+ wg.Wait()
+}
+
+func (gui *Gui) refreshCommitsWithLimit() error {
+ gui.Mutexes.LocalCommitsMutex.Lock()
+ defer gui.Mutexes.LocalCommitsMutex.Unlock()
+
+ commits, err := gui.git.Loaders.Commits.GetCommits(
+ loaders.GetCommitsOptions{
+ Limit: gui.State.Contexts.LocalCommits.GetLimitCommits(),
+ FilterPath: gui.State.Modes.Filtering.GetPath(),
+ IncludeRebaseCommits: true,
+ RefName: gui.refForLog(),
+ All: gui.State.Contexts.LocalCommits.GetShowWholeGitGraph(),
+ },
+ )
+ if err != nil {
+ return err
+ }
+ gui.State.Model.Commits = commits
+
+ return gui.c.PostRefreshUpdate(gui.State.Contexts.LocalCommits)
+}
+
+func (gui *Gui) refreshCommitFilesContext() error {
+ ref := gui.State.Contexts.CommitFiles.GetRef()
+ to := ref.RefName()
+ from, reverse := gui.State.Modes.Diffing.GetFromAndReverseArgsForDiff(ref.ParentRefName())
+
+ files, err := gui.git.Loaders.CommitFiles.GetFilesInDiff(from, to, reverse)
+ if err != nil {
+ return gui.c.Error(err)
+ }
+ gui.State.Model.CommitFiles = files
+ gui.State.Contexts.CommitFiles.CommitFileTreeViewModel.SetTree()
+
+ return gui.c.PostRefreshUpdate(gui.State.Contexts.CommitFiles)
+}
+
+func (gui *Gui) refreshRebaseCommits() error {
+ gui.Mutexes.LocalCommitsMutex.Lock()
+ defer gui.Mutexes.LocalCommitsMutex.Unlock()
+
+ updatedCommits, err := gui.git.Loaders.Commits.MergeRebasingCommits(gui.State.Model.Commits)
+ if err != nil {
+ return err
+ }
+ gui.State.Model.Commits = updatedCommits
+
+ return gui.c.PostRefreshUpdate(gui.State.Contexts.LocalCommits)
+}
+
+func (self *Gui) refreshTags() error {
+ tags, err := self.git.Loaders.Tags.GetTags()
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ self.State.Model.Tags = tags
+
+ return self.postRefreshUpdate(self.State.Contexts.Tags)
+}
+
+func (gui *Gui) refreshStateSubmoduleConfigs() error {
+ configs, err := gui.git.Submodule.GetConfigs()
+ if err != nil {
+ return err
+ }
+
+ gui.State.Model.Submodules = configs
+
+ return nil
+}
+
+// gui.refreshStatus is called at the end of this because that's when we can
+// be sure there is a State.Model.Branches array to pick the current branch from
+func (gui *Gui) refreshBranches() {
+ reflogCommits := gui.State.Model.FilteredReflogCommits
+ if gui.State.Modes.Filtering.Active() {
+ // in filter mode we filter our reflog commits to just those containing the path
+ // however we need all the reflog entries to populate the recencies of our branches
+ // which allows us to order them correctly. So if we're filtering we'll just
+ // manually load all the reflog commits here
+ var err error
+ reflogCommits, _, err = gui.git.Loaders.ReflogCommits.GetReflogCommits(nil, "")
+ if err != nil {
+ gui.c.Log.Error(err)
+ }
+ }
+
+ branches, err := gui.git.Loaders.Branches.Load(reflogCommits)
+ if err != nil {
+ _ = gui.c.Error(err)
+ }
+
+ gui.State.Model.Branches = branches
+
+ if err := gui.c.PostRefreshUpdate(gui.State.Contexts.Branches); err != nil {
+ gui.c.Log.Error(err)
+ }
+
+ gui.refreshStatus()
+}
+
+func (gui *Gui) refreshFilesAndSubmodules() error {
+ gui.Mutexes.RefreshingFilesMutex.Lock()
+ gui.State.IsRefreshingFiles = true
+ defer func() {
+ gui.State.IsRefreshingFiles = false
+ gui.Mutexes.RefreshingFilesMutex.Unlock()
+ }()
+
+ if err := gui.refreshStateSubmoduleConfigs(); err != nil {
+ return err
+ }
+
+ if err := gui.refreshStateFiles(); err != nil {
+ return err
+ }
+
+ gui.c.OnUIThread(func() error {
+ if err := gui.c.PostRefreshUpdate(gui.State.Contexts.Submodules); err != nil {
+ gui.c.Log.Error(err)
+ }
+
+ if err := gui.c.PostRefreshUpdate(gui.State.Contexts.Files); err != nil {
+ gui.c.Log.Error(err)
+ }
+
+ return nil
+ })
+
+ return nil
+}
+
+func (gui *Gui) refreshMergeState() error {
+ gui.State.Contexts.MergeConflicts.GetMutex().Lock()
+ defer gui.State.Contexts.MergeConflicts.GetMutex().Unlock()
+
+ if gui.currentContext().GetKey() != context.MERGE_CONFLICTS_CONTEXT_KEY {
+ return nil
+ }
+
+ hasConflicts, err := gui.helpers.MergeConflicts.SetConflictsAndRender(gui.State.Contexts.MergeConflicts.GetState().GetPath(), true)
+ if err != nil {
+ return gui.c.Error(err)
+ }
+
+ if !hasConflicts {
+ return gui.helpers.MergeConflicts.EscapeMerge()
+ }
+
+ return nil
+}
+
+func (gui *Gui) refreshStateFiles() error {
+ state := gui.State
+
+ fileTreeViewModel := state.Contexts.Files.FileTreeViewModel
+
+ // If git thinks any of our files have inline merge conflicts, but they actually don't,
+ // we stage them.
+ // Note that if files with merge conflicts have both arisen and have been resolved
+ // between refreshes, we won't stage them here. This is super unlikely though,
+ // and this approach spares us from having to call `git status` twice in a row.
+ // Although this also means that at startup we won't be staging anything until
+ // we call git status again.
+ pathsToStage := []string{}
+ prevConflictFileCount := 0
+ for _, file := range gui.State.Model.Files {
+ if file.HasMergeConflicts {
+ prevConflictFileCount++
+ }
+ if file.HasInlineMergeConflicts {
+ hasConflicts, err := mergeconflicts.FileHasConflictMarkers(file.Name)
+ if err != nil {
+ gui.Log.Error(err)
+ } else if !hasConflicts {
+ pathsToStage = append(pathsToStage, file.Name)
+ }
+ }
+ }
+
+ if len(pathsToStage) > 0 {
+ gui.c.LogAction(gui.Tr.Actions.StageResolvedFiles)
+ if err := gui.git.WorkingTree.StageFiles(pathsToStage); err != nil {
+ return gui.c.Error(err)
+ }
+ }
+
+ files := gui.git.Loaders.Files.
+ GetStatusFiles(loaders.GetStatusFileOptions{})
+
+ conflictFileCount := 0
+ for _, file := range files {
+ if file.HasMergeConflicts {
+ conflictFileCount++
+ }
+ }
+
+ if gui.git.Status.WorkingTreeState() != enums.REBASE_MODE_NONE && conflictFileCount == 0 && prevConflictFileCount > 0 {
+ gui.c.OnUIThread(func() error { return gui.helpers.MergeAndRebase.PromptToContinueRebase() })
+ }
+
+ fileTreeViewModel.RWMutex.Lock()
+
+ // only taking over the filter if it hasn't already been set by the user.
+ // Though this does make it impossible for the user to actually say they want to display all if
+ // conflicts are currently being shown. Hmm. Worth it I reckon. If we need to add some
+ // extra state here to see if the user's set the filter themselves we can do that, but
+ // I'd prefer to maintain as little state as possible.
+ if conflictFileCount > 0 {
+ if fileTreeViewModel.GetFilter() == filetree.DisplayAll {
+ fileTreeViewModel.SetFilter(filetree.DisplayConflicted)
+ }
+ } else if fileTreeViewModel.GetFilter() == filetree.DisplayConflicted {
+ fileTreeViewModel.SetFilter(filetree.DisplayAll)
+ }
+
+ state.Model.Files = files
+ fileTreeViewModel.SetTree()
+ fileTreeViewModel.RWMutex.Unlock()
+
+ if err := gui.fileWatcher.addFilesToFileWatcher(files); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// the reflogs panel is the only panel where we cache data, in that we only
+// load entries that have been created since we last ran the call. This means
+// we need to be more careful with how we use this, and to ensure we're emptying
+// the reflogs array when changing contexts.
+// This method also manages two things: ReflogCommits and FilteredReflogCommits.
+// FilteredReflogCommits are rendered in the reflogs panel, and ReflogCommits
+// are used by the branches panel to obtain recency values for sorting.
+func (gui *Gui) refreshReflogCommits() error {
+ // pulling state into its own variable incase it gets swapped out for another state
+ // and we get an out of bounds exception
+ state := gui.State
+ var lastReflogCommit *models.Commit
+ if len(state.Model.ReflogCommits) > 0 {
+ lastReflogCommit = state.Model.ReflogCommits[0]
+ }
+
+ refresh := func(stateCommits *[]*models.Commit, filterPath string) error {
+ commits, onlyObtainedNewReflogCommits, err := gui.git.Loaders.ReflogCommits.
+ GetReflogCommits(lastReflogCommit, filterPath)
+ if err != nil {
+ return gui.c.Error(err)
+ }
+
+ if onlyObtainedNewReflogCommits {
+ *stateCommits = append(commits, *stateCommits...)
+ } else {
+ *stateCommits = commits
+ }
+ return nil
+ }
+
+ if err := refresh(&state.Model.ReflogCommits, ""); err != nil {
+ return err
+ }
+
+ if gui.State.Modes.Filtering.Active() {
+ if err := refresh(&state.Model.FilteredReflogCommits, state.Modes.Filtering.GetPath()); err != nil {
+ return err
+ }
+ } else {
+ state.Model.FilteredReflogCommits = state.Model.ReflogCommits
+ }
+
+ return gui.c.PostRefreshUpdate(gui.State.Contexts.ReflogCommits)
+}
+
+func (gui *Gui) refreshRemotes() error {
+ prevSelectedRemote := gui.State.Contexts.Remotes.GetSelected()
+
+ remotes, err := gui.git.Loaders.Remotes.GetRemotes()
+ if err != nil {
+ return gui.c.Error(err)
+ }
+
+ gui.State.Model.Remotes = remotes
+
+ // we need to ensure our selected remote branches aren't now outdated
+ if prevSelectedRemote != nil && gui.State.Model.RemoteBranches != nil {
+ // find remote now
+ for _, remote := range remotes {
+ if remote.Name == prevSelectedRemote.Name {
+ gui.State.Model.RemoteBranches = remote.Branches
+ break
+ }
+ }
+ }
+
+ if err := gui.c.PostRefreshUpdate(gui.State.Contexts.Remotes); err != nil {
+ return err
+ }
+
+ if err := gui.c.PostRefreshUpdate(gui.State.Contexts.RemoteBranches); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (gui *Gui) refreshStashEntries() error {
+ gui.State.Model.StashEntries = gui.git.Loaders.Stash.
+ GetStashEntries(gui.State.Modes.Filtering.GetPath())
+
+ return gui.postRefreshUpdate(gui.State.Contexts.Stash)
+}
+
+// never call this on its own, it should only be called from within refreshCommits()
+func (gui *Gui) refreshStatus() {
+ gui.Mutexes.RefreshingStatusMutex.Lock()
+ defer gui.Mutexes.RefreshingStatusMutex.Unlock()
+
+ currentBranch := gui.helpers.Refs.GetCheckedOutRef()
+ if currentBranch == nil {
+ // need to wait for branches to refresh
+ return
+ }
+ status := ""
+
+ if currentBranch.IsRealBranch() {
+ status += presentation.ColoredBranchStatus(currentBranch, gui.Tr) + " "
+ }
+
+ workingTreeState := gui.git.Status.WorkingTreeState()
+ if workingTreeState != enums.REBASE_MODE_NONE {
+ status += style.FgYellow.Sprintf("(%s) ", formatWorkingTreeState(workingTreeState))
+ }
+
+ name := presentation.GetBranchTextStyle(currentBranch.Name).Sprint(currentBranch.Name)
+ repoName := utils.GetCurrentRepoName()
+ status += fmt.Sprintf("%s → %s ", repoName, name)
+
+ gui.setViewContent(gui.Views.Status, status)
+}
+
+func (gui *Gui) refreshStagingPanel(focusOpts types.OnFocusOpts) error {
+ secondaryFocused := gui.secondaryStagingFocused()
+
+ mainSelectedLineIdx := -1
+ secondarySelectedLineIdx := -1
+ if focusOpts.ClickedViewLineIdx > 0 {
+ if secondaryFocused {
+ secondarySelectedLineIdx = focusOpts.ClickedViewLineIdx
+ } else {
+ mainSelectedLineIdx = focusOpts.ClickedViewLineIdx
+ }
+ }
+
+ mainContext := gui.State.Contexts.Staging
+ secondaryContext := gui.State.Contexts.StagingSecondary
+
+ file := gui.getSelectedFile()
+ if file == nil || (!file.HasUnstagedChanges && !file.HasStagedChanges) {
+ return gui.handleStagingEscape()
+ }
+
+ mainDiff := gui.git.WorkingTree.WorktreeFileDiff(file, true, false, false)
+ secondaryDiff := gui.git.WorkingTree.WorktreeFileDiff(file, true, true, false)
+
+ // 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()
+
+ mainContext.SetState(
+ patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainContext.GetState(), gui.Log),
+ )
+
+ secondaryContext.SetState(
+ patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondaryContext.GetState(), gui.Log),
+ )
+
+ mainState := mainContext.GetState()
+ secondaryState := secondaryContext.GetState()
+
+ mainContent := mainContext.GetContentToRender(!secondaryFocused)
+ secondaryContent := secondaryContext.GetContentToRender(secondaryFocused)
+
+ mainContext.GetMutex().Unlock()
+ secondaryContext.GetMutex().Unlock()
+
+ if mainState == nil && secondaryState == nil {
+ return gui.handleStagingEscape()
+ }
+
+ if mainState == nil && !secondaryFocused {
+ return gui.c.PushContext(secondaryContext, focusOpts)
+ }
+
+ if secondaryState == nil && secondaryFocused {
+ return gui.c.PushContext(mainContext, focusOpts)
+ }
+
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Staging,
+ Main: &types.ViewUpdateOpts{
+ Task: types.NewRenderStringWithoutScrollTask(mainContent),
+ Title: gui.Tr.UnstagedChanges,
+ },
+ Secondary: &types.ViewUpdateOpts{
+ Task: types.NewRenderStringWithoutScrollTask(secondaryContent),
+ Title: gui.Tr.StagedChanges,
+ },
+ })
+}
+
+func (gui *Gui) handleStagingEscape() error {
+ return gui.c.PushContext(gui.State.Contexts.Files)
+}
+
+func (gui *Gui) secondaryStagingFocused() bool {
+ return gui.currentStaticContext().GetKey() == gui.State.Contexts.StagingSecondary.GetKey()
+}
+
+func (gui *Gui) refreshPatchBuildingPanel(opts types.OnFocusOpts) error {
+ selectedLineIdx := -1
+ if opts.ClickedWindowName == "main" {
+ selectedLineIdx = opts.ClickedViewLineIdx
+ }
+
+ if !gui.git.Patch.PatchManager.Active() {
+ return gui.helpers.PatchBuilding.Escape()
+ }
+
+ // get diff from commit file that's currently selected
+ path := gui.State.Contexts.CommitFiles.GetSelectedPath()
+ if path == "" {
+ return nil
+ }
+
+ ref := gui.State.Contexts.CommitFiles.CommitFileTreeViewModel.GetRef()
+ to := ref.RefName()
+ from, reverse := gui.State.Modes.Diffing.GetFromAndReverseArgsForDiff(ref.ParentRefName())
+ diff, err := gui.git.WorkingTree.ShowFileDiff(from, to, reverse, path, true)
+ if err != nil {
+ return err
+ }
+
+ secondaryDiff := gui.git.Patch.PatchManager.RenderPatchForFile(path, false, false, true)
+ if err != nil {
+ return err
+ }
+
+ context := gui.State.Contexts.CustomPatchBuilder
+
+ oldState := context.GetState()
+
+ state := patch_exploring.NewState(diff, selectedLineIdx, oldState, gui.Log)
+ context.SetState(state)
+ if state == nil {
+ return gui.helpers.PatchBuilding.Escape()
+ }
+
+ mainContent := context.GetContentToRender(true)
+
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().PatchBuilding,
+ Main: &types.ViewUpdateOpts{
+ Task: types.NewRenderStringWithoutScrollTask(mainContent),
+ Title: gui.Tr.Patch,
+ },
+ Secondary: &types.ViewUpdateOpts{
+ Task: types.NewRenderStringWithoutScrollTask(secondaryDiff),
+ Title: gui.Tr.CustomPatch,
+ },
+ })
+}
+
+func (gui *Gui) refreshMergePanel(isFocused bool) error {
+ content := gui.State.Contexts.MergeConflicts.GetContentToRender(isFocused)
+
+ var task types.UpdateTask
+ if gui.State.Contexts.MergeConflicts.IsUserScrolling() {
+ task = types.NewRenderStringWithoutScrollTask(content)
+ } else {
+ originY := gui.State.Contexts.MergeConflicts.GetOriginY()
+ task = types.NewRenderStringWithScrollTask(content, 0, originY)
+ }
+
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().MergeConflicts,
+ Main: &types.ViewUpdateOpts{
+ Task: task,
+ },
+ })
+}
diff --git a/pkg/gui/remote_branches_panel.go b/pkg/gui/remote_branches_panel.go
index 29ab59187..6e9a8e779 100644
--- a/pkg/gui/remote_branches_panel.go
+++ b/pkg/gui/remote_branches_panel.go
@@ -1,108 +1,22 @@
package gui
-import (
- "fmt"
-
- "github.com/jesseduffield/lazygit/pkg/commands/models"
- "github.com/jesseduffield/lazygit/pkg/utils"
-)
-
-// list panel functions
-
-func (gui *Gui) getSelectedRemoteBranch() *models.RemoteBranch {
- selectedLine := gui.State.Panels.RemoteBranches.SelectedLineIdx
- if selectedLine == -1 || len(gui.State.RemoteBranches) == 0 {
- return nil
- }
-
- return gui.State.RemoteBranches[selectedLine]
-}
+import "github.com/jesseduffield/lazygit/pkg/gui/types"
func (gui *Gui) remoteBranchesRenderToMain() error {
- var task updateTask
- remoteBranch := gui.getSelectedRemoteBranch()
+ var task types.UpdateTask
+ remoteBranch := gui.State.Contexts.RemoteBranches.GetSelected()
if remoteBranch == nil {
- task = NewRenderStringTask("No branches for this remote")
+ task = types.NewRenderStringTask("No branches for this remote")
} else {
- cmdObj := gui.Git.Branch.GetGraphCmdObj(remoteBranch.FullName())
- task = NewRunCommandTask(cmdObj.GetCmd())
+ cmdObj := gui.git.Branch.GetGraphCmdObj(remoteBranch.FullRefName())
+ task = types.NewRunCommandTask(cmdObj.GetCmd())
}
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "Remote Branch",
- task: task,
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: "Remote Branch",
+ Task: task,
},
})
}
-
-func (gui *Gui) handleRemoteBranchesEscape() error {
- return gui.pushContext(gui.State.Contexts.Remotes)
-}
-
-func (gui *Gui) handleMergeRemoteBranch() error {
- selectedBranchName := gui.getSelectedRemoteBranch().FullName()
- return gui.mergeBranchIntoCheckedOutBranch(selectedBranchName)
-}
-
-func (gui *Gui) handleDeleteRemoteBranch() error {
- remoteBranch := gui.getSelectedRemoteBranch()
- if remoteBranch == nil {
- return nil
- }
- message := fmt.Sprintf("%s '%s'?", gui.Tr.DeleteRemoteBranchMessage, remoteBranch.FullName())
-
- return gui.ask(askOpts{
- title: gui.Tr.DeleteRemoteBranch,
- prompt: message,
- handleConfirm: func() error {
- return gui.WithWaitingStatus(gui.Tr.DeletingStatus, func() error {
- gui.logAction(gui.Tr.Actions.DeleteRemoteBranch)
- err := gui.Git.Remote.DeleteRemoteBranch(remoteBranch.RemoteName, remoteBranch.Name)
- gui.handleCredentialsPopup(err)
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{BRANCHES, REMOTES}})
- })
- },
- })
-}
-
-func (gui *Gui) handleRebaseOntoRemoteBranch() error {
- selectedBranchName := gui.getSelectedRemoteBranch().FullName()
- return gui.handleRebaseOntoBranch(selectedBranchName)
-}
-
-func (gui *Gui) handleSetBranchUpstream() error {
- selectedBranch := gui.getSelectedRemoteBranch()
- checkedOutBranch := gui.getCheckedOutBranch()
-
- message := utils.ResolvePlaceholderString(
- gui.Tr.SetUpstreamMessage,
- map[string]string{
- "checkedOut": checkedOutBranch.Name,
- "selected": selectedBranch.FullName(),
- },
- )
-
- return gui.ask(askOpts{
- title: gui.Tr.SetUpstreamTitle,
- prompt: message,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.SetBranchUpstream)
- if err := gui.Git.Branch.SetUpstream(selectedBranch.RemoteName, selectedBranch.Name, checkedOutBranch.Name); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{BRANCHES, REMOTES}})
- },
- })
-}
-
-func (gui *Gui) handleCreateResetToRemoteBranchMenu() error {
- selectedBranch := gui.getSelectedRemoteBranch()
- if selectedBranch == nil {
- return nil
- }
-
- return gui.createResetMenu(selectedBranch.FullName())
-}
diff --git a/pkg/gui/remotes_panel.go b/pkg/gui/remotes_panel.go
index c74653a27..edaade8a8 100644
--- a/pkg/gui/remotes_panel.go
+++ b/pkg/gui/remotes_panel.go
@@ -4,184 +4,26 @@ import (
"fmt"
"strings"
- "github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/style"
- "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
// list panel functions
-func (gui *Gui) getSelectedRemote() *models.Remote {
- selectedLine := gui.State.Panels.Remotes.SelectedLineIdx
- if selectedLine == -1 || len(gui.State.Remotes) == 0 {
- return nil
- }
-
- return gui.State.Remotes[selectedLine]
-}
-
func (gui *Gui) remotesRenderToMain() error {
- var task updateTask
- remote := gui.getSelectedRemote()
+ var task types.UpdateTask
+ remote := gui.State.Contexts.Remotes.GetSelected()
if remote == nil {
- task = NewRenderStringTask("No remotes")
+ task = types.NewRenderStringTask("No remotes")
} else {
- task = NewRenderStringTask(fmt.Sprintf("%s\nUrls:\n%s", style.FgGreen.Sprint(remote.Name), strings.Join(remote.Urls, "\n")))
+ task = types.NewRenderStringTask(fmt.Sprintf("%s\nUrls:\n%s", style.FgGreen.Sprint(remote.Name), strings.Join(remote.Urls, "\n")))
}
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "Remote",
- task: task,
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: "Remote",
+ Task: task,
},
})
}
-
-func (gui *Gui) refreshRemotes() error {
- prevSelectedRemote := gui.getSelectedRemote()
-
- remotes, err := gui.Git.Loaders.Remotes.GetRemotes()
- if err != nil {
- return gui.surfaceError(err)
- }
-
- gui.State.Remotes = remotes
-
- // we need to ensure our selected remote branches aren't now outdated
- if prevSelectedRemote != nil && gui.State.RemoteBranches != nil {
- // find remote now
- for _, remote := range remotes {
- if remote.Name == prevSelectedRemote.Name {
- gui.State.RemoteBranches = remote.Branches
- }
- }
- }
-
- return gui.postRefreshUpdate(gui.mustContextForContextKey(ContextKey(gui.Views.Branches.Context)))
-}
-
-func (gui *Gui) handleRemoteEnter() error {
- // naive implementation: get the branches and render them to the list, change the context
- remote := gui.getSelectedRemote()
- if remote == nil {
- return nil
- }
-
- gui.State.RemoteBranches = remote.Branches
-
- newSelectedLine := 0
- if len(remote.Branches) == 0 {
- newSelectedLine = -1
- }
- gui.State.Panels.RemoteBranches.SelectedLineIdx = newSelectedLine
-
- return gui.pushContext(gui.State.Contexts.RemoteBranches)
-}
-
-func (gui *Gui) handleAddRemote() error {
- return gui.prompt(promptOpts{
- title: gui.Tr.LcNewRemoteName,
- handleConfirm: func(remoteName string) error {
- return gui.prompt(promptOpts{
- title: gui.Tr.LcNewRemoteUrl,
- handleConfirm: func(remoteUrl string) error {
- gui.logAction(gui.Tr.Actions.AddRemote)
- if err := gui.Git.Remote.AddRemote(remoteName, remoteUrl); err != nil {
- return err
- }
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{REMOTES}})
- },
- })
- },
- })
-
-}
-
-func (gui *Gui) handleRemoveRemote() error {
- remote := gui.getSelectedRemote()
- if remote == nil {
- return nil
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.LcRemoveRemote,
- prompt: gui.Tr.LcRemoveRemotePrompt + " '" + remote.Name + "'?",
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.RemoveRemote)
- if err := gui.Git.Remote.RemoveRemote(remote.Name); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{BRANCHES, REMOTES}})
- },
- })
-}
-
-func (gui *Gui) handleEditRemote() error {
- remote := gui.getSelectedRemote()
- if remote == nil {
- return nil
- }
-
- editNameMessage := utils.ResolvePlaceholderString(
- gui.Tr.LcEditRemoteName,
- map[string]string{
- "remoteName": remote.Name,
- },
- )
-
- return gui.prompt(promptOpts{
- title: editNameMessage,
- initialContent: remote.Name,
- handleConfirm: func(updatedRemoteName string) error {
- if updatedRemoteName != remote.Name {
- gui.logAction(gui.Tr.Actions.UpdateRemote)
- if err := gui.Git.Remote.RenameRemote(remote.Name, updatedRemoteName); err != nil {
- return gui.surfaceError(err)
- }
- }
-
- editUrlMessage := utils.ResolvePlaceholderString(
- gui.Tr.LcEditRemoteUrl,
- map[string]string{
- "remoteName": updatedRemoteName,
- },
- )
-
- urls := remote.Urls
- url := ""
- if len(urls) > 0 {
- url = urls[0]
- }
-
- return gui.prompt(promptOpts{
- title: editUrlMessage,
- initialContent: url,
- handleConfirm: func(updatedRemoteUrl string) error {
- gui.logAction(gui.Tr.Actions.UpdateRemote)
- if err := gui.Git.Remote.UpdateRemoteUrl(updatedRemoteName, updatedRemoteUrl); err != nil {
- return gui.surfaceError(err)
- }
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{BRANCHES, REMOTES}})
- },
- })
- },
- })
-}
-
-func (gui *Gui) handleFetchRemote() error {
- remote := gui.getSelectedRemote()
- if remote == nil {
- return nil
- }
-
- return gui.WithWaitingStatus(gui.Tr.FetchingRemoteStatus, func() error {
- gui.Mutexes.FetchMutex.Lock()
- defer gui.Mutexes.FetchMutex.Unlock()
-
- err := gui.Git.Sync.FetchRemote(remote.Name)
- gui.handleCredentialsPopup(err)
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{BRANCHES, REMOTES}})
- })
-}
diff --git a/pkg/gui/reset_menu_panel.go b/pkg/gui/reset_menu_panel.go
deleted file mode 100644
index 586987778..000000000
--- a/pkg/gui/reset_menu_panel.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package gui
-
-import (
- "fmt"
-
- "github.com/jesseduffield/lazygit/pkg/gui/style"
-)
-
-func (gui *Gui) resetToRef(ref string, strength string, envVars []string) error {
- if err := gui.Git.Commit.ResetToCommit(ref, strength, envVars); err != nil {
- return gui.surfaceError(err)
- }
-
- gui.State.Panels.Commits.SelectedLineIdx = 0
- gui.State.Panels.ReflogCommits.SelectedLineIdx = 0
- // loading a heap of commits is slow so we limit them whenever doing a reset
- gui.State.Panels.Commits.LimitCommits = true
-
- if err := gui.pushContext(gui.State.Contexts.BranchCommits); err != nil {
- return err
- }
-
- if err := gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES, BRANCHES, REFLOG, COMMITS}}); err != nil {
- return err
- }
-
- return nil
-}
-
-func (gui *Gui) createResetMenu(ref string) error {
- strengths := []string{"soft", "mixed", "hard"}
- menuItems := make([]*menuItem, len(strengths))
- for i, strength := range strengths {
- strength := strength
- menuItems[i] = &menuItem{
- displayStrings: []string{
- fmt.Sprintf("%s reset", strength),
- style.FgRed.Sprintf("reset --%s %s", strength, ref),
- },
- onPress: func() error {
- gui.logAction("Reset")
- return gui.resetToRef(ref, strength, []string{})
- },
- }
- }
-
- return gui.createMenu(fmt.Sprintf("%s %s", gui.Tr.LcResetTo, ref), menuItems, createMenuOptions{showCancel: true})
-}
diff --git a/pkg/gui/searching.go b/pkg/gui/searching.go
index dd7697363..a8580655c 100644
--- a/pkg/gui/searching.go
+++ b/pkg/gui/searching.go
@@ -3,6 +3,7 @@ package gui
import (
"fmt"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/theme"
)
@@ -17,7 +18,7 @@ func (gui *Gui) handleOpenSearch(viewName string) error {
gui.Views.Search.ClearTextArea()
- if err := gui.pushContext(gui.State.Contexts.Search); err != nil {
+ if err := gui.c.PushContext(gui.State.Contexts.Search); err != nil {
return err
}
@@ -26,7 +27,7 @@ func (gui *Gui) handleOpenSearch(viewName string) error {
func (gui *Gui) handleSearch() error {
gui.State.Searching.searchString = gui.Views.Search.TextArea.GetContent()
- if err := gui.returnFromContext(); err != nil {
+ if err := gui.c.PopContext(); err != nil {
return err
}
@@ -43,7 +44,7 @@ func (gui *Gui) handleSearch() error {
}
func (gui *Gui) onSelectItemWrapper(innerFunc func(int) error) func(int, int, int) error {
- keybindingConfig := gui.UserConfig.Keybinding
+ keybindingConfig := gui.c.UserConfig.Keybinding
return func(y int, index int, total int) error {
if total == 0 {
@@ -52,7 +53,7 @@ func (gui *Gui) onSelectItemWrapper(innerFunc func(int) error) func(int, int, in
fmt.Sprintf(
"no matches for '%s' %s",
gui.State.Searching.searchString,
- theme.OptionsFgColor.Sprintf("%s: exit search mode", gui.getKeyDisplay(keybindingConfig.Universal.Return)),
+ theme.OptionsFgColor.Sprintf("%s: exit search mode", keybindings.Label(keybindingConfig.Universal.Return)),
),
)
}
@@ -65,9 +66,9 @@ func (gui *Gui) onSelectItemWrapper(innerFunc func(int) error) func(int, int, in
total,
theme.OptionsFgColor.Sprintf(
"%s: next match, %s: previous match, %s: exit search mode",
- gui.getKeyDisplay(keybindingConfig.Universal.NextMatch),
- gui.getKeyDisplay(keybindingConfig.Universal.PrevMatch),
- gui.getKeyDisplay(keybindingConfig.Universal.Return),
+ keybindings.Label(keybindingConfig.Universal.NextMatch),
+ keybindings.Label(keybindingConfig.Universal.PrevMatch),
+ keybindings.Label(keybindingConfig.Universal.Return),
),
),
)
@@ -93,7 +94,7 @@ func (gui *Gui) handleSearchEscape() error {
return err
}
- if err := gui.returnFromContext(); err != nil {
+ if err := gui.c.PopContext(); err != nil {
return err
}
diff --git a/pkg/gui/services/custom_commands/client.go b/pkg/gui/services/custom_commands/client.go
new file mode 100644
index 000000000..aeaae084e
--- /dev/null
+++ b/pkg/gui/services/custom_commands/client.go
@@ -0,0 +1,51 @@
+package custom_commands
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// Client is the entry point to this package. It returns a list of keybindings based on the config's user-defined custom commands.
+// See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Command_Keybindings.md for more info.
+type Client struct {
+ customCommands []config.CustomCommand
+ handlerCreator *HandlerCreator
+ keybindingCreator *KeybindingCreator
+}
+
+func NewClient(
+ c *types.HelperCommon,
+ os *oscommands.OSCommand,
+ git *commands.GitCommand,
+ contexts *context.ContextTree,
+ helpers *helpers.Helpers,
+) *Client {
+ sessionStateLoader := NewSessionStateLoader(contexts, helpers)
+ handlerCreator := NewHandlerCreator(c, os, git, sessionStateLoader)
+ keybindingCreator := NewKeybindingCreator(contexts)
+ customCommands := c.UserConfig.CustomCommands
+
+ return &Client{
+ customCommands: customCommands,
+ keybindingCreator: keybindingCreator,
+ handlerCreator: handlerCreator,
+ }
+}
+
+func (self *Client) GetCustomCommandKeybindings() ([]*types.Binding, error) {
+ bindings := []*types.Binding{}
+ for _, customCommand := range self.customCommands {
+ handler := self.handlerCreator.call(customCommand)
+ binding, err := self.keybindingCreator.call(customCommand, handler)
+ if err != nil {
+ return nil, err
+ }
+ bindings = append(bindings, binding)
+ }
+
+ return bindings, nil
+}
diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go
new file mode 100644
index 000000000..6ac9fb733
--- /dev/null
+++ b/pkg/gui/services/custom_commands/handler_creator.go
@@ -0,0 +1,208 @@
+package custom_commands
+
+import (
+ "strings"
+
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+// takes a custom command and returns a function that will be called when the corresponding user-defined keybinding is pressed
+type HandlerCreator struct {
+ c *types.HelperCommon
+ os *oscommands.OSCommand
+ git *commands.GitCommand
+ sessionStateLoader *SessionStateLoader
+ resolver *Resolver
+ menuGenerator *MenuGenerator
+}
+
+func NewHandlerCreator(
+ c *types.HelperCommon,
+ os *oscommands.OSCommand,
+ git *commands.GitCommand,
+ sessionStateLoader *SessionStateLoader,
+) *HandlerCreator {
+ resolver := NewResolver(c.Common)
+ menuGenerator := NewMenuGenerator(c.Common)
+
+ return &HandlerCreator{
+ c: c,
+ os: os,
+ git: git,
+ sessionStateLoader: sessionStateLoader,
+ resolver: resolver,
+ menuGenerator: menuGenerator,
+ }
+}
+
+func (self *HandlerCreator) call(customCommand config.CustomCommand) func() error {
+ return func() error {
+ sessionState := self.sessionStateLoader.call()
+ promptResponses := make([]string, len(customCommand.Prompts))
+
+ f := func() error { return self.finalHandler(customCommand, sessionState, promptResponses) }
+
+ // if we have prompts we'll recursively wrap our confirm handlers with more prompts
+ // until we reach the actual command
+ for reverseIdx := range customCommand.Prompts {
+ // reassigning so that we don't end up with an infinite recursion
+ g := f
+ idx := len(customCommand.Prompts) - 1 - reverseIdx
+
+ // going backwards so the outermost prompt is the first one
+ prompt := customCommand.Prompts[idx]
+
+ wrappedF := func(response string) error {
+ promptResponses[idx] = response
+ return g()
+ }
+
+ resolveTemplate := self.getResolveTemplateFn(promptResponses, sessionState)
+ resolvedPrompt, err := self.resolver.resolvePrompt(&prompt, resolveTemplate)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ switch prompt.Type {
+ case "input":
+ f = func() error {
+ return self.inputPrompt(resolvedPrompt, wrappedF)
+ }
+ case "menu":
+ f = func() error {
+ return self.menuPrompt(resolvedPrompt, wrappedF)
+ }
+ case "menuFromCommand":
+ f = func() error {
+ return self.menuPromptFromCommand(resolvedPrompt, wrappedF)
+ }
+ case "confirm":
+ f = func() error {
+ return self.confirmPrompt(resolvedPrompt, g)
+ }
+ default:
+ return self.c.ErrorMsg("custom command prompt must have a type of 'input', 'menu', 'menuFromCommand', or 'confirm'")
+ }
+ }
+
+ return f()
+ }
+}
+
+func (self *HandlerCreator) inputPrompt(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error {
+ return self.c.Prompt(types.PromptOpts{
+ Title: prompt.Title,
+ InitialContent: prompt.InitialValue,
+ HandleConfirm: func(str string) error {
+ return wrappedF(str)
+ },
+ })
+}
+
+func (self *HandlerCreator) menuPrompt(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error {
+ menuItems := slices.Map(prompt.Options, func(option config.CustomCommandMenuOption) *types.MenuItem {
+ return &types.MenuItem{
+ LabelColumns: []string{option.Name, style.FgYellow.Sprint(option.Description)},
+ OnPress: func() error {
+ return wrappedF(option.Value)
+ },
+ }
+ })
+
+ return self.c.Menu(types.CreateMenuOptions{Title: prompt.Title, Items: menuItems})
+}
+
+func (self *HandlerCreator) confirmPrompt(prompt *config.CustomCommandPrompt, handleConfirm func() error) error {
+ return self.c.Confirm(types.ConfirmOpts{
+ Title: prompt.Title,
+ Prompt: prompt.Body,
+ HandleConfirm: handleConfirm,
+ })
+}
+
+func (self *HandlerCreator) menuPromptFromCommand(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error {
+ // Run and save output
+ message, err := self.git.Custom.RunWithOutput(prompt.Command)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ // Need to make a menu out of what the cmd has displayed
+ candidates, err := self.menuGenerator.call(message, prompt.Filter, prompt.ValueFormat, prompt.LabelFormat)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ menuItems := slices.Map(candidates, func(candidate *commandMenuEntry) *types.MenuItem {
+ return &types.MenuItem{
+ LabelColumns: []string{candidate.label},
+ OnPress: func() error {
+ return wrappedF(candidate.value)
+ },
+ }
+ })
+
+ return self.c.Menu(types.CreateMenuOptions{Title: prompt.Title, Items: menuItems})
+}
+
+type CustomCommandObjects struct {
+ *SessionState
+ PromptResponses []string
+}
+
+func (self *HandlerCreator) getResolveTemplateFn(promptResponses []string, sessionState *SessionState) func(string) (string, error) {
+ objects := CustomCommandObjects{
+ SessionState: sessionState,
+ PromptResponses: promptResponses,
+ }
+
+ return func(templateStr string) (string, error) { return utils.ResolveTemplate(templateStr, objects) }
+}
+
+func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, sessionState *SessionState, promptResponses []string) error {
+ resolveTemplate := self.getResolveTemplateFn(promptResponses, sessionState)
+ cmdStr, err := resolveTemplate(customCommand.Command)
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ cmdObj := self.os.Cmd.NewShell(cmdStr)
+
+ if customCommand.Subprocess {
+ return self.c.RunSubprocessAndRefresh(cmdObj)
+ }
+
+ loadingText := customCommand.LoadingText
+ if loadingText == "" {
+ loadingText = self.c.Tr.LcRunningCustomCommandStatus
+ }
+
+ return self.c.WithWaitingStatus(loadingText, func() error {
+ self.c.LogAction(self.c.Tr.Actions.CustomCommand)
+
+ if customCommand.Stream {
+ cmdObj.StreamOutput()
+ }
+ output, err := cmdObj.RunWithOutput()
+ if err != nil {
+ return self.c.Error(err)
+ }
+
+ if customCommand.ShowOutput {
+ if strings.TrimSpace(output) == "" {
+ output = self.c.Tr.EmptyOutput
+ }
+ if err = self.c.Alert(cmdStr, output); err != nil {
+ return self.c.Error(err)
+ }
+ return self.c.Refresh(types.RefreshOptions{})
+ }
+ return self.c.Refresh(types.RefreshOptions{})
+ })
+}
diff --git a/pkg/gui/services/custom_commands/keybinding_creator.go b/pkg/gui/services/custom_commands/keybinding_creator.go
new file mode 100644
index 000000000..7251225fe
--- /dev/null
+++ b/pkg/gui/services/custom_commands/keybinding_creator.go
@@ -0,0 +1,84 @@
+package custom_commands
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// KeybindingCreator takes a custom command along with its handler and returns a corresponding keybinding
+type KeybindingCreator struct {
+ contexts *context.ContextTree
+}
+
+func NewKeybindingCreator(contexts *context.ContextTree) *KeybindingCreator {
+ return &KeybindingCreator{
+ contexts: contexts,
+ }
+}
+
+func (self *KeybindingCreator) call(customCommand config.CustomCommand, handler func() error) (*types.Binding, error) {
+ if customCommand.Context == "" {
+ return nil, formatContextNotProvidedError(customCommand)
+ }
+
+ viewName, err := self.getViewNameAndContexts(customCommand)
+ if err != nil {
+ return nil, err
+ }
+
+ description := customCommand.Description
+ if description == "" {
+ description = customCommand.Command
+ }
+
+ return &types.Binding{
+ ViewName: viewName,
+ Key: keybindings.GetKey(customCommand.Key),
+ Modifier: gocui.ModNone,
+ Handler: handler,
+ Description: description,
+ }, nil
+}
+
+func (self *KeybindingCreator) getViewNameAndContexts(customCommand config.CustomCommand) (string, error) {
+ if customCommand.Context == "global" {
+ return "", nil
+ }
+
+ ctx, ok := self.contextForContextKey(types.ContextKey(customCommand.Context))
+ if !ok {
+ return "", formatUnknownContextError(customCommand)
+ }
+
+ viewName := ctx.GetViewName()
+ return viewName, nil
+}
+
+func (self *KeybindingCreator) contextForContextKey(contextKey types.ContextKey) (types.Context, bool) {
+ for _, context := range self.contexts.Flatten() {
+ if context.GetKey() == contextKey {
+ return context, true
+ }
+ }
+
+ return nil, false
+}
+
+func formatUnknownContextError(customCommand config.CustomCommand) error {
+ allContextKeyStrings := slices.Map(context.AllContextKeys, func(key types.ContextKey) string {
+ return string(key)
+ })
+
+ return fmt.Errorf("Error when setting custom command keybindings: unknown context: %s. Key: %s, Command: %s.\nPermitted contexts: %s", customCommand.Context, customCommand.Key, customCommand.Command, strings.Join(allContextKeyStrings, ", "))
+}
+
+func formatContextNotProvidedError(customCommand config.CustomCommand) error {
+ return fmt.Errorf("Error parsing custom command keybindings: context not provided (use context: 'global' for the global context). Key: %s, Command: %s", customCommand.Key, customCommand.Command)
+}
diff --git a/pkg/gui/services/custom_commands/menu_generator.go b/pkg/gui/services/custom_commands/menu_generator.go
new file mode 100644
index 000000000..5bec1db91
--- /dev/null
+++ b/pkg/gui/services/custom_commands/menu_generator.go
@@ -0,0 +1,138 @@
+package custom_commands
+
+import (
+ "bytes"
+ "errors"
+ "regexp"
+ "strconv"
+ "strings"
+ "text/template"
+
+ "github.com/jesseduffield/lazygit/pkg/common"
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
+)
+
+type MenuGenerator struct {
+ c *common.Common
+}
+
+// takes the output of a command and returns a list of menu entries based on a filter
+// and value/label format templates provided by the user
+func NewMenuGenerator(c *common.Common) *MenuGenerator {
+ return &MenuGenerator{c: c}
+}
+
+type commandMenuEntry struct {
+ label string
+ value string
+}
+
+func (self *MenuGenerator) call(commandOutput, filter, valueFormat, labelFormat string) ([]*commandMenuEntry, error) {
+ regex, err := regexp.Compile(filter)
+ if err != nil {
+ return nil, errors.New("unable to parse filter regex, error: " + err.Error())
+ }
+
+ valueTemplateAux, err := template.New("format").Parse(valueFormat)
+ if err != nil {
+ return nil, errors.New("unable to parse value format, error: " + err.Error())
+ }
+ valueTemplate := NewTrimmerTemplate(valueTemplateAux)
+
+ var labelTemplate *TrimmerTemplate
+ if labelFormat != "" {
+ colorFuncMap := style.TemplateFuncMapAddColors(template.FuncMap{})
+ labelTemplateAux, err := template.New("format").Funcs(colorFuncMap).Parse(labelFormat)
+ if err != nil {
+ return nil, errors.New("unable to parse label format, error: " + err.Error())
+ }
+ labelTemplate = NewTrimmerTemplate(labelTemplateAux)
+ } else {
+ labelTemplate = valueTemplate
+ }
+
+ candidates := []*commandMenuEntry{}
+ for _, line := range strings.Split(commandOutput, "\n") {
+ if line == "" {
+ continue
+ }
+
+ candidate, err := self.generateMenuCandidate(
+ line,
+ regex,
+ valueTemplate,
+ labelTemplate,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ candidates = append(candidates, candidate)
+ }
+
+ return candidates, err
+}
+
+func (self *MenuGenerator) generateMenuCandidate(
+ line string,
+ regex *regexp.Regexp,
+ valueTemplate *TrimmerTemplate,
+ labelTemplate *TrimmerTemplate,
+) (*commandMenuEntry, error) {
+ tmplData := self.parseLine(line, regex)
+
+ entry := &commandMenuEntry{}
+
+ var err error
+ entry.value, err = valueTemplate.execute(tmplData)
+ if err != nil {
+ return nil, err
+ }
+
+ entry.label, err = labelTemplate.execute(tmplData)
+ if err != nil {
+ return nil, err
+ }
+
+ return entry, nil
+}
+
+func (self *MenuGenerator) parseLine(line string, regex *regexp.Regexp) map[string]string {
+ tmplData := map[string]string{}
+ out := regex.FindAllStringSubmatch(line, -1)
+ if len(out) > 0 {
+ for groupIdx, group := range regex.SubexpNames() {
+ // Record matched group with group ids
+ matchName := "group_" + strconv.Itoa(groupIdx)
+ tmplData[matchName] = out[0][groupIdx]
+ // Record last named group non-empty matches as group matches
+ if group != "" {
+ tmplData[group] = out[0][groupIdx]
+ }
+ }
+ }
+
+ return tmplData
+}
+
+// wrapper around a template which trims the output
+type TrimmerTemplate struct {
+ template *template.Template
+ buffer *bytes.Buffer
+}
+
+func NewTrimmerTemplate(template *template.Template) *TrimmerTemplate {
+ return &TrimmerTemplate{
+ template: template,
+ buffer: bytes.NewBuffer(nil),
+ }
+}
+
+func (self *TrimmerTemplate) execute(tmplData map[string]string) (string, error) {
+ self.buffer.Reset()
+ err := self.template.Execute(self.buffer, tmplData)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(self.buffer.String()), nil
+}
diff --git a/pkg/gui/services/custom_commands/menu_generator_test.go b/pkg/gui/services/custom_commands/menu_generator_test.go
new file mode 100644
index 000000000..7dd3e58e8
--- /dev/null
+++ b/pkg/gui/services/custom_commands/menu_generator_test.go
@@ -0,0 +1,65 @@
+package custom_commands
+
+import (
+ "testing"
+
+ "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMenuGenerator(t *testing.T) {
+ type scenario struct {
+ testName string
+ cmdOut string
+ filter string
+ valueFormat string
+ labelFormat string
+ test func([]*commandMenuEntry, error)
+ }
+
+ scenarios := []scenario{
+ {
+ "Extract remote branch name",
+ "upstream/pr-1",
+ "(?P[a-z_]+)/(?P.*)",
+ "{{ .branch }}",
+ "Remote: {{ .remote }}",
+ func(actualEntry []*commandMenuEntry, err error) {
+ assert.NoError(t, err)
+ assert.EqualValues(t, "pr-1", actualEntry[0].value)
+ assert.EqualValues(t, "Remote: upstream", actualEntry[0].label)
+ },
+ },
+ {
+ "Multiple named groups with empty labelFormat",
+ "upstream/pr-1",
+ "(?P[a-z]*)/(?P.*)",
+ "{{ .branch }}|{{ .remote }}",
+ "",
+ func(actualEntry []*commandMenuEntry, err error) {
+ assert.NoError(t, err)
+ assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value)
+ assert.EqualValues(t, "pr-1|upstream", actualEntry[0].label)
+ },
+ },
+ {
+ "Multiple named groups with group ids",
+ "upstream/pr-1",
+ "(?P[a-z]*)/(?P.*)",
+ "{{ .group_2 }}|{{ .group_1 }}",
+ "Remote: {{ .group_1 }}",
+ func(actualEntry []*commandMenuEntry, err error) {
+ assert.NoError(t, err)
+ assert.EqualValues(t, "pr-1|upstream", actualEntry[0].value)
+ assert.EqualValues(t, "Remote: upstream", actualEntry[0].label)
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ s := s
+ t.Run(s.testName, func(t *testing.T) {
+ s.test(NewMenuGenerator(utils.NewDummyCommon()).call(s.cmdOut, s.filter, s.valueFormat, s.labelFormat))
+ })
+ }
+}
diff --git a/pkg/gui/services/custom_commands/resolver.go b/pkg/gui/services/custom_commands/resolver.go
new file mode 100644
index 000000000..4702d36c4
--- /dev/null
+++ b/pkg/gui/services/custom_commands/resolver.go
@@ -0,0 +1,126 @@
+package custom_commands
+
+import (
+ "bytes"
+ "text/template"
+
+ "github.com/jesseduffield/lazygit/pkg/common"
+ "github.com/jesseduffield/lazygit/pkg/config"
+)
+
+// takes a prompt that is defined in terms of template strings and resolves the templates to contain actual values
+type Resolver struct {
+ c *common.Common
+}
+
+func NewResolver(c *common.Common) *Resolver {
+ return &Resolver{c: c}
+}
+
+func (self *Resolver) resolvePrompt(
+ prompt *config.CustomCommandPrompt,
+ resolveTemplate func(string) (string, error),
+) (*config.CustomCommandPrompt, error) {
+ var err error
+ result := &config.CustomCommandPrompt{
+ ValueFormat: prompt.ValueFormat,
+ LabelFormat: prompt.LabelFormat,
+ }
+
+ result.Title, err = resolveTemplate(prompt.Title)
+ if err != nil {
+ return nil, err
+ }
+
+ result.InitialValue, err = resolveTemplate(prompt.InitialValue)
+ if err != nil {
+ return nil, err
+ }
+
+ result.Body, err = resolveTemplate(prompt.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ result.Command, err = resolveTemplate(prompt.Command)
+ if err != nil {
+ return nil, err
+ }
+
+ result.Filter, err = resolveTemplate(prompt.Filter)
+ if err != nil {
+ return nil, err
+ }
+
+ if prompt.Type == "menu" {
+ result.Options, err = self.resolveMenuOptions(prompt, resolveTemplate)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return result, nil
+}
+
+func (self *Resolver) resolveMenuOptions(prompt *config.CustomCommandPrompt, resolveTemplate func(string) (string, error)) ([]config.CustomCommandMenuOption, error) {
+ newOptions := make([]config.CustomCommandMenuOption, 0, len(prompt.Options))
+ for _, option := range prompt.Options {
+ option := option
+ newOption, err := self.resolveMenuOption(&option, resolveTemplate)
+ if err != nil {
+ return nil, err
+ }
+ newOptions = append(newOptions, *newOption)
+ }
+
+ return newOptions, nil
+}
+
+func (self *Resolver) resolveMenuOption(option *config.CustomCommandMenuOption, resolveTemplate func(string) (string, error)) (*config.CustomCommandMenuOption, error) {
+ nameTemplate := option.Name
+ if nameTemplate == "" {
+ // this allows you to only pass values rather than bother with names/descriptions
+ nameTemplate = option.Value
+ }
+
+ name, err := resolveTemplate(nameTemplate)
+ if err != nil {
+ return nil, err
+ }
+
+ description, err := resolveTemplate(option.Description)
+ if err != nil {
+ return nil, err
+ }
+
+ value, err := resolveTemplate(option.Value)
+ if err != nil {
+ return nil, err
+ }
+
+ return &config.CustomCommandMenuOption{
+ Name: name,
+ Description: description,
+ Value: value,
+ }, nil
+}
+
+type CustomCommandObject struct {
+ // deprecated. Use Responses instead
+ PromptResponses []string
+ Form map[string]string
+}
+
+func ResolveTemplate(templateStr string, object interface{}) (string, error) {
+ tmpl, err := template.New("template").Parse(templateStr)
+ if err != nil {
+ return "", err
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, object); err != nil {
+ return "", err
+ }
+
+ return buf.String(), nil
+}
diff --git a/pkg/gui/services/custom_commands/session_state_loader.go b/pkg/gui/services/custom_commands/session_state_loader.go
new file mode 100644
index 000000000..42f3403ec
--- /dev/null
+++ b/pkg/gui/services/custom_commands/session_state_loader.go
@@ -0,0 +1,56 @@
+package custom_commands
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
+)
+
+// loads the session state at the time that a custom command is invoked, for use
+// in the custom command's template strings
+type SessionStateLoader struct {
+ contexts *context.ContextTree
+ helpers *helpers.Helpers
+}
+
+func NewSessionStateLoader(contexts *context.ContextTree, helpers *helpers.Helpers) *SessionStateLoader {
+ return &SessionStateLoader{
+ contexts: contexts,
+ helpers: helpers,
+ }
+}
+
+// SessionState captures the current state of the application for use in custom commands
+type SessionState struct {
+ SelectedLocalCommit *models.Commit
+ SelectedReflogCommit *models.Commit
+ SelectedSubCommit *models.Commit
+ SelectedFile *models.File
+ SelectedPath string
+ SelectedLocalBranch *models.Branch
+ SelectedRemoteBranch *models.RemoteBranch
+ SelectedRemote *models.Remote
+ SelectedTag *models.Tag
+ SelectedStashEntry *models.StashEntry
+ SelectedCommitFile *models.CommitFile
+ SelectedCommitFilePath string
+ CheckedOutBranch *models.Branch
+}
+
+func (self *SessionStateLoader) call() *SessionState {
+ return &SessionState{
+ SelectedFile: self.contexts.Files.GetSelectedFile(),
+ SelectedPath: self.contexts.Files.GetSelectedPath(),
+ SelectedLocalCommit: self.contexts.LocalCommits.GetSelected(),
+ SelectedReflogCommit: self.contexts.ReflogCommits.GetSelected(),
+ SelectedLocalBranch: self.contexts.Branches.GetSelected(),
+ SelectedRemoteBranch: self.contexts.RemoteBranches.GetSelected(),
+ SelectedRemote: self.contexts.Remotes.GetSelected(),
+ SelectedTag: self.contexts.Tags.GetSelected(),
+ SelectedStashEntry: self.contexts.Stash.GetSelected(),
+ SelectedCommitFile: self.contexts.CommitFiles.GetSelectedFile(),
+ SelectedCommitFilePath: self.contexts.CommitFiles.GetSelectedPath(),
+ SelectedSubCommit: self.contexts.SubCommits.GetSelected(),
+ CheckedOutBranch: self.helpers.Refs.GetCheckedOutRef(),
+ }
+}
diff --git a/pkg/gui/side_window.go b/pkg/gui/side_window.go
index 2aad00c37..b57998d00 100644
--- a/pkg/gui/side_window.go
+++ b/pkg/gui/side_window.go
@@ -21,9 +21,9 @@ func (gui *Gui) nextSideWindow() error {
return err
}
- viewName := gui.getViewNameForWindow(newWindow)
+ context := gui.getContextForWindow(newWindow)
- return gui.pushContextWithView(viewName)
+ return gui.c.PushContext(context)
}
func (gui *Gui) previousSideWindow() error {
@@ -47,13 +47,15 @@ func (gui *Gui) previousSideWindow() error {
return err
}
- viewName := gui.getViewNameForWindow(newWindow)
+ context := gui.getContextForWindow(newWindow)
- return gui.pushContextWithView(viewName)
+ return gui.c.PushContext(context)
}
-func (gui *Gui) goToSideWindow(sideViewName string) func() error {
+func (gui *Gui) goToSideWindow(window string) func() error {
return func() error {
- return gui.pushContextWithView(sideViewName)
+ context := gui.getContextForWindow(window)
+
+ return gui.c.PushContext(context)
}
}
diff --git a/pkg/gui/staging_panel.go b/pkg/gui/staging_panel.go
deleted file mode 100644
index ecb208dcc..000000000
--- a/pkg/gui/staging_panel.go
+++ /dev/null
@@ -1,166 +0,0 @@
-package gui
-
-import (
- "strings"
-
- "github.com/jesseduffield/lazygit/pkg/commands/patch"
-)
-
-func (gui *Gui) refreshStagingPanel(forceSecondaryFocused bool, selectedLineIdx int) error {
- gui.splitMainPanel(true)
-
- file := gui.getSelectedFile()
- if file == nil || (!file.HasUnstagedChanges && !file.HasStagedChanges) {
- return gui.handleStagingEscape()
- }
-
- secondaryFocused := false
- if forceSecondaryFocused {
- secondaryFocused = true
- } else if gui.State.Panels.LineByLine != nil {
- secondaryFocused = gui.State.Panels.LineByLine.SecondaryFocused
- }
-
- if (secondaryFocused && !file.HasStagedChanges) || (!secondaryFocused && !file.HasUnstagedChanges) {
- secondaryFocused = !secondaryFocused
- }
-
- if secondaryFocused {
- gui.Views.Main.Title = gui.Tr.StagedChanges
- gui.Views.Secondary.Title = gui.Tr.UnstagedChanges
- } else {
- gui.Views.Main.Title = gui.Tr.UnstagedChanges
- gui.Views.Secondary.Title = gui.Tr.StagedChanges
- }
-
- // note for custom diffs, we'll need to send a flag here saying not to use the custom diff
- diff := gui.Git.WorkingTree.WorktreeFileDiff(file, true, secondaryFocused, false)
- secondaryDiff := gui.Git.WorkingTree.WorktreeFileDiff(file, true, !secondaryFocused, false)
-
- // if we have e.g. a deleted file with nothing else to the diff will have only
- // 4-5 lines in which case we'll swap panels
- if len(strings.Split(diff, "\n")) < 5 {
- if len(strings.Split(secondaryDiff, "\n")) < 5 {
- return gui.handleStagingEscape()
- }
- secondaryFocused = !secondaryFocused
- diff, secondaryDiff = secondaryDiff, diff
- }
-
- empty, err := gui.refreshLineByLinePanel(diff, secondaryDiff, secondaryFocused, selectedLineIdx)
- if err != nil {
- return err
- }
-
- if empty {
- return gui.handleStagingEscape()
- }
-
- return nil
-}
-
-func (gui *Gui) handleTogglePanelClick() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.SecondaryFocused = !state.SecondaryFocused
-
- return gui.refreshStagingPanel(false, gui.Views.Secondary.SelectedLineIdx())
- })
-}
-
-func (gui *Gui) handleRefreshStagingPanel(forceSecondaryFocused bool, selectedLineIdx int) error {
- gui.Mutexes.LineByLinePanelMutex.Lock()
- defer gui.Mutexes.LineByLinePanelMutex.Unlock()
-
- return gui.refreshStagingPanel(forceSecondaryFocused, selectedLineIdx)
-}
-
-func (gui *Gui) onStagingFocus(forceSecondaryFocused bool, selectedLineIdx int) error {
- gui.Mutexes.LineByLinePanelMutex.Lock()
- defer gui.Mutexes.LineByLinePanelMutex.Unlock()
-
- if gui.State.Panels.LineByLine == nil || selectedLineIdx != -1 {
- return gui.refreshStagingPanel(forceSecondaryFocused, selectedLineIdx)
- }
-
- return nil
-}
-
-func (gui *Gui) handleTogglePanel() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- state.SecondaryFocused = !state.SecondaryFocused
- return gui.refreshStagingPanel(false, -1)
- })
-}
-
-func (gui *Gui) handleStagingEscape() error {
- gui.escapeLineByLinePanel()
-
- return gui.pushContext(gui.State.Contexts.Files)
-}
-
-func (gui *Gui) handleToggleStagedSelection() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- return gui.applySelection(state.SecondaryFocused, state)
- })
-}
-
-func (gui *Gui) handleResetSelection() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- if state.SecondaryFocused {
- // for backwards compatibility
- return gui.applySelection(true, state)
- }
-
- if !gui.UserConfig.Gui.SkipUnstageLineWarning {
- return gui.ask(askOpts{
- title: gui.Tr.UnstageLinesTitle,
- prompt: gui.Tr.UnstageLinesPrompt,
- handleConfirm: func() error {
- return gui.withLBLActiveCheck(func(state *LblPanelState) error {
- return gui.applySelection(true, state)
- })
- },
- })
- } else {
- return gui.applySelection(true, state)
- }
- })
-}
-
-func (gui *Gui) applySelection(reverse bool, state *LblPanelState) error {
- file := gui.getSelectedFile()
- if file == nil {
- return nil
- }
-
- firstLineIdx, lastLineIdx := state.SelectedRange()
- patch := patch.ModifiedPatchForRange(gui.Log, file.Name, state.GetDiff(), firstLineIdx, lastLineIdx, reverse, false)
-
- if patch == "" {
- return nil
- }
-
- // apply the patch then refresh this panel
- // create a new temp file with the patch, then call git apply with that patch
- applyFlags := []string{}
- if !reverse || state.SecondaryFocused {
- applyFlags = append(applyFlags, "cached")
- }
- gui.logAction(gui.Tr.Actions.ApplyPatch)
- err := gui.Git.WorkingTree.ApplyPatch(patch, applyFlags...)
- if err != nil {
- return gui.surfaceError(err)
- }
-
- if state.SelectingRange() {
- state.SetLineSelectMode()
- }
-
- if err := gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}}); err != nil {
- return err
- }
- if err := gui.refreshStagingPanel(false, -1); err != nil {
- return err
- }
- return nil
-}
diff --git a/pkg/gui/stash_panel.go b/pkg/gui/stash_panel.go
index 2b9bae445..439d8205e 100644
--- a/pkg/gui/stash_panel.go
+++ b/pkg/gui/stash_panel.go
@@ -1,156 +1,21 @@
package gui
-import (
- "github.com/jesseduffield/lazygit/pkg/commands/models"
-)
-
-// list panel functions
-
-func (gui *Gui) getSelectedStashEntry() *models.StashEntry {
- selectedLine := gui.State.Panels.Stash.SelectedLineIdx
- if selectedLine == -1 {
- return nil
- }
-
- return gui.State.StashEntries[selectedLine]
-}
+import "github.com/jesseduffield/lazygit/pkg/gui/types"
func (gui *Gui) stashRenderToMain() error {
- var task updateTask
- stashEntry := gui.getSelectedStashEntry()
+ var task types.UpdateTask
+ stashEntry := gui.State.Contexts.Stash.GetSelected()
if stashEntry == nil {
- task = NewRenderStringTask(gui.Tr.NoStashEntries)
+ task = types.NewRenderStringTask(gui.c.Tr.NoStashEntries)
} else {
- task = NewRunPtyTask(gui.Git.Stash.ShowStashEntryCmdObj(stashEntry.Index).GetCmd())
+ task = types.NewRunPtyTask(gui.git.Stash.ShowStashEntryCmdObj(stashEntry.Index).GetCmd())
}
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "Stash",
- task: task,
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: "Stash",
+ Task: task,
},
})
}
-
-func (gui *Gui) refreshStashEntries() error {
- gui.State.StashEntries = gui.Git.Loaders.Stash.
- GetStashEntries(gui.State.Modes.Filtering.GetPath())
-
- return gui.postRefreshUpdate(gui.State.Contexts.Stash)
-}
-
-// specific functions
-
-func (gui *Gui) handleStashApply() error {
- stashEntry := gui.getSelectedStashEntry()
- if stashEntry == nil {
- return nil
- }
-
- skipStashWarning := gui.UserConfig.Gui.SkipStashWarning
-
- apply := func() error {
- gui.logAction(gui.Tr.Actions.Stash)
- err := gui.Git.Stash.Apply(stashEntry.Index)
- _ = gui.postStashRefresh()
- if err != nil {
- return gui.surfaceError(err)
- }
- return nil
- }
-
- if skipStashWarning {
- return apply()
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.StashApply,
- prompt: gui.Tr.SureApplyStashEntry,
- handleConfirm: func() error {
- return apply()
- },
- })
-}
-
-func (gui *Gui) handleStashPop() error {
- stashEntry := gui.getSelectedStashEntry()
- if stashEntry == nil {
- return nil
- }
-
- skipStashWarning := gui.UserConfig.Gui.SkipStashWarning
-
- pop := func() error {
- gui.logAction(gui.Tr.Actions.Stash)
- err := gui.Git.Stash.Pop(stashEntry.Index)
- _ = gui.postStashRefresh()
- if err != nil {
- return gui.surfaceError(err)
- }
- return nil
- }
-
- if skipStashWarning {
- return pop()
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.StashPop,
- prompt: gui.Tr.SurePopStashEntry,
- handleConfirm: func() error {
- return pop()
- },
- })
-}
-
-func (gui *Gui) handleStashDrop() error {
- stashEntry := gui.getSelectedStashEntry()
- if stashEntry == nil {
- return nil
- }
-
- return gui.ask(askOpts{
- title: gui.Tr.StashDrop,
- prompt: gui.Tr.SureDropStashEntry,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.Stash)
- err := gui.Git.Stash.Drop(stashEntry.Index)
- _ = gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{STASH}})
- if err != nil {
- return gui.surfaceError(err)
- }
- return nil
- },
- })
-}
-
-func (gui *Gui) postStashRefresh() error {
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{STASH, FILES}})
-}
-
-func (gui *Gui) handleStashSave(stashFunc func(message string) error) error {
- if len(gui.trackedFiles()) == 0 && len(gui.stagedFiles()) == 0 {
- return gui.createErrorPanel(gui.Tr.NoTrackedStagedFilesStash)
- }
-
- return gui.prompt(promptOpts{
- title: gui.Tr.StashChanges,
- handleConfirm: func(stashComment string) error {
- err := stashFunc(stashComment)
- _ = gui.postStashRefresh()
- if err != nil {
- return gui.surfaceError(err)
- }
- return nil
- },
- })
-}
-
-func (gui *Gui) handleViewStashFiles() error {
- stashEntry := gui.getSelectedStashEntry()
- if stashEntry == nil {
- return nil
- }
-
- return gui.switchToCommitFilesContext(stashEntry.RefName(), false, gui.State.Contexts.Stash, "stash")
-}
diff --git a/pkg/gui/status_panel.go b/pkg/gui/status_panel.go
index 444c32da1..75f69b736 100644
--- a/pkg/gui/status_panel.go
+++ b/pkg/gui/status_panel.go
@@ -5,41 +5,15 @@ import (
"fmt"
"strings"
+ "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
"github.com/jesseduffield/lazygit/pkg/constants"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
-// never call this on its own, it should only be called from within refreshCommits()
-func (gui *Gui) refreshStatus() {
- gui.Mutexes.RefreshingStatusMutex.Lock()
- defer gui.Mutexes.RefreshingStatusMutex.Unlock()
-
- currentBranch := gui.currentBranch()
- if currentBranch == nil {
- // need to wait for branches to refresh
- return
- }
- status := ""
-
- if currentBranch.IsRealBranch() {
- status += presentation.ColoredBranchStatus(currentBranch) + " "
- }
-
- workingTreeState := gui.Git.Status.WorkingTreeState()
- if workingTreeState != enums.REBASE_MODE_NONE {
- status += style.FgYellow.Sprintf("(%s) ", formatWorkingTreeState(workingTreeState))
- }
-
- name := presentation.GetBranchTextStyle(currentBranch.Name).Sprint(currentBranch.Name)
- repoName := utils.GetCurrentRepoName()
- status += fmt.Sprintf("%s → %s ", repoName, name)
-
- gui.setViewContent(gui.Views.Status, status)
-}
-
func runeCount(str string) int {
return len([]rune(str))
}
@@ -49,35 +23,33 @@ func cursorInSubstring(cx int, prefix string, substring string) bool {
}
func (gui *Gui) handleCheckForUpdate() error {
- gui.Updater.CheckForNewUpdate(gui.onUserUpdateCheckFinish, true)
- return gui.createLoaderPanel(gui.Tr.CheckingForUpdates)
+ return gui.c.WithWaitingStatus(gui.c.Tr.CheckingForUpdates, func() error {
+ gui.Updater.CheckForNewUpdate(gui.onUserUpdateCheckFinish, true)
+ return nil
+ })
}
func (gui *Gui) handleStatusClick() error {
// TODO: move into some abstraction (status is currently not a listViewContext where a lot of this code lives)
- if gui.popupPanelFocused() {
- return nil
- }
-
- currentBranch := gui.currentBranch()
+ currentBranch := gui.helpers.Refs.GetCheckedOutRef()
if currentBranch == nil {
// need to wait for branches to refresh
return nil
}
- if err := gui.pushContext(gui.State.Contexts.Status); err != nil {
+ if err := gui.c.PushContext(gui.State.Contexts.Status); err != nil {
return err
}
cx, _ := gui.Views.Status.Cursor()
- upstreamStatus := presentation.BranchStatus(currentBranch)
+ upstreamStatus := presentation.BranchStatus(currentBranch, gui.Tr)
repoName := utils.GetCurrentRepoName()
- workingTreeState := gui.Git.Status.WorkingTreeState()
+ workingTreeState := gui.git.Status.WorkingTreeState()
switch workingTreeState {
case enums.REBASE_MODE_REBASING, enums.REBASE_MODE_MERGING:
workingTreeStatus := fmt.Sprintf("(%s)", formatWorkingTreeState(workingTreeState))
if cursorInSubstring(cx, upstreamStatus+" ", workingTreeStatus) {
- return gui.handleCreateRebaseOptionsMenu()
+ return gui.helpers.MergeAndRebase.CreateRebaseOptionsMenu()
}
if cursorInSubstring(cx, upstreamStatus+" "+workingTreeStatus+" ", repoName) {
return gui.handleCreateRecentReposMenu()
@@ -103,11 +75,6 @@ func formatWorkingTreeState(rebaseMode enums.RebaseMode) string {
}
func (gui *Gui) statusRenderToMain() error {
- // TODO: move into some abstraction (status is currently not a listViewContext where a lot of this code lives)
- if gui.popupPanelFocused() {
- return nil
- }
-
dashboardString := strings.Join(
[]string{
lazygitTitle(),
@@ -120,10 +87,11 @@ func (gui *Gui) statusRenderToMain() error {
style.FgMagenta.Sprintf("Become a sponsor: %s", constants.Links.Donate), // caffeine ain't free
}, "\n\n")
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "",
- task: NewRenderStringTask(dashboardString),
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: gui.c.Tr.StatusTitle,
+ Task: types.NewRenderStringTask(dashboardString),
},
})
}
@@ -132,30 +100,32 @@ func (gui *Gui) askForConfigFile(action func(file string) error) error {
confPaths := gui.Config.GetUserConfigPaths()
switch len(confPaths) {
case 0:
- return errors.New(gui.Tr.NoConfigFileFoundErr)
+ return errors.New(gui.c.Tr.NoConfigFileFoundErr)
case 1:
return action(confPaths[0])
default:
- menuItems := make([]*menuItem, len(confPaths))
- for i, file := range confPaths {
- i := i
- menuItems[i] = &menuItem{
- displayString: file,
- onPress: func() error {
- return action(confPaths[i])
+ menuItems := slices.Map(confPaths, func(path string) *types.MenuItem {
+ return &types.MenuItem{
+ Label: path,
+ OnPress: func() error {
+ return action(path)
},
}
- }
- return gui.createMenu(gui.Tr.SelectConfigFile, menuItems, createMenuOptions{})
+ })
+
+ return gui.c.Menu(types.CreateMenuOptions{
+ Title: gui.c.Tr.SelectConfigFile,
+ Items: menuItems,
+ })
}
}
func (gui *Gui) handleOpenConfig() error {
- return gui.askForConfigFile(gui.openFile)
+ return gui.askForConfigFile(gui.helpers.Files.OpenFile)
}
func (gui *Gui) handleEditConfig() error {
- return gui.askForConfigFile(gui.editFile)
+ return gui.askForConfigFile(gui.helpers.Files.EditFile)
}
func lazygitTitle() string {
diff --git a/pkg/gui/style/style_test.go b/pkg/gui/style/style_test.go
index 360ad00e6..c8157efd6 100644
--- a/pkg/gui/style/style_test.go
+++ b/pkg/gui/style/style_test.go
@@ -135,7 +135,7 @@ func TestMerge(t *testing.T) {
"\x1b[38;2;255;0;255;48;2;255;255;0;1;4mfoo\x1b[0m",
},
{
- "mix color-16 with rgb colors",
+ "mix color-16 (background) with rgb (foreground)",
[]TextStyle{New().SetFg(rgbYellow), BgRed},
TextStyle{
fg: &rgbYellow,
@@ -147,6 +147,19 @@ func TestMerge(t *testing.T) {
},
"\x1b[38;2;255;255;0;48;2;197;30;20mfoo\x1b[0m",
},
+ {
+ "mix color-16 (foreground) with rgb (background)",
+ []TextStyle{FgRed, New().SetBg(rgbYellow)},
+ TextStyle{
+ fg: &Color{basic: &fgRed},
+ bg: &rgbYellow,
+ Style: color.NewRGBStyle(
+ fgRed.RGB(),
+ rgbYellowLib,
+ ).SetOpts(color.Opts{}),
+ },
+ "\x1b[38;2;197;30;20;48;2;255;255;0mfoo\x1b[0m",
+ },
}
for _, s := range scenarios {
diff --git a/pkg/gui/sub_commits_panel.go b/pkg/gui/sub_commits_panel.go
index d023d261f..ae3f4e905 100644
--- a/pkg/gui/sub_commits_panel.go
+++ b/pkg/gui/sub_commits_panel.go
@@ -1,106 +1,25 @@
package gui
-import (
- "github.com/jesseduffield/lazygit/pkg/commands/loaders"
- "github.com/jesseduffield/lazygit/pkg/commands/models"
-)
+import "github.com/jesseduffield/lazygit/pkg/gui/types"
// list panel functions
-func (gui *Gui) getSelectedSubCommit() *models.Commit {
- selectedLine := gui.State.Panels.SubCommits.SelectedLineIdx
- commits := gui.State.SubCommits
- if selectedLine == -1 || len(commits) == 0 {
- return nil
- }
-
- return commits[selectedLine]
-}
-
func (gui *Gui) subCommitsRenderToMain() error {
- commit := gui.getSelectedSubCommit()
- var task updateTask
+ commit := gui.State.Contexts.SubCommits.GetSelected()
+ var task types.UpdateTask
if commit == nil {
- task = NewRenderStringTask("No commits")
+ task = types.NewRenderStringTask("No commits")
} else {
- cmdObj := gui.Git.Commit.ShowCmdObj(commit.Sha, gui.State.Modes.Filtering.GetPath())
+ cmdObj := gui.git.Commit.ShowCmdObj(commit.Sha, gui.State.Modes.Filtering.GetPath())
- task = NewRunPtyTask(cmdObj.GetCmd())
+ task = types.NewRunPtyTask(cmdObj.GetCmd())
}
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "Commit",
- task: task,
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: "Commit",
+ Task: task,
},
})
}
-
-func (gui *Gui) handleCheckoutSubCommit() error {
- commit := gui.getSelectedSubCommit()
- if commit == nil {
- return nil
- }
-
- err := gui.ask(askOpts{
- title: gui.Tr.LcCheckoutCommit,
- prompt: gui.Tr.SureCheckoutThisCommit,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.CheckoutCommit)
- return gui.handleCheckoutRef(commit.Sha, handleCheckoutRefOptions{})
- },
- })
- if err != nil {
- return err
- }
-
- gui.State.Panels.SubCommits.SelectedLineIdx = 0
-
- return nil
-}
-
-func (gui *Gui) handleCreateSubCommitResetMenu() error {
- commit := gui.getSelectedSubCommit()
-
- return gui.createResetMenu(commit.Sha)
-}
-
-func (gui *Gui) handleViewSubCommitFiles() error {
- commit := gui.getSelectedSubCommit()
- if commit == nil {
- return nil
- }
-
- return gui.switchToCommitFilesContext(commit.Sha, false, gui.State.Contexts.SubCommits, "branches")
-}
-
-func (gui *Gui) switchToSubCommitsContext(refName string) error {
- // need to populate my sub commits
- commits, err := gui.Git.Loaders.Commits.GetCommits(
- loaders.GetCommitsOptions{
- Limit: gui.State.Panels.Commits.LimitCommits,
- FilterPath: gui.State.Modes.Filtering.GetPath(),
- IncludeRebaseCommits: false,
- RefName: refName,
- },
- )
- if err != nil {
- return err
- }
-
- gui.State.SubCommits = commits
- gui.State.Panels.SubCommits.refName = refName
- gui.State.Panels.SubCommits.SelectedLineIdx = 0
- gui.State.Contexts.SubCommits.SetParentContext(gui.currentSideListContext())
-
- return gui.pushContext(gui.State.Contexts.SubCommits)
-}
-
-func (gui *Gui) handleSwitchToSubCommits() error {
- currentContext := gui.currentSideListContext()
- if currentContext == nil {
- return nil
- }
-
- return gui.switchToSubCommitsContext(currentContext.GetSelectedItemId())
-}
diff --git a/pkg/gui/submodules_panel.go b/pkg/gui/submodules_panel.go
index be0574356..163234153 100644
--- a/pkg/gui/submodules_panel.go
+++ b/pkg/gui/submodules_panel.go
@@ -3,27 +3,17 @@ package gui
import (
"fmt"
"os"
- "path/filepath"
- "strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
)
-func (gui *Gui) getSelectedSubmodule() *models.SubmoduleConfig {
- selectedLine := gui.State.Panels.Submodules.SelectedLineIdx
- if selectedLine == -1 || len(gui.State.Submodules) == 0 {
- return nil
- }
-
- return gui.State.Submodules[selectedLine]
-}
-
func (gui *Gui) submodulesRenderToMain() error {
- var task updateTask
- submodule := gui.getSelectedSubmodule()
+ var task types.UpdateTask
+ submodule := gui.State.Contexts.Submodules.GetSelected()
if submodule == nil {
- task = NewRenderStringTask("No submodules")
+ task = types.NewRenderStringTask("No submodules")
} else {
prefix := fmt.Sprintf(
"Name: %s\nPath: %s\nUrl: %s\n\n",
@@ -32,233 +22,30 @@ func (gui *Gui) submodulesRenderToMain() error {
style.FgCyan.Sprint(submodule.Url),
)
- file := gui.fileForSubmodule(submodule)
+ file := gui.helpers.WorkingTree.FileForSubmodule(submodule)
if file == nil {
- task = NewRenderStringTask(prefix)
+ task = types.NewRenderStringTask(prefix)
} else {
- cmdObj := gui.Git.WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, gui.State.IgnoreWhitespaceInDiffView)
- task = NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)
+ cmdObj := gui.git.WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, gui.IgnoreWhitespaceInDiffView)
+ task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)
}
}
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "Submodule",
- task: task,
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: "Submodule",
+ Task: task,
},
})
}
-func (gui *Gui) refreshStateSubmoduleConfigs() error {
- configs, err := gui.Git.Submodule.GetConfigs()
- if err != nil {
- return err
- }
-
- gui.State.Submodules = configs
-
- return nil
-}
-
-func (gui *Gui) handleSubmoduleEnter(submodule *models.SubmoduleConfig) error {
- return gui.enterSubmodule(submodule)
-}
-
func (gui *Gui) enterSubmodule(submodule *models.SubmoduleConfig) error {
wd, err := os.Getwd()
if err != nil {
return err
}
- gui.RepoPathStack = append(gui.RepoPathStack, wd)
+ gui.RepoPathStack.Push(wd)
return gui.dispatchSwitchToRepo(submodule.Path, true)
}
-
-func (gui *Gui) removeSubmodule(submodule *models.SubmoduleConfig) error {
- return gui.ask(askOpts{
- title: gui.Tr.RemoveSubmodule,
- prompt: fmt.Sprintf(gui.Tr.RemoveSubmodulePrompt, submodule.Name),
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.RemoveSubmodule)
- if err := gui.Git.Submodule.Delete(submodule); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{SUBMODULES, FILES}})
- },
- })
-}
-
-func (gui *Gui) handleResetSubmodule(submodule *models.SubmoduleConfig) error {
- return gui.WithWaitingStatus(gui.Tr.LcResettingSubmoduleStatus, func() error {
- return gui.resetSubmodule(submodule)
- })
-}
-
-func (gui *Gui) fileForSubmodule(submodule *models.SubmoduleConfig) *models.File {
- for _, file := range gui.State.FileTreeViewModel.GetAllFiles() {
- if file.IsSubmodule([]*models.SubmoduleConfig{submodule}) {
- return file
- }
- }
-
- return nil
-}
-
-func (gui *Gui) resetSubmodule(submodule *models.SubmoduleConfig) error {
- gui.logAction(gui.Tr.Actions.ResetSubmodule)
-
- file := gui.fileForSubmodule(submodule)
- if file != nil {
- if err := gui.Git.WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
- return gui.surfaceError(err)
- }
- }
-
- if err := gui.Git.Submodule.Stash(submodule); err != nil {
- return gui.surfaceError(err)
- }
- if err := gui.Git.Submodule.Reset(submodule); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES, SUBMODULES}})
-}
-
-func (gui *Gui) handleAddSubmodule() error {
- return gui.prompt(promptOpts{
- title: gui.Tr.LcNewSubmoduleUrl,
- handleConfirm: func(submoduleUrl string) error {
- nameSuggestion := filepath.Base(strings.TrimSuffix(submoduleUrl, filepath.Ext(submoduleUrl)))
-
- return gui.prompt(promptOpts{
- title: gui.Tr.LcNewSubmoduleName,
- initialContent: nameSuggestion,
- handleConfirm: func(submoduleName string) error {
-
- return gui.prompt(promptOpts{
- title: gui.Tr.LcNewSubmodulePath,
- initialContent: submoduleName,
- handleConfirm: func(submodulePath string) error {
- return gui.WithWaitingStatus(gui.Tr.LcAddingSubmoduleStatus, func() error {
- gui.logAction(gui.Tr.Actions.AddSubmodule)
- err := gui.Git.Submodule.Add(submoduleName, submodulePath, submoduleUrl)
- gui.handleCredentialsPopup(err)
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{SUBMODULES}})
- })
- },
- })
- },
- })
- },
- })
-
-}
-
-func (gui *Gui) handleEditSubmoduleUrl(submodule *models.SubmoduleConfig) error {
- return gui.prompt(promptOpts{
- title: fmt.Sprintf(gui.Tr.LcUpdateSubmoduleUrl, submodule.Name),
- initialContent: submodule.Url,
- handleConfirm: func(newUrl string) error {
- return gui.WithWaitingStatus(gui.Tr.LcUpdatingSubmoduleUrlStatus, func() error {
- gui.logAction(gui.Tr.Actions.UpdateSubmoduleUrl)
- err := gui.Git.Submodule.UpdateUrl(submodule.Name, submodule.Path, newUrl)
- gui.handleCredentialsPopup(err)
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{SUBMODULES}})
- })
- },
- })
-}
-
-func (gui *Gui) handleSubmoduleInit(submodule *models.SubmoduleConfig) error {
- return gui.WithWaitingStatus(gui.Tr.LcInitializingSubmoduleStatus, func() error {
- gui.logAction(gui.Tr.Actions.InitialiseSubmodule)
- err := gui.Git.Submodule.Init(submodule.Path)
- gui.handleCredentialsPopup(err)
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{SUBMODULES}})
- })
-}
-
-func (gui *Gui) forSubmodule(callback func(*models.SubmoduleConfig) error) func() error {
- return func() error {
- submodule := gui.getSelectedSubmodule()
- if submodule == nil {
- return nil
- }
-
- return callback(submodule)
- }
-}
-
-func (gui *Gui) handleBulkSubmoduleActionsMenu() error {
- menuItems := []*menuItem{
- {
- displayStrings: []string{gui.Tr.LcBulkInitSubmodules, style.FgGreen.Sprint(gui.Git.Submodule.BulkInitCmdObj().ToString())},
- onPress: func() error {
- return gui.WithWaitingStatus(gui.Tr.LcRunningCommand, func() error {
- gui.logAction(gui.Tr.Actions.BulkInitialiseSubmodules)
- err := gui.Git.Submodule.BulkInitCmdObj().Run()
- if err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{SUBMODULES}})
- })
- },
- },
- {
- displayStrings: []string{gui.Tr.LcBulkUpdateSubmodules, style.FgYellow.Sprint(gui.Git.Submodule.BulkUpdateCmdObj().ToString())},
- onPress: func() error {
- return gui.WithWaitingStatus(gui.Tr.LcRunningCommand, func() error {
- gui.logAction(gui.Tr.Actions.BulkUpdateSubmodules)
- if err := gui.Git.Submodule.BulkUpdateCmdObj().Run(); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{SUBMODULES}})
- })
- },
- },
- {
- displayStrings: []string{gui.Tr.LcSubmoduleStashAndReset, style.FgRed.Sprintf("git stash in each submodule && %s", gui.Git.Submodule.ForceBulkUpdateCmdObj().ToString())},
- onPress: func() error {
- return gui.WithWaitingStatus(gui.Tr.LcRunningCommand, func() error {
- gui.logAction(gui.Tr.Actions.BulkStashAndResetSubmodules)
- if err := gui.Git.Submodule.ResetSubmodules(gui.State.Submodules); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{SUBMODULES}})
- })
- },
- },
- {
- displayStrings: []string{gui.Tr.LcBulkDeinitSubmodules, style.FgRed.Sprint(gui.Git.Submodule.BulkDeinitCmdObj().ToString())},
- onPress: func() error {
- return gui.WithWaitingStatus(gui.Tr.LcRunningCommand, func() error {
- gui.logAction(gui.Tr.Actions.BulkDeinitialiseSubmodules)
- if err := gui.Git.Submodule.BulkDeinitCmdObj().Run(); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{SUBMODULES}})
- })
- },
- },
- }
-
- return gui.createMenu(gui.Tr.LcBulkSubmoduleOptions, menuItems, createMenuOptions{showCancel: true})
-}
-
-func (gui *Gui) handleUpdateSubmodule(submodule *models.SubmoduleConfig) error {
- return gui.WithWaitingStatus(gui.Tr.LcUpdatingSubmoduleStatus, func() error {
- gui.logAction(gui.Tr.Actions.UpdateSubmodule)
- err := gui.Git.Submodule.Update(submodule.Path)
- gui.handleCredentialsPopup(err)
-
- return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{SUBMODULES}})
- })
-}
diff --git a/pkg/gui/suggestions_panel.go b/pkg/gui/suggestions_panel.go
index c11145ded..d7b8b0d2b 100644
--- a/pkg/gui/suggestions_panel.go
+++ b/pkg/gui/suggestions_panel.go
@@ -15,17 +15,12 @@ func (gui *Gui) getSelectedSuggestionValue() string {
}
func (gui *Gui) getSelectedSuggestion() *types.Suggestion {
- selectedLine := gui.State.Panels.Suggestions.SelectedLineIdx
- if selectedLine == -1 {
- return nil
- }
-
- return gui.State.Suggestions[selectedLine]
+ return gui.State.Contexts.Suggestions.GetSelected()
}
func (gui *Gui) setSuggestions(suggestions []*types.Suggestion) {
gui.State.Suggestions = suggestions
- gui.State.Panels.Suggestions.SelectedLineIdx = 0
+ gui.State.Contexts.Suggestions.SetSelectedLineIdx(0)
_ = gui.resetOrigin(gui.Views.Suggestions)
_ = gui.State.Contexts.Suggestions.HandleRender()
}
diff --git a/pkg/gui/tags_panel.go b/pkg/gui/tags_panel.go
index efad33fbf..af09e4242 100644
--- a/pkg/gui/tags_panel.go
+++ b/pkg/gui/tags_panel.go
@@ -1,120 +1,22 @@
package gui
-import (
- "github.com/jesseduffield/lazygit/pkg/commands/models"
- "github.com/jesseduffield/lazygit/pkg/utils"
-)
-
-func (gui *Gui) getSelectedTag() *models.Tag {
- selectedLine := gui.State.Panels.Tags.SelectedLineIdx
- if selectedLine == -1 || len(gui.State.Tags) == 0 {
- return nil
- }
-
- return gui.State.Tags[selectedLine]
-}
-
-func (gui *Gui) handleCreateTag() error {
- // leaving commit SHA blank so that we're just creating the tag for the current commit
- return gui.createTagMenu("")
-}
+import "github.com/jesseduffield/lazygit/pkg/gui/types"
func (gui *Gui) tagsRenderToMain() error {
- var task updateTask
- tag := gui.getSelectedTag()
+ var task types.UpdateTask
+ tag := gui.State.Contexts.Tags.GetSelected()
if tag == nil {
- task = NewRenderStringTask("No tags")
+ task = types.NewRenderStringTask("No tags")
} else {
- cmdObj := gui.Git.Branch.GetGraphCmdObj(tag.Name)
- task = NewRunCommandTask(cmdObj.GetCmd())
+ cmdObj := gui.git.Branch.GetGraphCmdObj(tag.FullRefName())
+ task = types.NewRunCommandTask(cmdObj.GetCmd())
}
- return gui.refreshMainViews(refreshMainOpts{
- main: &viewUpdateOpts{
- title: "Tag",
- task: task,
+ return gui.c.RenderToMainViews(types.RefreshMainOpts{
+ Pair: gui.c.MainViewPairs().Normal,
+ Main: &types.ViewUpdateOpts{
+ Title: "Tag",
+ Task: task,
},
})
}
-
-// this is a controller: it can't access tags directly. Or can it? It should be able to get but not set. But that's exactly what I'm doing here, setting it. but through a mutator which encapsulates the event.
-func (gui *Gui) refreshTags() error {
- tags, err := gui.Git.Loaders.Tags.GetTags()
- if err != nil {
- return gui.surfaceError(err)
- }
-
- gui.State.Tags = tags
-
- return gui.postRefreshUpdate(gui.State.Contexts.Tags)
-}
-
-func (gui *Gui) withSelectedTag(f func(tag *models.Tag) error) func() error {
- return func() error {
- tag := gui.getSelectedTag()
- if tag == nil {
- return nil
- }
-
- return f(tag)
- }
-}
-
-// tag-specific handlers
-
-func (gui *Gui) handleCheckoutTag(tag *models.Tag) error {
- gui.logAction(gui.Tr.Actions.CheckoutTag)
- if err := gui.handleCheckoutRef(tag.Name, handleCheckoutRefOptions{}); err != nil {
- return err
- }
- return gui.pushContext(gui.State.Contexts.Branches)
-}
-
-func (gui *Gui) handleDeleteTag(tag *models.Tag) error {
- prompt := utils.ResolvePlaceholderString(
- gui.Tr.DeleteTagPrompt,
- map[string]string{
- "tagName": tag.Name,
- },
- )
-
- return gui.ask(askOpts{
- title: gui.Tr.DeleteTagTitle,
- prompt: prompt,
- handleConfirm: func() error {
- gui.logAction(gui.Tr.Actions.DeleteTag)
- if err := gui.Git.Tag.Delete(tag.Name); err != nil {
- return gui.surfaceError(err)
- }
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{COMMITS, TAGS}})
- },
- })
-}
-
-func (gui *Gui) handlePushTag(tag *models.Tag) error {
- title := utils.ResolvePlaceholderString(
- gui.Tr.PushTagTitle,
- map[string]string{
- "tagName": tag.Name,
- },
- )
-
- return gui.prompt(promptOpts{
- title: title,
- initialContent: "origin",
- findSuggestionsFunc: gui.getRemoteSuggestionsFunc(),
- handleConfirm: func(response string) error {
- return gui.WithWaitingStatus(gui.Tr.PushingTagStatus, func() error {
- gui.logAction(gui.Tr.Actions.PushTag)
- err := gui.Git.Tag.Push(response, tag.Name)
- gui.handleCredentialsPopup(err)
-
- return nil
- })
- },
- })
-}
-
-func (gui *Gui) handleCreateResetToTagMenu(tag *models.Tag) error {
- return gui.createResetMenu(tag.Name)
-}
diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go
index 4c268c14d..33aa09eb9 100644
--- a/pkg/gui/tasks_adapter.go
+++ b/pkg/gui/tasks_adapter.go
@@ -11,7 +11,7 @@ import (
func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error {
cmdStr := strings.Join(cmd.Args, " ")
- gui.Log.WithField(
+ gui.c.Log.WithField(
"command",
cmdStr,
).Debug("RunCommand")
@@ -24,19 +24,19 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
start := func() (*exec.Cmd, io.Reader) {
r, err := cmd.StdoutPipe()
if err != nil {
- gui.Log.Warn(err)
+ gui.c.Log.Error(err)
}
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
- gui.Log.Warn(err)
+ gui.c.Log.Error(err)
}
return cmd, r
}
if err := manager.NewTask(manager.NewCmdTask(start, prefix, height+oy+10, nil), cmdStr); err != nil {
- gui.Log.Warn(err)
+ gui.c.Log.Error(err)
}
return nil
@@ -64,6 +64,22 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
return nil
}
+func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX int, originY int) error {
+ manager := gui.getManager(view)
+
+ f := func(stop chan struct{}) error {
+ gui.setViewContent(view, str)
+ _ = view.SetOrigin(originX, originY)
+ return nil
+ }
+
+ if err := manager.NewTask(f, ""); err != nil {
+ return err
+ }
+
+ return nil
+}
+
func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) error {
manager := gui.getManager(view)
@@ -98,21 +114,15 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
},
func() {
// Need to check if the content of the view is well past the origin.
- // It would be better to use .ViewLinesHeight here (given it considers
- // wrapping) but when this function is called they haven't been written to yet.
- linesHeight := view.LinesHeight()
- _, height := view.Size()
+ linesHeight := view.ViewLinesHeight()
_, originY := view.Origin()
if linesHeight < originY {
- newOriginY := linesHeight - height
- if newOriginY < 0 {
- newOriginY = 0
- }
+ newOriginY := linesHeight
+
err := view.SetOrigin(0, newOriginY)
if err != nil {
panic(err)
}
-
}
view.FlushStaleCells()
diff --git a/pkg/gui/test_mode.go b/pkg/gui/test_mode.go
new file mode 100644
index 000000000..cd3bd83ba
--- /dev/null
+++ b/pkg/gui/test_mode.go
@@ -0,0 +1,124 @@
+package gui
+
+import (
+ "encoding/json"
+ "io/ioutil"
+ "log"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/jesseduffield/gocui"
+ integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+type IntegrationTest interface {
+ Run(guiAdapter *GuiDriver)
+}
+
+func (gui *Gui) handleTestMode(test integrationTypes.IntegrationTest) {
+ if test != nil {
+ go func() {
+ time.Sleep(time.Millisecond * 100)
+
+ test.Run(&GuiDriver{gui: gui})
+
+ gui.g.Update(func(*gocui.Gui) error {
+ return gocui.ErrQuit
+ })
+
+ time.Sleep(time.Second * 1)
+
+ log.Fatal("gocui should have already exited")
+ }()
+
+ go utils.Safe(func() {
+ time.Sleep(time.Second * 40)
+ log.Fatal("40 seconds is up, lazygit recording took too long to complete")
+ })
+ }
+
+ if Replaying() {
+ gui.g.RecordingConfig = gocui.RecordingConfig{
+ Speed: GetRecordingSpeed(),
+ Leeway: 100,
+ }
+
+ var err error
+ gui.g.Recording, err = LoadRecording()
+ if err != nil {
+ panic(err)
+ }
+
+ go utils.Safe(func() {
+ time.Sleep(time.Second * 40)
+ log.Fatal("40 seconds is up, lazygit recording took too long to complete")
+ })
+ }
+}
+
+func Headless() bool {
+ return os.Getenv("HEADLESS") != ""
+}
+
+// OLD integration test format stuff
+
+func Replaying() bool {
+ return os.Getenv("REPLAY_EVENTS_FROM") != ""
+}
+
+func RecordingEvents() bool {
+ return recordEventsTo() != ""
+}
+
+func recordEventsTo() string {
+ return os.Getenv("RECORD_EVENTS_TO")
+}
+
+func GetRecordingSpeed() float64 {
+ // humans are slow so this speeds things up.
+ speed := 1.0
+ envReplaySpeed := os.Getenv("SPEED")
+ if envReplaySpeed != "" {
+ var err error
+ speed, err = strconv.ParseFloat(envReplaySpeed, 64)
+ if err != nil {
+ log.Fatal(err)
+ }
+ }
+ return speed
+}
+
+func LoadRecording() (*gocui.Recording, error) {
+ path := os.Getenv("REPLAY_EVENTS_FROM")
+
+ data, err := ioutil.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ recording := &gocui.Recording{}
+
+ err = json.Unmarshal(data, &recording)
+ if err != nil {
+ return nil, err
+ }
+
+ return recording, nil
+}
+
+func SaveRecording(recording *gocui.Recording) error {
+ if !RecordingEvents() {
+ return nil
+ }
+
+ jsonEvents, err := json.Marshal(recording)
+ if err != nil {
+ return err
+ }
+
+ path := recordEventsTo()
+
+ return ioutil.WriteFile(path, jsonEvents, 0o600)
+}
diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go
new file mode 100644
index 000000000..ab8f6b2b8
--- /dev/null
+++ b/pkg/gui/types/common.go
@@ -0,0 +1,175 @@
+package types
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands/git_commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/common"
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/sasha-s/go-deadlock"
+ "gopkg.in/ozeidan/fuzzy-patricia.v3/patricia"
+)
+
+type HelperCommon struct {
+ *common.Common
+ IGuiCommon
+}
+
+type IGuiCommon interface {
+ IPopupHandler
+
+ LogAction(action string)
+ LogCommand(cmdStr string, isCommandLine bool)
+ // we call this when we want to refetch some models and render the result. Internally calls PostRefreshUpdate
+ Refresh(RefreshOptions) error
+ // we call this when we've changed something in the view model but not the actual model,
+ // e.g. expanding or collapsing a folder in a file view. Calling 'Refresh' in this
+ // case would be overkill, although refresh will internally call 'PostRefreshUpdate'
+ PostRefreshUpdate(Context) error
+ // this just re-renders the screen
+ Render()
+ // allows rendering to main views (i.e. the ones to the right of the side panel)
+ // in such a way that avoids concurrency issues when there are slow commands
+ // to display the output of
+ RenderToMainViews(opts RefreshMainOpts) error
+ // used purely for the sake of RenderToMainViews to provide the pair of main views we want to render to
+ MainViewPairs() MainViewPairs
+
+ // returns true if command completed successfully
+ RunSubprocess(cmdObj oscommands.ICmdObj) (bool, error)
+ RunSubprocessAndRefresh(oscommands.ICmdObj) error
+
+ PushContext(context Context, opts ...OnFocusOpts) error
+ PopContext() error
+ CurrentContext() Context
+ CurrentStaticContext() Context
+ IsCurrentContext(Context) bool
+ // enters search mode for the current view
+ OpenSearch()
+
+ GetAppState() *config.AppState
+ SaveAppState() error
+
+ // Runs the given function on the UI thread (this is for things like showing a popup asking a user for input).
+ // Only necessary to call if you're not already on the UI thread i.e. you're inside a goroutine.
+ // All controller handlers are executed on the UI thread.
+ OnUIThread(f func() error)
+}
+
+type IPopupHandler interface {
+ // Shows a popup with a (localized) "Error" caption and the given error message (in red).
+ //
+ // This is a convenience wrapper around Alert().
+ ErrorMsg(message string) error
+ Error(err error) error
+ // Shows a notification popup with the given title and message to the user.
+ //
+ // This is a convenience wrapper around Confirm(), thus the popup can be closed using both 'Enter' and 'ESC'.
+ Alert(title string, message string) error
+ // Shows a popup asking the user for confirmation.
+ Confirm(opts ConfirmOpts) error
+ // Shows a popup prompting the user for input.
+ Prompt(opts PromptOpts) error
+ WithLoaderPanel(message string, f func() error) error
+ WithWaitingStatus(message string, f func() error) error
+ Menu(opts CreateMenuOptions) error
+ Toast(message string)
+ GetPromptInput() string
+}
+
+type CreateMenuOptions struct {
+ Title string
+ Items []*MenuItem
+ HideCancel bool
+}
+
+type CreatePopupPanelOpts struct {
+ HasLoader bool
+ Editable bool
+ Title string
+ Prompt string
+ HandleConfirm func() error
+ HandleConfirmPrompt func(string) error
+ HandleClose func() error
+
+ FindSuggestionsFunc func(string) []*Suggestion
+ Mask bool
+}
+
+type ConfirmOpts struct {
+ Title string
+ Prompt string
+ HandleConfirm func() error
+ HandleClose func() error
+ HasLoader bool
+ FindSuggestionsFunc func(string) []*Suggestion
+ Editable bool
+ Mask bool
+}
+
+type PromptOpts struct {
+ Title string
+ InitialContent string
+ FindSuggestionsFunc func(string) []*Suggestion
+ HandleConfirm func(string) error
+ // CAPTURE THIS
+ HandleClose func() error
+ Mask bool
+}
+
+type MenuItem struct {
+ Label string
+
+ // alternative to Label. Allows specifying columns which will be auto-aligned
+ LabelColumns []string
+
+ OnPress func() error
+
+ // Only applies when Label is used
+ OpensMenu bool
+
+ // If Key is defined it allows the user to press the key to invoke the menu
+ // item, as opposed to having to navigate to it
+ Key Key
+
+ // The tooltip will be displayed upon highlighting the menu item
+ Tooltip string
+}
+
+type Model struct {
+ CommitFiles []*models.CommitFile
+ Files []*models.File
+ Submodules []*models.SubmoduleConfig
+ Branches []*models.Branch
+ Commits []*models.Commit
+ StashEntries []*models.StashEntry
+ SubCommits []*models.Commit
+ Remotes []*models.Remote
+
+ // FilteredReflogCommits are the ones that appear in the reflog panel.
+ // when in filtering mode we only include the ones that match the given path
+ FilteredReflogCommits []*models.Commit
+ // ReflogCommits are the ones used by the branches panel to obtain recency values
+ // if we're not in filtering mode, CommitFiles and FilteredReflogCommits will be
+ // one and the same
+ ReflogCommits []*models.Commit
+
+ BisectInfo *git_commands.BisectInfo
+ RemoteBranches []*models.RemoteBranch
+ Tags []*models.Tag
+
+ // for displaying suggestions while typing in a file name
+ FilesTrie *patricia.Trie
+}
+
+// if you add a new mutex here be sure to instantiate it. We're using pointers to
+// mutexes so that we can pass the mutexes to controllers.
+type Mutexes struct {
+ RefreshingFilesMutex *deadlock.Mutex
+ RefreshingStatusMutex *deadlock.Mutex
+ SyncMutex *deadlock.Mutex
+ LocalCommitsMutex *deadlock.Mutex
+ SubprocessMutex *deadlock.Mutex
+ PopupMutex *deadlock.Mutex
+ PtyMutex *deadlock.Mutex
+}
diff --git a/pkg/gui/types/common_commands.go b/pkg/gui/types/common_commands.go
new file mode 100644
index 000000000..74bfd603b
--- /dev/null
+++ b/pkg/gui/types/common_commands.go
@@ -0,0 +1,7 @@
+package types
+
+type CheckoutRefOptions struct {
+ WaitingStatus string
+ EnvVars []string
+ OnRefNotFound func(ref string) error
+}
diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go
new file mode 100644
index 000000000..e88d0d0f9
--- /dev/null
+++ b/pkg/gui/types/context.go
@@ -0,0 +1,183 @@
+package types
+
+import (
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
+ "github.com/sasha-s/go-deadlock"
+)
+
+type ContextKind int
+
+const (
+ // this is your files, branches, commits, contexts etc. They're all on the left hand side
+ // and you can cycle through them.
+ SIDE_CONTEXT ContextKind = iota
+ // This is either the left or right 'main' contexts that appear to the right of the side contexts
+ MAIN_CONTEXT
+ // A persistent popup is one that has its own identity e.g. the commit message context.
+ // When you open a popup over it, we'll let you return to it upon pressing escape
+ PERSISTENT_POPUP
+ // A temporary popup is one that could be used for various things (e.g. a generic menu or confirmation popup).
+ // Because we re-use these contexts, they're temporary in that you can't return to them after you've switched from them
+ // to some other context, because the context you switched to might actually be the same context but rendering different content.
+ // We should really be able to spawn new contexts for menus/prompts so that we can actually return to old ones.
+ TEMPORARY_POPUP
+ // This contains the command log, underneath the main contexts.
+ EXTRAS_CONTEXT
+ // only used by the one global context, purely for the sake of defining keybindings globally
+ GLOBAL_CONTEXT
+ // a display context only renders a view. It has no keybindings associated and
+ // it cannot receive focus.
+ DISPLAY_CONTEXT
+)
+
+type ParentContexter interface {
+ SetParentContext(Context)
+ // we return a bool here to tell us whether or not the returned value just wraps a nil
+ GetParentContext() (Context, bool)
+}
+
+type IBaseContext interface {
+ HasKeybindings
+ ParentContexter
+
+ GetKind() ContextKind
+ GetViewName() string
+ GetView() *gocui.View
+ GetViewTrait() IViewTrait
+ GetWindowName() string
+ SetWindowName(string)
+ GetKey() ContextKey
+ IsFocusable() bool
+ // if a context is transient, then it only appears via some keybinding on another
+ // context. Until we add support for having multiple of the same context, no two
+ // of the same transient context can appear at once meaning one might be 'stolen'
+ // from another window.
+ IsTransient() bool
+ // this tells us if the view's bounds are determined by its window or if they're
+ // determined independently.
+ HasControlledBounds() bool
+
+ // returns the desired title for the view upon activation. If there is no desired title (returns empty string), then
+ // no title will be set
+ Title() string
+
+ GetOptionsMap() map[string]string
+
+ AddKeybindingsFn(KeybindingsFn)
+ AddMouseKeybindingsFn(MouseKeybindingsFn)
+
+ // This is a bit of a hack at the moment: we currently only set an onclick function so that
+ // our list controller can come along and wrap it in a list-specific click handler.
+ // We'll need to think of a better way to do this.
+ AddOnClickFn(func() error)
+}
+
+type Context interface {
+ IBaseContext
+
+ HandleFocus(opts OnFocusOpts) error
+ HandleFocusLost(opts OnFocusLostOpts) error
+ HandleRender() error
+ HandleRenderToMain() error
+}
+
+type IListContext interface {
+ Context
+
+ GetSelectedItemId() string
+
+ GetList() IList
+
+ OnSearchSelect(selectedLineIdx int) error
+ FocusLine()
+}
+
+type IPatchExplorerContext interface {
+ Context
+
+ GetState() *patch_exploring.State
+ SetState(*patch_exploring.State)
+ GetIncludedLineIndices() []int
+ RenderAndFocus(isFocused bool) error
+ Render(isFocused bool) error
+ Focus() error
+ GetContentToRender(isFocused bool) string
+ NavigateTo(isFocused bool, selectedLineIdx int) error
+ GetMutex() *deadlock.Mutex
+}
+
+type IViewTrait interface {
+ FocusPoint(yIdx int)
+ SetViewPortContent(content string)
+ SetContent(content string)
+ SetFooter(value string)
+ SetOriginX(value int)
+ ViewPortYBounds() (int, int)
+ ScrollLeft()
+ ScrollRight()
+ ScrollUp(value int)
+ ScrollDown(value int)
+ PageDelta() int
+ SelectedLineIdx() int
+ SetHighlight(bool)
+}
+
+type OnFocusOpts struct {
+ ClickedWindowName string
+ ClickedViewLineIdx int
+}
+
+type OnFocusLostOpts struct {
+ NewContextKey ContextKey
+}
+
+type ContextKey string
+
+type KeybindingsOpts struct {
+ GetKey func(key string) Key
+ Config config.KeybindingConfig
+ Guards KeybindingGuards
+}
+
+type (
+ KeybindingsFn func(opts KeybindingsOpts) []*Binding
+ MouseKeybindingsFn func(opts KeybindingsOpts) []*gocui.ViewMouseBinding
+)
+
+type HasKeybindings interface {
+ GetKeybindings(opts KeybindingsOpts) []*Binding
+ GetMouseKeybindings(opts KeybindingsOpts) []*gocui.ViewMouseBinding
+ GetOnClick() func() error
+}
+
+type IController interface {
+ HasKeybindings
+ Context() Context
+}
+
+type IList interface {
+ IListCursor
+ Len() int
+}
+
+type IListCursor interface {
+ GetSelectedLineIdx() int
+ SetSelectedLineIdx(value int)
+ MoveSelectedLine(delta int)
+ RefreshSelectedIdx()
+}
+
+type IListPanelState interface {
+ SetSelectedLineIdx(int)
+ GetSelectedLineIdx() int
+}
+
+type ListItem interface {
+ // ID is a SHA when the item is a commit, a filename when the item is a file, 'stash@{4}' when it's a stash entry, 'my_branch' when it's a branch
+ ID() string
+
+ // Description is something we would show in a message e.g. '123as14: push blah' for a commit
+ Description() string
+}
diff --git a/pkg/gui/types/keybindings.go b/pkg/gui/types/keybindings.go
new file mode 100644
index 000000000..c945ce3ab
--- /dev/null
+++ b/pkg/gui/types/keybindings.go
@@ -0,0 +1,31 @@
+package types
+
+import "github.com/jesseduffield/gocui"
+
+type Key interface{} // FIXME: find out how to get `gocui.Key | rune`
+
+// Binding - a keybinding mapping a key and modifier to a handler. The keypress
+// is only handled if the given view has focus, or handled globally if the view
+// is ""
+type Binding struct {
+ ViewName string
+ Handler func() error
+ Key Key
+ Modifier gocui.Modifier
+ Description string
+ Alternative string
+ Tag string // e.g. 'navigation'. Used for grouping things in the cheatsheet
+ OpensMenu bool
+
+ // to be displayed if the keybinding is highlighted from within a menu
+ Tooltip string
+}
+
+// A guard is a decorator which checks something before executing a handler
+// and potentially early-exits if some precondition hasn't been met.
+type Guard func(func() error) func() error
+
+type KeybindingGuards struct {
+ OutsideFilterMode Guard
+ NoPopupPanel Guard
+}
diff --git a/pkg/gui/types/modes.go b/pkg/gui/types/modes.go
new file mode 100644
index 000000000..ba135de63
--- /dev/null
+++ b/pkg/gui/types/modes.go
@@ -0,0 +1,13 @@
+package types
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking"
+ "github.com/jesseduffield/lazygit/pkg/gui/modes/diffing"
+ "github.com/jesseduffield/lazygit/pkg/gui/modes/filtering"
+)
+
+type Modes struct {
+ Filtering filtering.Filtering
+ CherryPicking *cherrypicking.CherryPicking
+ Diffing diffing.Diffing
+}
diff --git a/pkg/gui/types/ref.go b/pkg/gui/types/ref.go
new file mode 100644
index 000000000..e83d91b65
--- /dev/null
+++ b/pkg/gui/types/ref.go
@@ -0,0 +1,8 @@
+package types
+
+type Ref interface {
+ FullRefName() string
+ RefName() string
+ ParentRefName() string
+ Description() string
+}
diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go
new file mode 100644
index 000000000..475b90942
--- /dev/null
+++ b/pkg/gui/types/refresh.go
@@ -0,0 +1,37 @@
+package types
+
+// models/views that we can refresh
+type RefreshableView int
+
+const (
+ COMMITS RefreshableView = iota
+ REBASE_COMMITS
+ BRANCHES
+ FILES
+ STASH
+ REFLOG
+ TAGS
+ REMOTES
+ STATUS
+ SUBMODULES
+ STAGING
+ PATCH_BUILDING
+ MERGE_CONFLICTS
+ COMMIT_FILES
+ // not actually a view. Will refactor this later
+ BISECT_INFO
+)
+
+type RefreshMode int
+
+const (
+ SYNC RefreshMode = iota // wait until everything is done before returning
+ ASYNC // return immediately, allowing each independent thing to update itself
+ BLOCK_UI // wrap code in an update call to ensure UI updates all at once and keybindings aren't executed till complete
+)
+
+type RefreshOptions struct {
+ Then func()
+ Scope []RefreshableView // e.g. []int{COMMITS, BRANCHES}. Leave empty to refresh everything
+ Mode RefreshMode // one of SYNC (default), ASYNC, and BLOCK_UI
+}
diff --git a/pkg/gui/types/rendering.go b/pkg/gui/types/rendering.go
new file mode 100644
index 000000000..b4e7bedcb
--- /dev/null
+++ b/pkg/gui/types/rendering.go
@@ -0,0 +1,95 @@
+package types
+
+import (
+ "os/exec"
+)
+
+type MainContextPair struct {
+ Main Context
+ Secondary Context
+}
+
+func NewMainContextPair(main Context, secondary Context) MainContextPair {
+ return MainContextPair{Main: main, Secondary: secondary}
+}
+
+type MainViewPairs struct {
+ Normal MainContextPair
+ MergeConflicts MainContextPair
+ Staging MainContextPair
+ PatchBuilding MainContextPair
+}
+
+type ViewUpdateOpts struct {
+ Title string
+
+ Task UpdateTask
+}
+
+type RefreshMainOpts struct {
+ Pair MainContextPair
+ Main *ViewUpdateOpts
+ Secondary *ViewUpdateOpts
+}
+
+type UpdateTask interface {
+ IsUpdateTask()
+}
+
+type RenderStringTask struct {
+ Str string
+}
+
+func (t *RenderStringTask) IsUpdateTask() {}
+
+func NewRenderStringTask(str string) *RenderStringTask {
+ return &RenderStringTask{Str: str}
+}
+
+type RenderStringWithoutScrollTask struct {
+ Str string
+}
+
+func (t *RenderStringWithoutScrollTask) IsUpdateTask() {}
+
+func NewRenderStringWithoutScrollTask(str string) *RenderStringWithoutScrollTask {
+ return &RenderStringWithoutScrollTask{Str: str}
+}
+
+type RenderStringWithScrollTask struct {
+ Str string
+ OriginX int
+ OriginY int
+}
+
+func (t *RenderStringWithScrollTask) IsUpdateTask() {}
+
+func NewRenderStringWithScrollTask(str string, originX int, originY int) *RenderStringWithScrollTask {
+ return &RenderStringWithScrollTask{Str: str, OriginX: originX, OriginY: originY}
+}
+
+type RunCommandTask struct {
+ Cmd *exec.Cmd
+ Prefix string
+}
+
+func (t *RunCommandTask) IsUpdateTask() {}
+
+func NewRunCommandTask(cmd *exec.Cmd) *RunCommandTask {
+ return &RunCommandTask{Cmd: cmd}
+}
+
+func NewRunCommandTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunCommandTask {
+ return &RunCommandTask{Cmd: cmd, Prefix: prefix}
+}
+
+type RunPtyTask struct {
+ Cmd *exec.Cmd
+ Prefix string
+}
+
+func (t *RunPtyTask) IsUpdateTask() {}
+
+func NewRunPtyTask(cmd *exec.Cmd) *RunPtyTask {
+ return &RunPtyTask{Cmd: cmd}
+}
diff --git a/pkg/gui/undoing.go b/pkg/gui/undoing.go
deleted file mode 100644
index 72a4ef302..000000000
--- a/pkg/gui/undoing.go
+++ /dev/null
@@ -1,206 +0,0 @@
-package gui
-
-import (
- "github.com/jesseduffield/lazygit/pkg/commands/types/enums"
- "github.com/jesseduffield/lazygit/pkg/utils"
-)
-
-// Quick summary of how this all works:
-// when you want to undo or redo, we start from the top of the reflog and work
-// down until we've reached the last user-initiated reflog entry that hasn't already been undone
-// we then do the reverse of what that reflog describes.
-// When we do this, we create a new reflog entry, and tag it as either an undo or redo
-// Then, next time we want to undo, we'll use those entries to know which user-initiated
-// actions we can skip. E.g. if I do do three things, A, B, and C, and hit undo twice,
-// the reflog will read UUCBA, and when I read the first two undos, I know to skip the following
-// two user actions, meaning we end up undoing reflog entry C. Redoing works in a similar way.
-
-type ReflogActionKind int
-
-const (
- CHECKOUT ReflogActionKind = iota
- COMMIT
- REBASE
- CURRENT_REBASE
-)
-
-type reflogAction struct {
- kind ReflogActionKind
- from string
- to string
-}
-
-// Here we're going through the reflog and maintaining a counter that represents how many
-// undos/redos/user actions we've seen. when we hit a user action we call the callback specifying
-// what the counter is up to and the nature of the action.
-// If we find ourselves mid-rebase, we just return because undo/redo mid rebase
-// requires knowledge of previous TODO file states, which you can't just get from the reflog.
-// Though we might support this later, hence the use of the CURRENT_REBASE action kind.
-func (gui *Gui) parseReflogForActions(onUserAction func(counter int, action reflogAction) (bool, error)) error {
- counter := 0
- reflogCommits := gui.State.FilteredReflogCommits
- rebaseFinishCommitSha := ""
- var action *reflogAction
- for reflogCommitIdx, reflogCommit := range reflogCommits {
- action = nil
-
- prevCommitSha := ""
- if len(reflogCommits)-1 >= reflogCommitIdx+1 {
- prevCommitSha = reflogCommits[reflogCommitIdx+1].Sha
- }
-
- if rebaseFinishCommitSha == "" {
- if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^\[lazygit undo\]`); ok {
- counter++
- } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^\[lazygit redo\]`); ok {
- counter--
- } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase -i \(abort\)|^rebase -i \(finish\)`); ok {
- rebaseFinishCommitSha = reflogCommit.Sha
- } else if ok, match := utils.FindStringSubmatch(reflogCommit.Name, `^checkout: moving from ([\S]+) to ([\S]+)`); ok {
- action = &reflogAction{kind: CHECKOUT, from: match[1], to: match[2]}
- } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^commit|^reset: moving to|^pull`); ok {
- action = &reflogAction{kind: COMMIT, from: prevCommitSha, to: reflogCommit.Sha}
- } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase -i \(start\)`); ok {
- // if we're here then we must be currently inside an interactive rebase
- action = &reflogAction{kind: CURRENT_REBASE, from: prevCommitSha}
- }
- } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase -i \(start\)`); ok {
- action = &reflogAction{kind: REBASE, from: prevCommitSha, to: rebaseFinishCommitSha}
- rebaseFinishCommitSha = ""
- }
-
- if action != nil {
- if action.kind != CURRENT_REBASE && action.from == action.to {
- // if we're going from one place to the same place we'll ignore the action.
- continue
- }
- ok, err := onUserAction(counter, *action)
- if ok {
- return err
- }
- counter--
- }
- }
- return nil
-}
-
-func (gui *Gui) reflogUndo() error {
- undoEnvVars := []string{"GIT_REFLOG_ACTION=[lazygit undo]"}
- undoingStatus := gui.Tr.UndoingStatus
-
- if gui.Git.Status.WorkingTreeState() == enums.REBASE_MODE_REBASING {
- return gui.createErrorPanel(gui.Tr.LcCantUndoWhileRebasing)
- }
-
- return gui.parseReflogForActions(func(counter int, action reflogAction) (bool, error) {
- if counter != 0 {
- return false, nil
- }
-
- switch action.kind {
- case COMMIT, REBASE:
- gui.logAction(gui.Tr.Actions.Undo)
- return true, gui.handleHardResetWithAutoStash(action.from, handleHardResetWithAutoStashOptions{
- EnvVars: undoEnvVars,
- WaitingStatus: undoingStatus,
- })
- case CHECKOUT:
- gui.logAction(gui.Tr.Actions.Undo)
- return true, gui.handleCheckoutRef(action.from, handleCheckoutRefOptions{
- EnvVars: undoEnvVars,
- WaitingStatus: undoingStatus,
- })
- case CURRENT_REBASE:
- // do nothing
- }
-
- gui.Log.Error("didn't match on the user action when trying to undo")
- return true, nil
- })
-}
-
-func (gui *Gui) reflogRedo() error {
- redoEnvVars := []string{"GIT_REFLOG_ACTION=[lazygit redo]"}
- redoingStatus := gui.Tr.RedoingStatus
-
- if gui.Git.Status.WorkingTreeState() == enums.REBASE_MODE_REBASING {
- return gui.createErrorPanel(gui.Tr.LcCantRedoWhileRebasing)
- }
-
- return gui.parseReflogForActions(func(counter int, action reflogAction) (bool, error) {
- // if we're redoing and the counter is zero, we just return
- if counter == 0 {
- return true, nil
- } else if counter > 1 {
- return false, nil
- }
-
- switch action.kind {
- case COMMIT, REBASE:
- gui.logAction(gui.Tr.Actions.Redo)
- return true, gui.handleHardResetWithAutoStash(action.to, handleHardResetWithAutoStashOptions{
- EnvVars: redoEnvVars,
- WaitingStatus: redoingStatus,
- })
- case CHECKOUT:
- gui.logAction(gui.Tr.Actions.Redo)
- return true, gui.handleCheckoutRef(action.to, handleCheckoutRefOptions{
- EnvVars: redoEnvVars,
- WaitingStatus: redoingStatus,
- })
- case CURRENT_REBASE:
- // do nothing
- }
-
- gui.Log.Error("didn't match on the user action when trying to redo")
- return true, nil
- })
-}
-
-type handleHardResetWithAutoStashOptions struct {
- WaitingStatus string
- EnvVars []string
-}
-
-// only to be used in the undo flow for now
-func (gui *Gui) handleHardResetWithAutoStash(commitSha string, options handleHardResetWithAutoStashOptions) error {
- reset := func() error {
- if err := gui.resetToRef(commitSha, "hard", options.EnvVars); err != nil {
- return gui.surfaceError(err)
- }
- return nil
- }
-
- // if we have any modified tracked files we need to ask the user if they want us to stash for them
- dirtyWorkingTree := len(gui.trackedFiles()) > 0 || len(gui.stagedFiles()) > 0
- if dirtyWorkingTree {
- // offer to autostash changes
- return gui.ask(askOpts{
- title: gui.Tr.AutoStashTitle,
- prompt: gui.Tr.AutoStashPrompt,
- handleConfirm: func() error {
- return gui.WithWaitingStatus(options.WaitingStatus, func() error {
- if err := gui.Git.Stash.Save(gui.Tr.StashPrefix + commitSha); err != nil {
- return gui.surfaceError(err)
- }
- if err := reset(); err != nil {
- return err
- }
-
- err := gui.Git.Stash.Pop(0)
- if err := gui.refreshSidePanels(refreshOptions{}); err != nil {
- return err
- }
- if err != nil {
- return gui.surfaceError(err)
- }
- return nil
- })
- },
- })
- }
-
- return gui.WithWaitingStatus(options.WaitingStatus, func() error {
- return reset()
- })
-}
diff --git a/pkg/gui/updates.go b/pkg/gui/updates.go
index cce723c4f..93231e4f0 100644
--- a/pkg/gui/updates.go
+++ b/pkg/gui/updates.go
@@ -1,16 +1,22 @@
package gui
import (
- "fmt"
-
"github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
)
func (gui *Gui) showUpdatePrompt(newVersion string) error {
- return gui.ask(askOpts{
- title: "New version available!",
- prompt: fmt.Sprintf("Download version %s? (enter/esc)", newVersion),
- handleConfirm: func() error {
+ message := utils.ResolvePlaceholderString(
+ gui.Tr.UpdateAvailable, map[string]string{
+ "newVersion": newVersion,
+ },
+ )
+
+ return gui.c.Confirm(types.ConfirmOpts{
+ Title: gui.Tr.UpdateAvailableTitle,
+ Prompt: message,
+ HandleConfirm: func() error {
gui.startUpdating(newVersion)
return nil
},
@@ -19,10 +25,10 @@ func (gui *Gui) showUpdatePrompt(newVersion string) error {
func (gui *Gui) onUserUpdateCheckFinish(newVersion string, err error) error {
if err != nil {
- return gui.surfaceError(err)
+ return gui.c.Error(err)
}
if newVersion == "" {
- return gui.createErrorPanel("New version not found")
+ return gui.c.ErrorMsg(gui.Tr.FailedToRetrieveLatestVersionErr)
}
return gui.showUpdatePrompt(newVersion)
}
@@ -30,13 +36,13 @@ func (gui *Gui) onUserUpdateCheckFinish(newVersion string, err error) error {
func (gui *Gui) onBackgroundUpdateCheckFinish(newVersion string, err error) error {
if err != nil {
// ignoring the error for now so that I'm not annoying users
- gui.Log.Error(err.Error())
+ gui.c.Log.Error(err.Error())
return nil
}
if newVersion == "" {
return nil
}
- if gui.UserConfig.Update.Method == "background" {
+ if gui.c.UserConfig.Update.Method == "background" {
gui.startUpdating(newVersion)
return nil
}
@@ -45,29 +51,34 @@ func (gui *Gui) onBackgroundUpdateCheckFinish(newVersion string, err error) erro
func (gui *Gui) startUpdating(newVersion string) {
gui.State.Updating = true
- statusId := gui.statusManager.addWaitingStatus("updating")
+ statusId := gui.statusManager.addWaitingStatus(gui.Tr.UpdateInProgressWaitingStatus)
gui.Updater.Update(newVersion, func(err error) error { return gui.onUpdateFinish(statusId, err) })
}
func (gui *Gui) onUpdateFinish(statusId int, err error) error {
gui.State.Updating = false
gui.statusManager.removeStatus(statusId)
- gui.OnUIThread(func() error {
+ gui.c.OnUIThread(func() error {
_ = gui.renderString(gui.Views.AppStatus, "")
if err != nil {
- return gui.createErrorPanel("Update failed: " + err.Error())
+ errMessage := utils.ResolvePlaceholderString(
+ gui.Tr.UpdateFailedErr, map[string]string{
+ "errMessage": err.Error(),
+ },
+ )
+ return gui.c.ErrorMsg(errMessage)
}
- return nil
+ return gui.c.Alert(gui.Tr.UpdateCompletedTitle, gui.Tr.UpdateCompleted)
})
return nil
}
func (gui *Gui) createUpdateQuitConfirmation() error {
- return gui.ask(askOpts{
- title: "Currently Updating",
- prompt: "An update is in progress. Are you sure you want to quit?",
- handleConfirm: func() error {
+ return gui.c.Confirm(types.ConfirmOpts{
+ Title: gui.Tr.ConfirmQuitDuringUpdateTitle,
+ Prompt: gui.Tr.ConfirmQuitDuringUpdate,
+ HandleConfirm: func() error {
return gocui.ErrQuit
},
})
diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go
index 300571ad3..40bc2fa02 100644
--- a/pkg/gui/view_helpers.go
+++ b/pkg/gui/view_helpers.go
@@ -2,197 +2,14 @@ package gui
import (
"fmt"
- "sort"
- "strings"
- "sync"
"github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/spkg/bom"
)
-func (gui *Gui) getCyclableWindows() []string {
- return []string{"status", "files", "branches", "commits", "stash"}
-}
-
-// models/views that we can refresh
-type RefreshableView int
-
-const (
- COMMITS RefreshableView = iota
- BRANCHES
- FILES
- STASH
- REFLOG
- TAGS
- REMOTES
- STATUS
- SUBMODULES
- // not actually a view. Will refactor this later
- BISECT_INFO
-)
-
-func getScopeNames(scopes []RefreshableView) []string {
- scopeNameMap := map[RefreshableView]string{
- COMMITS: "commits",
- BRANCHES: "branches",
- FILES: "files",
- SUBMODULES: "submodules",
- STASH: "stash",
- REFLOG: "reflog",
- TAGS: "tags",
- REMOTES: "remotes",
- STATUS: "status",
- }
-
- scopeNames := make([]string, len(scopes))
- for i, scope := range scopes {
- scopeNames[i] = scopeNameMap[scope]
- }
-
- return scopeNames
-}
-
-func getModeName(mode RefreshMode) string {
- switch mode {
- case SYNC:
- return "sync"
- case ASYNC:
- return "async"
- case BLOCK_UI:
- return "block-ui"
- default:
- return "unknown mode"
- }
-}
-
-type RefreshMode int
-
-const (
- SYNC RefreshMode = iota // wait until everything is done before returning
- ASYNC // return immediately, allowing each independent thing to update itself
- BLOCK_UI // wrap code in an update call to ensure UI updates all at once and keybindings aren't executed till complete
-)
-
-type refreshOptions struct {
- then func()
- scope []RefreshableView // e.g. []int{COMMITS, BRANCHES}. Leave empty to refresh everything
- mode RefreshMode // one of SYNC (default), ASYNC, and BLOCK_UI
-}
-
-func arrToMap(arr []RefreshableView) map[RefreshableView]bool {
- output := map[RefreshableView]bool{}
- for _, el := range arr {
- output[el] = true
- }
- return output
-}
-
-func (gui *Gui) refreshSidePanels(options refreshOptions) error {
- if options.scope == nil {
- gui.Log.Infof(
- "refreshing all scopes in %s mode",
- getModeName(options.mode),
- )
- } else {
- gui.Log.Infof(
- "refreshing the following scopes in %s mode: %s",
- getModeName(options.mode),
- strings.Join(getScopeNames(options.scope), ","),
- )
- }
-
- wg := sync.WaitGroup{}
-
- f := func() {
- var scopeMap map[RefreshableView]bool
- if len(options.scope) == 0 {
- scopeMap = arrToMap([]RefreshableView{COMMITS, BRANCHES, FILES, STASH, REFLOG, TAGS, REMOTES, STATUS, BISECT_INFO})
- } else {
- scopeMap = arrToMap(options.scope)
- }
-
- if scopeMap[COMMITS] || scopeMap[BRANCHES] || scopeMap[REFLOG] || scopeMap[BISECT_INFO] {
- wg.Add(1)
- func() {
- if options.mode == ASYNC {
- go utils.Safe(func() { gui.refreshCommits() })
- } else {
- gui.refreshCommits()
- }
- wg.Done()
- }()
- }
-
- if scopeMap[FILES] || scopeMap[SUBMODULES] {
- wg.Add(1)
- func() {
- if options.mode == ASYNC {
- go utils.Safe(func() { _ = gui.refreshFilesAndSubmodules() })
- } else {
- _ = gui.refreshFilesAndSubmodules()
- }
- wg.Done()
- }()
- }
-
- if scopeMap[STASH] {
- wg.Add(1)
- func() {
- if options.mode == ASYNC {
- go utils.Safe(func() { _ = gui.refreshStashEntries() })
- } else {
- _ = gui.refreshStashEntries()
- }
- wg.Done()
- }()
- }
-
- if scopeMap[TAGS] {
- wg.Add(1)
- func() {
- if options.mode == ASYNC {
- go utils.Safe(func() { _ = gui.refreshTags() })
- } else {
- _ = gui.refreshTags()
- }
- wg.Done()
- }()
- }
-
- if scopeMap[REMOTES] {
- wg.Add(1)
- func() {
- if options.mode == ASYNC {
- go utils.Safe(func() { _ = gui.refreshRemotes() })
- } else {
- _ = gui.refreshRemotes()
- }
- wg.Done()
- }()
- }
-
- wg.Wait()
-
- gui.refreshStatus()
-
- if options.then != nil {
- options.then()
- }
- }
-
- if options.mode == BLOCK_UI {
- gui.OnUIThread(func() error {
- f()
- return nil
- })
- } else {
- f()
- }
-
- return nil
-}
-
func (gui *Gui) resetOrigin(v *gocui.View) error {
_ = v.SetCursor(0, 0)
return v.SetOrigin(0, 0)
@@ -219,19 +36,6 @@ func (gui *Gui) renderString(view *gocui.View, s string) error {
return nil
}
-func (gui *Gui) optionsMapToString(optionsMap map[string]string) string {
- optionsArray := make([]string, 0)
- for key, description := range optionsMap {
- optionsArray = append(optionsArray, key+": "+description)
- }
- sort.Strings(optionsArray)
- return strings.Join(optionsArray, ", ")
-}
-
-func (gui *Gui) renderOptionsMap(optionsMap map[string]string) {
- _ = gui.renderString(gui.Views.Options, gui.optionsMapToString(optionsMap))
-}
-
func (gui *Gui) currentViewName() string {
currentView := gui.g.CurrentView()
if currentView == nil {
@@ -245,131 +49,140 @@ func (gui *Gui) resizeCurrentPopupPanel() error {
if v == nil {
return nil
}
- if gui.isPopupPanel(v.Name()) {
+
+ if v == gui.Views.Menu {
+ gui.resizeMenu()
+ } else if v == gui.Views.Confirmation || v == gui.Views.Suggestions {
+ gui.resizeConfirmationPanel()
+ } else if gui.isPopupPanel(v.Name()) {
return gui.resizePopupPanel(v, v.Buffer())
}
+
return nil
}
func (gui *Gui) resizePopupPanel(v *gocui.View, content string) error {
- // If the confirmation panel is already displayed, just resize the width,
- // otherwise continue
x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(v.Wrap, content)
- vx0, vy0, vx1, vy1 := v.Dimensions()
- if vx0 == x0 && vy0 == y0 && vx1 == x1 && vy1 == y1 {
- return nil
- }
_, err := gui.g.SetView(v.Name(), x0, y0, x1, y1, 0)
return err
}
-func (gui *Gui) changeSelectedLine(panelState IListPanelState, total int, change int) {
- // TODO: find out why we're doing this
- line := panelState.GetSelectedLineIdx()
+func (gui *Gui) resizeMenu() {
+ itemCount := gui.State.Contexts.Menu.GetList().Len()
+ offset := 3
+ panelWidth := gui.getConfirmationPanelWidth()
+ x0, y0, x1, y1 := gui.getConfirmationPanelDimensionsForContentHeight(panelWidth, itemCount+offset)
+ menuBottom := y1 - offset
+ _, _ = gui.g.SetView(gui.Views.Menu.Name(), x0, y0, x1, menuBottom, 0)
- if line == -1 {
- return
- }
- var newLine int
- if line+change < 0 {
- newLine = 0
- } else if line+change >= total {
- newLine = total - 1
- } else {
- newLine = line + change
- }
-
- panelState.SetSelectedLineIdx(newLine)
+ tooltipTop := menuBottom + 1
+ tooltipHeight := gui.getMessageHeight(true, gui.State.Contexts.Menu.GetSelected().Tooltip, panelWidth) + 2 // plus 2 for the frame
+ _, _ = gui.g.SetView(gui.Views.Tooltip.Name(), x0, tooltipTop, x1, tooltipTop+tooltipHeight-1, 0)
}
-func (gui *Gui) refreshSelectedLine(panelState IListPanelState, total int) {
- line := panelState.GetSelectedLineIdx()
-
- if line == -1 && total > 0 {
- panelState.SetSelectedLineIdx(0)
- } else if total-1 < line {
- panelState.SetSelectedLineIdx(total - 1)
+func (gui *Gui) resizeConfirmationPanel() {
+ suggestionsViewHeight := 0
+ if gui.Views.Suggestions.Visible {
+ suggestionsViewHeight = 11
}
-}
+ panelWidth := gui.getConfirmationPanelWidth()
+ prompt := gui.Views.Confirmation.Buffer()
+ panelHeight := gui.getMessageHeight(true, prompt, panelWidth) + suggestionsViewHeight
+ x0, y0, x1, y1 := gui.getConfirmationPanelDimensionsAux(panelWidth, panelHeight)
+ confirmationViewBottom := y1 - suggestionsViewHeight
+ _, _ = gui.g.SetView(gui.Views.Confirmation.Name(), x0, y0, x1, confirmationViewBottom, 0)
-func (gui *Gui) renderDisplayStrings(v *gocui.View, displayStrings [][]string) {
- list := utils.RenderDisplayStrings(displayStrings)
- v.SetContent(list)
-}
-
-func (gui *Gui) renderDisplayStringsAtPos(v *gocui.View, y int, displayStrings [][]string) {
- list := utils.RenderDisplayStrings(displayStrings)
- v.OverwriteLines(y, list)
+ suggestionsViewTop := confirmationViewBottom + 1
+ _, _ = gui.g.SetView(gui.Views.Suggestions.Name(), x0, suggestionsViewTop, x1, suggestionsViewTop+suggestionsViewHeight, 0)
}
func (gui *Gui) globalOptionsMap() map[string]string {
- keybindingConfig := gui.UserConfig.Keybinding
+ keybindingConfig := gui.c.UserConfig.Keybinding
return map[string]string{
- fmt.Sprintf("%s/%s", gui.getKeyDisplay(keybindingConfig.Universal.ScrollUpMain), gui.getKeyDisplay(keybindingConfig.Universal.ScrollDownMain)): gui.Tr.LcScroll,
- fmt.Sprintf("%s %s %s %s", gui.getKeyDisplay(keybindingConfig.Universal.PrevBlock), gui.getKeyDisplay(keybindingConfig.Universal.NextBlock), gui.getKeyDisplay(keybindingConfig.Universal.PrevItem), gui.getKeyDisplay(keybindingConfig.Universal.NextItem)): gui.Tr.LcNavigate,
- gui.getKeyDisplay(keybindingConfig.Universal.Return): gui.Tr.LcCancel,
- gui.getKeyDisplay(keybindingConfig.Universal.Quit): gui.Tr.LcQuit,
- gui.getKeyDisplay(keybindingConfig.Universal.OptionMenu): gui.Tr.LcMenu,
- fmt.Sprintf("%s-%s", gui.getKeyDisplay(keybindingConfig.Universal.JumpToBlock[0]), gui.getKeyDisplay(keybindingConfig.Universal.JumpToBlock[len(keybindingConfig.Universal.JumpToBlock)-1])): gui.Tr.LcJump,
- fmt.Sprintf("%s/%s", gui.getKeyDisplay(keybindingConfig.Universal.ScrollLeft), gui.getKeyDisplay(keybindingConfig.Universal.ScrollRight)): gui.Tr.LcScrollLeftRight,
+ fmt.Sprintf("%s/%s", keybindings.Label(keybindingConfig.Universal.ScrollUpMain), keybindings.Label(keybindingConfig.Universal.ScrollDownMain)): gui.c.Tr.LcScroll,
+ fmt.Sprintf("%s %s %s %s", keybindings.Label(keybindingConfig.Universal.PrevBlock), keybindings.Label(keybindingConfig.Universal.NextBlock), keybindings.Label(keybindingConfig.Universal.PrevItem), keybindings.Label(keybindingConfig.Universal.NextItem)): gui.c.Tr.LcNavigate,
+ keybindings.Label(keybindingConfig.Universal.Return): gui.c.Tr.LcCancel,
+ keybindings.Label(keybindingConfig.Universal.Quit): gui.c.Tr.LcQuit,
+ keybindings.Label(keybindingConfig.Universal.OptionMenu): gui.c.Tr.LcMenu,
+ fmt.Sprintf("%s-%s", keybindings.Label(keybindingConfig.Universal.JumpToBlock[0]), keybindings.Label(keybindingConfig.Universal.JumpToBlock[len(keybindingConfig.Universal.JumpToBlock)-1])): gui.c.Tr.LcJump,
+ fmt.Sprintf("%s/%s", keybindings.Label(keybindingConfig.Universal.ScrollLeft), keybindings.Label(keybindingConfig.Universal.ScrollRight)): gui.c.Tr.LcScrollLeftRight,
}
}
func (gui *Gui) isPopupPanel(viewName string) bool {
- return viewName == "commitMessage" || viewName == "credentials" || viewName == "confirmation" || viewName == "menu"
+ return viewName == "commitMessage" || viewName == "confirmation" || viewName == "menu"
}
func (gui *Gui) popupPanelFocused() bool {
return gui.isPopupPanel(gui.currentViewName())
}
-// secondaryViewFocused tells us whether it appears that the secondary view is focused. The view is actually never focused for real: we just swap the main and secondary views and then you're still focused on the main view so that we can give you access to all its keybindings for free. I will probably regret this design decision soon enough.
-func (gui *Gui) secondaryViewFocused() bool {
- state := gui.State.Panels.LineByLine
- return state != nil && state.SecondaryFocused
+func (gui *Gui) onViewTabClick(windowName string, tabIndex int) error {
+ tabs := gui.viewTabMap()[windowName]
+ if len(tabs) == 0 {
+ return nil
+ }
+
+ viewName := tabs[tabIndex].ViewName
+
+ context, ok := gui.contextForView(viewName)
+ if !ok {
+ return nil
+ }
+
+ return gui.c.PushContext(context)
}
-func (gui *Gui) onViewTabClick(viewName string, tabIndex int) error {
- context := gui.State.ViewTabContextMap[viewName][tabIndex].contexts[0]
+func (gui *Gui) contextForView(viewName string) (types.Context, bool) {
+ view, err := gui.g.View(viewName)
+ if err != nil {
+ return nil, false
+ }
- return gui.pushContext(context)
+ for _, context := range gui.State.Contexts.Flatten() {
+ if context.GetViewName() == view.Name() {
+ return context, true
+ }
+ }
+
+ return nil, false
}
func (gui *Gui) handleNextTab() error {
- v := getTabbedView(gui)
- if v == nil {
+ view := getTabbedView(gui)
+ if view == nil {
return nil
}
- return gui.onViewTabClick(
- v.Name(),
- utils.ModuloWithWrap(v.TabIndex+1, len(v.Tabs)),
- )
+ for _, context := range gui.State.Contexts.Flatten() {
+ if context.GetViewName() == view.Name() {
+ return gui.onViewTabClick(
+ context.GetWindowName(),
+ utils.ModuloWithWrap(view.TabIndex+1, len(view.Tabs)),
+ )
+ }
+ }
+
+ return nil
}
func (gui *Gui) handlePrevTab() error {
- v := getTabbedView(gui)
- if v == nil {
+ view := getTabbedView(gui)
+ if view == nil {
return nil
}
- return gui.onViewTabClick(
- v.Name(),
- utils.ModuloWithWrap(v.TabIndex-1, len(v.Tabs)),
- )
-}
-
-// this is the distance we will move the cursor when paging up or down in a view
-func (gui *Gui) pageDelta(view *gocui.View) int {
- _, height := view.Size()
-
- delta := height - 1
- if delta == 0 {
- return 1
+ for _, context := range gui.State.Contexts.Flatten() {
+ if context.GetViewName() == view.Name() {
+ return gui.onViewTabClick(
+ context.GetWindowName(),
+ utils.ModuloWithWrap(view.TabIndex-1, len(view.Tabs)),
+ )
+ }
}
- return delta
+ return nil
}
func getTabbedView(gui *Gui) *gocui.View {
@@ -380,5 +193,5 @@ func getTabbedView(gui *Gui) *gocui.View {
}
func (gui *Gui) render() {
- gui.OnUIThread(func() error { return nil })
+ gui.c.OnUIThread(func() error { return nil })
}
diff --git a/pkg/gui/views.go b/pkg/gui/views.go
new file mode 100644
index 000000000..9cc3d983f
--- /dev/null
+++ b/pkg/gui/views.go
@@ -0,0 +1,224 @@
+package gui
+
+import (
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/theme"
+)
+
+type Views struct {
+ Status *gocui.View
+ Submodules *gocui.View
+ Files *gocui.View
+ Branches *gocui.View
+ Remotes *gocui.View
+ Tags *gocui.View
+ RemoteBranches *gocui.View
+ ReflogCommits *gocui.View
+ Commits *gocui.View
+ Stash *gocui.View
+
+ Main *gocui.View
+ Secondary *gocui.View
+ Staging *gocui.View
+ StagingSecondary *gocui.View
+ PatchBuilding *gocui.View
+ PatchBuildingSecondary *gocui.View
+ MergeConflicts *gocui.View
+
+ Options *gocui.View
+ Confirmation *gocui.View
+ Menu *gocui.View
+ CommitMessage *gocui.View
+ CommitFiles *gocui.View
+ SubCommits *gocui.View
+ Information *gocui.View
+ AppStatus *gocui.View
+ Search *gocui.View
+ SearchPrefix *gocui.View
+ Limit *gocui.View
+ Suggestions *gocui.View
+ Tooltip *gocui.View
+ Extras *gocui.View
+}
+
+type viewNameMapping struct {
+ viewPtr **gocui.View
+ name string
+}
+
+func (gui *Gui) orderedViews() []*gocui.View {
+ return slices.Map(gui.orderedViewNameMappings(), func(v viewNameMapping) *gocui.View {
+ return *v.viewPtr
+ })
+}
+
+func (gui *Gui) orderedViewNameMappings() []viewNameMapping {
+ return []viewNameMapping{
+ // first layer. Ordering within this layer does not matter because there are
+ // no overlapping views
+ {viewPtr: &gui.Views.Status, name: "status"},
+ {viewPtr: &gui.Views.Submodules, name: "submodules"},
+ {viewPtr: &gui.Views.Files, name: "files"},
+ {viewPtr: &gui.Views.Tags, name: "tags"},
+ {viewPtr: &gui.Views.Remotes, name: "remotes"},
+ {viewPtr: &gui.Views.Branches, name: "localBranches"},
+ {viewPtr: &gui.Views.RemoteBranches, name: "remoteBranches"},
+ {viewPtr: &gui.Views.ReflogCommits, name: "reflogCommits"},
+ {viewPtr: &gui.Views.Commits, name: "commits"},
+ {viewPtr: &gui.Views.Stash, name: "stash"},
+ {viewPtr: &gui.Views.SubCommits, name: "subCommits"},
+ {viewPtr: &gui.Views.CommitFiles, name: "commitFiles"},
+
+ {viewPtr: &gui.Views.Staging, name: "staging"},
+ {viewPtr: &gui.Views.StagingSecondary, name: "stagingSecondary"},
+ {viewPtr: &gui.Views.PatchBuilding, name: "patchBuilding"},
+ {viewPtr: &gui.Views.PatchBuildingSecondary, name: "patchBuildingSecondary"},
+ {viewPtr: &gui.Views.MergeConflicts, name: "mergeConflicts"},
+ {viewPtr: &gui.Views.Secondary, name: "secondary"},
+ {viewPtr: &gui.Views.Main, name: "main"},
+
+ {viewPtr: &gui.Views.Extras, name: "extras"},
+
+ // bottom line
+ {viewPtr: &gui.Views.Options, name: "options"},
+ {viewPtr: &gui.Views.AppStatus, name: "appStatus"},
+ {viewPtr: &gui.Views.Information, name: "information"},
+ {viewPtr: &gui.Views.Search, name: "search"},
+ // this view takes up one character. Its only purpose is to show the slash when searching
+ {viewPtr: &gui.Views.SearchPrefix, name: "searchPrefix"},
+
+ // popups.
+ {viewPtr: &gui.Views.CommitMessage, name: "commitMessage"},
+ {viewPtr: &gui.Views.Menu, name: "menu"},
+ {viewPtr: &gui.Views.Suggestions, name: "suggestions"},
+ {viewPtr: &gui.Views.Confirmation, name: "confirmation"},
+ {viewPtr: &gui.Views.Tooltip, name: "tooltip"},
+
+ // this guy will cover everything else when it appears
+ {viewPtr: &gui.Views.Limit, name: "limit"},
+ }
+}
+
+func (gui *Gui) windowForView(viewName string) string {
+ context, ok := gui.contextForView(viewName)
+ if !ok {
+ panic("todo: deal with this")
+ }
+
+ return context.GetWindowName()
+}
+
+func (gui *Gui) createAllViews() error {
+ var err error
+ for _, mapping := range gui.orderedViewNameMappings() {
+ *mapping.viewPtr, err = gui.prepareView(mapping.name)
+ if err != nil && err.Error() != UNKNOWN_VIEW_ERROR_MSG {
+ return err
+ }
+ }
+
+ gui.Views.Options.FgColor = theme.OptionsColor
+ gui.Views.Options.Frame = false
+
+ gui.Views.SearchPrefix.BgColor = gocui.ColorDefault
+ gui.Views.SearchPrefix.FgColor = gocui.ColorGreen
+ gui.Views.SearchPrefix.Frame = false
+ gui.setViewContent(gui.Views.SearchPrefix, SEARCH_PREFIX)
+
+ gui.Views.Stash.Title = gui.c.Tr.StashTitle
+ gui.Views.Stash.FgColor = theme.GocuiDefaultTextColor
+
+ gui.Views.Commits.Title = gui.c.Tr.CommitsTitle
+ gui.Views.Commits.FgColor = theme.GocuiDefaultTextColor
+
+ gui.Views.CommitFiles.Title = gui.c.Tr.CommitFiles
+ gui.Views.CommitFiles.FgColor = theme.GocuiDefaultTextColor
+
+ gui.Views.SubCommits.FgColor = theme.GocuiDefaultTextColor
+
+ gui.Views.Branches.Title = gui.c.Tr.BranchesTitle
+ gui.Views.Branches.FgColor = theme.GocuiDefaultTextColor
+
+ gui.Views.Remotes.Title = gui.c.Tr.RemotesTitle
+ gui.Views.Remotes.FgColor = theme.GocuiDefaultTextColor
+
+ gui.Views.Tags.Title = gui.c.Tr.TagsTitle
+ gui.Views.Tags.FgColor = theme.GocuiDefaultTextColor
+
+ gui.Views.RemoteBranches.FgColor = theme.GocuiDefaultTextColor
+
+ gui.Views.Files.Title = gui.c.Tr.FilesTitle
+ gui.Views.Files.FgColor = theme.GocuiDefaultTextColor
+
+ for _, view := range []*gocui.View{gui.Views.Main, gui.Views.Secondary, gui.Views.Staging, gui.Views.StagingSecondary, gui.Views.PatchBuilding, gui.Views.PatchBuildingSecondary, gui.Views.MergeConflicts} {
+ view.Title = gui.c.Tr.DiffTitle
+ view.Wrap = true
+ view.FgColor = theme.GocuiDefaultTextColor
+ view.IgnoreCarriageReturns = true
+ view.CanScrollPastBottom = gui.c.UserConfig.Gui.ScrollPastBottom
+ }
+
+ gui.Views.Staging.Title = gui.c.Tr.UnstagedChanges
+ gui.Views.Staging.Highlight = true
+ gui.Views.Staging.Wrap = true
+
+ gui.Views.StagingSecondary.Title = gui.c.Tr.StagedChanges
+ gui.Views.StagingSecondary.Highlight = true
+ gui.Views.StagingSecondary.Wrap = true
+
+ gui.Views.PatchBuilding.Title = gui.Tr.Patch
+ gui.Views.PatchBuilding.Highlight = true
+ gui.Views.PatchBuilding.Wrap = true
+
+ gui.Views.PatchBuildingSecondary.Title = gui.Tr.CustomPatch
+ gui.Views.PatchBuildingSecondary.Highlight = true
+ gui.Views.PatchBuildingSecondary.Wrap = true
+
+ gui.Views.MergeConflicts.Title = gui.c.Tr.MergeConflictsTitle
+ gui.Views.MergeConflicts.Highlight = true
+ gui.Views.MergeConflicts.Wrap = false
+
+ gui.Views.Limit.Title = gui.c.Tr.NotEnoughSpace
+ gui.Views.Limit.Wrap = true
+
+ gui.Views.Status.Title = gui.c.Tr.StatusTitle
+ gui.Views.Status.FgColor = theme.GocuiDefaultTextColor
+
+ gui.Views.Search.BgColor = gocui.ColorDefault
+ gui.Views.Search.FgColor = gocui.ColorGreen
+ gui.Views.Search.Editable = true
+ gui.Views.Search.Frame = false
+
+ gui.Views.AppStatus.BgColor = gocui.ColorDefault
+ gui.Views.AppStatus.FgColor = gocui.ColorCyan
+ gui.Views.AppStatus.Visible = false
+ gui.Views.AppStatus.Frame = false
+
+ gui.Views.CommitMessage.Visible = false
+ gui.Views.CommitMessage.Title = gui.c.Tr.CommitMessage
+ gui.Views.CommitMessage.FgColor = theme.GocuiDefaultTextColor
+ gui.Views.CommitMessage.Editable = true
+ gui.Views.CommitMessage.Editor = gocui.EditorFunc(gui.commitMessageEditor)
+
+ gui.Views.Confirmation.Visible = false
+
+ gui.Views.Suggestions.Visible = false
+
+ gui.Views.Tooltip.FgColor = theme.GocuiDefaultTextColor
+
+ gui.Views.Menu.Visible = false
+
+ gui.Views.Tooltip.Visible = false
+
+ gui.Views.Information.BgColor = gocui.ColorDefault
+ gui.Views.Information.FgColor = gocui.ColorGreen
+ gui.Views.Information.Frame = false
+
+ gui.Views.Extras.Title = gui.c.Tr.CommandLog
+ gui.Views.Extras.FgColor = theme.GocuiDefaultTextColor
+ gui.Views.Extras.Autoscroll = true
+ gui.Views.Extras.Wrap = true
+
+ return nil
+}
diff --git a/pkg/gui/whitespace-toggle.go b/pkg/gui/whitespace-toggle.go
index e7df9d879..ad82bc036 100644
--- a/pkg/gui/whitespace-toggle.go
+++ b/pkg/gui/whitespace-toggle.go
@@ -1,13 +1,13 @@
package gui
func (gui *Gui) toggleWhitespaceInDiffView() error {
- gui.State.IgnoreWhitespaceInDiffView = !gui.State.IgnoreWhitespaceInDiffView
+ gui.IgnoreWhitespaceInDiffView = !gui.IgnoreWhitespaceInDiffView
- toastMessage := gui.Tr.ShowingWhitespaceInDiffView
- if gui.State.IgnoreWhitespaceInDiffView {
- toastMessage = gui.Tr.IgnoringWhitespaceInDiffView
+ toastMessage := gui.c.Tr.ShowingWhitespaceInDiffView
+ if gui.IgnoreWhitespaceInDiffView {
+ toastMessage = gui.c.Tr.IgnoringWhitespaceInDiffView
}
- gui.raiseToast(toastMessage)
+ gui.c.Toast(toastMessage)
return gui.refreshFilesAndSubmodules()
}
diff --git a/pkg/gui/window.go b/pkg/gui/window.go
index 3dccde7e7..efee847e1 100644
--- a/pkg/gui/window.go
+++ b/pkg/gui/window.go
@@ -1,6 +1,12 @@
package gui
-import "github.com/jesseduffield/gocui"
+import (
+ "fmt"
+
+ "github.com/jesseduffield/lazygit/pkg/gui/context"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ "github.com/samber/lo"
+)
// A window refers to a place on the screen which can hold one or more views.
// A view is a box that renders content, and within a window only one view will
@@ -8,37 +14,93 @@ import "github.com/jesseduffield/gocui"
// space. Right now most windows are 1:1 with views, except for commitFiles which
// is a view that moves between windows
+func (gui *Gui) initialWindowViewNameMap(contextTree *context.ContextTree) map[string]string {
+ result := map[string]string{}
+
+ for _, context := range contextTree.Flatten() {
+ result[context.GetWindowName()] = context.GetViewName()
+ }
+
+ return result
+}
+
func (gui *Gui) getViewNameForWindow(window string) string {
viewName, ok := gui.State.WindowViewNameMap[window]
if !ok {
- return window
+ panic(fmt.Sprintf("Viewname not found for window: %s", window))
}
return viewName
}
-func (gui *Gui) getWindowForView(view *gocui.View) string {
- if view == gui.Views.CommitFiles {
- return gui.State.Contexts.CommitFiles.GetWindowName()
+func (gui *Gui) getContextForWindow(window string) types.Context {
+ viewName := gui.getViewNameForWindow(window)
+
+ context, ok := gui.contextForView(viewName)
+ if !ok {
+ panic("TODO: fix this")
}
- return view.Name()
+ return context
}
-func (gui *Gui) setViewAsActiveForWindow(view *gocui.View) {
- if gui.State.WindowViewNameMap == nil {
- gui.State.WindowViewNameMap = map[string]string{}
+// for now all we actually care about is the context's view so we're storing that
+func (gui *Gui) setWindowContext(c types.Context) {
+ if c.IsTransient() {
+ gui.resetWindowContext(c)
}
- gui.State.WindowViewNameMap[gui.getWindowForView(view)] = view.Name()
+ gui.State.WindowViewNameMap[c.GetWindowName()] = c.GetViewName()
}
func (gui *Gui) currentWindow() string {
- return gui.getWindowForView(gui.g.CurrentView())
+ return gui.currentContext().GetWindowName()
}
-func (gui *Gui) resetWindowForView(view *gocui.View) {
- window := gui.getWindowForView(view)
- // we assume here that the window contains as its default view a view with the same name as the window
- gui.State.WindowViewNameMap[window] = window
+// assumes the context's windowName has been set to the new window if necessary
+func (gui *Gui) resetWindowContext(c types.Context) {
+ for windowName, viewName := range gui.State.WindowViewNameMap {
+ if viewName == c.GetViewName() && windowName != c.GetWindowName() {
+ for _, context := range gui.State.Contexts.Flatten() {
+ if context.GetKey() != c.GetKey() && context.GetWindowName() == windowName {
+ gui.State.WindowViewNameMap[windowName] = context.GetViewName()
+ }
+ }
+ }
+ }
+}
+
+func (gui *Gui) moveToTopOfWindow(context types.Context) {
+ view := context.GetView()
+ if view == nil {
+ return
+ }
+
+ window := context.GetWindowName()
+
+ // now I need to find all views in that same window, via contexts. And I guess then I need to find the index of the highest view in that list.
+ viewNamesInWindow := gui.viewNamesInWindow(window)
+
+ // The views list is ordered highest-last, so we're grabbing the last view of the window
+ topView := view
+ for _, currentView := range gui.g.Views() {
+ if lo.Contains(viewNamesInWindow, currentView.Name()) {
+ topView = currentView
+ }
+ }
+
+ if err := gui.g.SetViewOnTopOf(view.Name(), topView.Name()); err != nil {
+ gui.Log.Error(err)
+ }
+}
+
+func (gui *Gui) viewNamesInWindow(windowName string) []string {
+ result := []string{}
+ for _, context := range gui.State.Contexts.Flatten() {
+ if context.GetWindowName() == windowName {
+ result = append(result, context.GetViewName())
+ }
+ }
+
+ return result
}
diff --git a/pkg/gui/workspace_reset_options_panel.go b/pkg/gui/workspace_reset_options_panel.go
deleted file mode 100644
index ba2df22e6..000000000
--- a/pkg/gui/workspace_reset_options_panel.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package gui
-
-import (
- "fmt"
-
- "github.com/jesseduffield/lazygit/pkg/gui/style"
-)
-
-func (gui *Gui) handleCreateResetMenu() error {
- red := style.FgRed
-
- nukeStr := "reset --hard HEAD && git clean -fd"
- if len(gui.State.Submodules) > 0 {
- nukeStr = fmt.Sprintf("%s (%s)", nukeStr, gui.Tr.LcAndResetSubmodules)
- }
-
- menuItems := []*menuItem{
- {
- displayStrings: []string{
- gui.Tr.LcDiscardAllChangesToAllFiles,
- red.Sprint(nukeStr),
- },
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.NukeWorkingTree)
- if err := gui.Git.WorkingTree.ResetAndClean(); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
- },
- },
- {
- displayStrings: []string{
- gui.Tr.LcDiscardAnyUnstagedChanges,
- red.Sprint("git checkout -- ."),
- },
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.DiscardUnstagedFileChanges)
- if err := gui.Git.WorkingTree.DiscardAnyUnstagedFileChanges(); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
- },
- },
- {
- displayStrings: []string{
- gui.Tr.LcDiscardUntrackedFiles,
- red.Sprint("git clean -fd"),
- },
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.RemoveUntrackedFiles)
- if err := gui.Git.WorkingTree.RemoveUntrackedFiles(); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
- },
- },
- {
- displayStrings: []string{
- gui.Tr.LcSoftReset,
- red.Sprint("git reset --soft HEAD"),
- },
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.SoftReset)
- if err := gui.Git.WorkingTree.ResetSoft("HEAD"); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
- },
- },
- {
- displayStrings: []string{
- "mixed reset",
- red.Sprint("git reset --mixed HEAD"),
- },
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.MixedReset)
- if err := gui.Git.WorkingTree.ResetMixed("HEAD"); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
- },
- },
- {
- displayStrings: []string{
- gui.Tr.LcHardReset,
- red.Sprint("git reset --hard HEAD"),
- },
- onPress: func() error {
- gui.logAction(gui.Tr.Actions.HardReset)
- if err := gui.Git.WorkingTree.ResetHard("HEAD"); err != nil {
- return gui.surfaceError(err)
- }
-
- return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
- },
- },
- }
-
- return gui.createMenu("", menuItems, createMenuOptions{showCancel: true})
-}
diff --git a/pkg/i18n/chinese.go b/pkg/i18n/chinese.go
index 580a08205..66e895359 100644
--- a/pkg/i18n/chinese.go
+++ b/pkg/i18n/chinese.go
@@ -1,33 +1,38 @@
+/*
+
+本翻译文件ä¸çš„词čŻçš„翻译参č€äş† https://github.com/progit/progit2-zh/blob/master/TRANSLATION_NOTES.asc。
+下方的术čŻĺŻąç…§čˇ¨ćŻĺŻąĺ…¶çš„čˇĄĺ……ă€‚
+
+Translation in this file refer to https://github.com/progit/progit2-zh/blob/master/TRANSLATION_NOTES.asc.
+Glossary below is a supplement of that documentation.
+
+Glossary 术čŻĺŻąç…§čˇ¨
+
+change 更改
+fixup äż®ćŁ
+reset 重置
+
+*/
+
package i18n
-// 本翻译文件ä¸çš„词čŻçš„翻译参č€äş† https://github.com/progit/progit2-zh/blob/master/TRANSLATION_NOTES.asc。
-// 下方的术čŻĺŻąç…§čˇ¨ćŻĺŻąĺ…¶çš„čˇĄĺ……
-
-// Translation in this file refer to https://github.com/progit/progit2-zh/blob/master/TRANSLATION_NOTES.asc.
-// Glossary below is a supplement of that documentation.
-
-// Glossary 术čŻĺŻąç…§čˇ¨
-
-// change 更改
-// fixup äż®ćŁ
-// reset 重置
-
const chineseIntroPopupMessage = `
-感谢使用 lazygitďĽä¸‹éť˘ĺ‡ ç‚ąä˝ ĺŹŻč˝äĽšć„źĺ…´č¶ŁďĽš
+感谢使用 lazygitďĽä˝ çśźçš„ĺ¤ŞćŁ’äş†ă€‚ä¸‹éť˘ĺ‡ ç‚ąä˝ ĺŹŻč˝äĽšć„źĺ…´č¶ŁďĽš
1) č§‚çś‹ć¤č§†é˘‘,快速了解 lazygit 的功č˝ďĽš
https://youtu.be/CPLdltN7wgE
- 2) č®°ĺľ—é…读最新的发行说ćŽďĽš
+ 2) 记得看看最新发行说ćŽďĽš
https://github.com/jesseduffield/lazygit/releases
- 3) 使用 git 说ćŽä˝ ćŻä¸€ä˝Ťç¨‹ĺşŹĺ‘ďĽä˝ 可以和ć‘们一起让
- lazygit ĺŹĺľ—更好。č€č™‘贡献一些代ç :
+ 3) 使用 git 说ćŽä˝ ćŻä¸€ä˝Ťç¨‹ĺşŹĺ‘ďĽä˝ 可以和ć‘们一起让 lazygit ĺŹĺľ—更好。
+ č€č™‘为本项目ĺšäş›č´ˇçŚ®ĺ§ďĽš
https://github.com/jesseduffield/lazygit
- 也可以赞助并告诉ć‘哪里需č¦ć”ąčż›ďĽŚç‚ąĺŹłä¸‹č§’çš„ćŤčµ 按钮就好了。
- 就算给仓库点个ćźćźäąźĺľćŁ’ďĽ
+ ä˝ äąźĺŹŻä»Ąç›´ćŽĄčµžĺŠ©ďĽŚĺą¶ĺ‘ŠčŻ‰ć‘哪里需č¦ć”ąčż›ďĽŚç‚ąĺŹłä¸‹č§’çš„ćŤčµ 按钮就好了。
+ 哪怕只ćŻç»™ä»“库点个ćźćźäąźĺľćŁ’ďĽ
`
+// exporting this so we can use it in tests
func chineseTranslationSet() TranslationSet {
return TranslationSet{
NotEnoughSpace: "没有足够的空间来渲染面板",
@@ -40,7 +45,7 @@ func chineseTranslationSet() TranslationSet {
StagedChanges: `已暂ĺ更改`,
MainTitle: "主č¦",
StagingTitle: "ćŁĺś¨ćš‚ĺ",
- MergingTitle: "ĺĺą¶ä¸",
+ MergingTitle: "ćŁĺś¨ĺĺą¶",
NormalTitle: "ćŁĺ¸¸",
CommitMessage: "ćŹäş¤äżˇćŻ",
CredentialsUsername: "用ć·ĺŤ",
@@ -49,6 +54,7 @@ func chineseTranslationSet() TranslationSet {
PassUnameWrong: "密ç ĺ’Ś/ć– ç”¨ć·ĺŤé”™čŻŻ",
CommitChanges: "ćŹäş¤ć›´ć”ą",
AmendLastCommit: "修补最ĺŽä¸€ć¬ˇćŹäş¤",
+ AmendLastCommitTitle: "修补最ĺŽä¸€ć¬ˇćŹäş¤",
SureToAmend: "您确定č¦äż®čˇĄä¸Šä¸€ć¬ˇćŹäş¤ĺ—?之ĺŽć‚¨ĺŹŻä»Ąä»ŽćŹäş¤éť˘ćťżć›´ć”ąćŹäş¤ć¶ćŻă€‚",
NoCommitToAmend: "没有需č¦ćŹäş¤çš„修补。",
CommitChangesWithEditor: "ćŹäş¤ć›´ć”ąďĽä˝żç”¨çĽ–辑器编辑ćŹäş¤äżˇćŻďĽ‰",
@@ -59,22 +65,21 @@ func chineseTranslationSet() TranslationSet {
LcToggleStaged: "ĺ‡ćŤ˘ćš‚ĺ状ć€",
LcToggleStagedAll: "ĺ‡ćŤ˘ć‰€ćś‰ć–‡ä»¶çš„ćš‚ĺ状ć€",
LcToggleTreeView: "ĺ‡ćŤ˘ć–‡ä»¶ć ‘视图",
- LcOpenMergeTool: "打开ĺĺą¶ĺ·Ąĺ…·",
+ LcOpenMergeTool: "打开外é¨ĺĺą¶ĺ·Ąĺ…· (git mergetool)",
LcRefresh: "ĺ·ć–°",
LcPush: "推é€",
LcPull: "拉取",
LcScroll: "滚动",
MergeConflictsTitle: "ĺ并冲çŞ",
LcCheckout: "检出",
- LcCommitFileFilter: "过滤ćŹäş¤ć–‡ä»¶",
NoChangedFiles: "没有更改过文件",
NoFilesDisplay: "没有文件可ćľç¤ş",
NotAFile: "不ćŻć–‡ä»¶",
- PullWait: "拉取ä¸â€¦â€¦",
- PushWait: "推é€ä¸â€¦â€¦",
- FetchWait: "ćŁĺś¨ćŠ“ĺŹ–â€¦â€¦",
+ PullWait: "ćŁĺś¨ć‹‰ĺŹ–â€¦",
+ PushWait: "ćŁĺś¨ćލé€â€¦",
+ FetchWait: "ćŁĺś¨ćŠ“ĺŹ–â€¦",
LcSoftReset: "软重置",
- AlreadyCheckedOutBranch: "您已经检出了这个ĺ†ć”Ż",
+ AlreadyCheckedOutBranch: "您已经检出至ć¤ĺ†ć”Ż",
SureForceCheckout: "您确定č¦ĺĽşĺ¶ćŁ€ĺ‡şĺ—?您将丢失所有本地更改",
ForceCheckoutBranch: "强ĺ¶ćŁ€ĺ‡şĺ†ć”Ż",
BranchName: "ĺ†ć”ŻĺŤç§°",
@@ -97,7 +102,6 @@ func chineseTranslationSet() TranslationSet {
LcClose: "ĺ…łé—",
LcQuit: "退出",
LcSquashDown: "ĺ‘下压缩",
- LcResetToThisCommit: "重置为ć¤ćŹäş¤",
LcFixupCommit: "äż®ćŁćŹäş¤ďĽfixup)",
NoCommitsThisBranch: "该ĺ†ć”Żć˛ˇćś‰ćŹäş¤",
OnlySquashTopmostCommit: "只č˝ĺŽ‹çĽ©ćś€éˇ¶ĺ±‚çš„ćŹäş¤",
@@ -135,7 +139,6 @@ func chineseTranslationSet() TranslationSet {
SureApplyStashEntry: "您确定č¦ĺş”用ć¤č´®č—Źćťˇç›®ďĽź",
NoTrackedStagedFilesStash: "没有可以贮藏的已跟踪/ćš‚ĺ文件",
StashChanges: "贮藏更改",
- MergeAborted: "ĺĺą¶ä¸ć˘",
OpenConfig: "打开配置文件",
EditConfig: "编辑配置文件",
ForcePush: "强ĺ¶ćލé€",
@@ -143,9 +146,9 @@ func chineseTranslationSet() TranslationSet {
ForcePushDisabled: "您的ĺ†ć”Żĺ·˛ä¸Žčżśç¨‹ĺ†ć”Żä¸ŤĺŚ, 并且您已经ç¦ç”¨äş†ĺĽşčˇŚćލé€",
UpdatesRejectedAndForcePushDisabled: "更新被拒绝,您已ç¦ç”¨ĺĽşĺ¶ćލé€",
LcCheckForUpdate: "检查更新",
- CheckingForUpdates: "检查更新ä¸â€¦â€¦",
- OnLatestVersionErr: "您的软件已经ćŻćś€ć–°ç‰ćś¬",
- MajorVersionErr: "ć–°ç‰ćś¬ ({{.newVersion}}) 与当前ç‰ćś¬ç›¸ćŻ”ďĽŚĺ…·ćś‰ĺ‘ĺŽĺ…Ľĺ®ąçš„更改 ({{.currentVersion}})",
+ CheckingForUpdates: "ćŁĺś¨ćŁ€ćźĄć›´ć–°â€¦",
+ OnLatestVersionErr: "ĺ·˛ćŻćś€ć–°ç‰ćś¬",
+ MajorVersionErr: "ć–°ç‰ćś¬ ({{.newVersion}}) 与当前ç‰ćś¬ ({{.currentVersion}}) 相比,具有非ĺ‘ĺŽĺ…Ľĺ®ąçš„更改",
CouldNotFindBinaryErr: "在 {{.url}} 处找不ĺ°ä»»ä˝•二进ĺ¶ć–‡ä»¶",
MergeToolTitle: "ĺĺą¶ĺ·Ąĺ…·",
MergeToolPrompt: "确定č¦ć‰“开 `git mergetool` ĺ—?",
@@ -173,7 +176,7 @@ func chineseTranslationSet() TranslationSet {
ToggleDragSelect: `ĺ‡ćŤ˘ć‹–动选择`,
ToggleSelectHunk: `ĺ‡ćŤ˘é€‰ć‹©ĺŚşĺť—`,
ToggleSelectionForPatch: `ć·»ĺŠ /移除 行ĺ°čˇĄä¸`,
- TogglePanel: `ĺ‡ćŤ˘ĺ°ĺ…¶ä»–面板`,
+ ToggleStagingPanel: `ĺ‡ćŤ˘ĺ°ĺ…¶ä»–面板`,
ReturnToFilesPanel: `返回文件面板`,
FastForward: `从上游快进ć¤ĺ†ć”Ż`,
Fetching: "抓取并快进 {{.from}} -> {{.to}} ...",
@@ -187,37 +190,37 @@ func chineseTranslationSet() TranslationSet {
MergeOptionsTitle: "ĺ并选项",
RebaseOptionsTitle: "ĺŹĺźşé€‰éˇą",
CommitMessageTitle: "ćŹäş¤č®ŻćŻ",
- LocalBranchesTitle: "ĺ†ć”Żć ‡çľ",
+ LocalBranchesTitle: "ĺ†ć”Żéˇµéť˘",
SearchTitle: "ćśç´˘",
TagsTitle: "ć ‡çľéˇµéť˘",
MenuTitle: "菜单",
RemotesTitle: "远程页面",
- CredentialsTitle: "čŻäą¦",
- RemoteBranchesTitle: "远程ĺ†ć”ŻďĽĺś¨čżśç¨‹éˇµéť˘ä¸ďĽ‰",
+ RemoteBranchesTitle: "远程ĺ†ć”Ż",
PatchBuildingTitle: "构建补ä¸ä¸",
InformationTitle: "信ćŻ",
SecondaryTitle: "次č¦",
- ReflogCommitsTitle: "Reflog",
+ ReflogCommitsTitle: "Reflog 页面",
GlobalTitle: "全局键绑定",
ConflictsResolved: "已解决所有冲çŞă€‚ćŻĺ¦ç»§ç»ďĽź",
RebasingTitle: "ĺŹĺźş",
ConfirmRebase: "您确定č¦ĺ°†ĺ†ć”Ż {{.checkedOutBranch}} ĺŹĺźşĺ° {{.selectedBranch}} ĺ—?",
ConfirmMerge: "您确定č¦ĺ°†ĺ†ć”Ż {{.selectedBranch}} ĺĺą¶ĺ° {{.checkedOutBranch}} ĺ—?",
- FwdNoUpstream: "ć— ćł•ĺż«čż›ć˛ˇćś‰ä¸Šć¸¸çš„ĺ†ć”Ż",
- FwdCommitsToPush: "ć— ćł•ĺż«čż›ĺą¶ćŹäş¤ćލé€çš„ĺ†ć”Ż",
+ FwdNoUpstream: "ć¤ĺ†ć”Żć˛ˇćś‰ä¸Šć¸¸ďĽŚć— 法快进",
+ FwdNoLocalUpstream: "ć¤ĺ†ć”Żçš„čżśç¨‹ćśŞĺś¨ćś¬ĺś°ćł¨ĺ†ŚďĽŚć— ćł•ĺż«čż›",
+ FwdCommitsToPush: "ć¤ĺ†ć”Żĺ¸¦ćś‰ĺ°šćśŞćލé€çš„ćŹäş¤ďĽŚć— 法快进",
ErrorOccurred: "发生错误ďĽčŻ·ĺś¨ä»Ąä¸‹ä˝Ťç˝®ĺ›ĺ»ş issue",
- NoRoom: "没有足够的空间",
+ NoRoom: "空间不足",
YouAreHere: "您在这里",
LcRewordNotSupported: "当前不支ćŚäş¤äş’式重新基准化时的重新措词ćŹäş¤",
LcCherryPickCopy: "复ĺ¶ćŹäş¤ďĽć‹Łé€‰ďĽ‰",
LcCherryPickCopyRange: "复ĺ¶ćŹäş¤čŚĺ›´ďĽć‹Łé€‰ďĽ‰",
LcPasteCommits: "ç˛č´´ćŹäş¤ďĽć‹Łé€‰ďĽ‰",
SureCherryPick: "您确定č¦ĺ°†é€‰ä¸çš„ćŹäş¤čż›čˇŚć‹Łé€‰ĺ°čż™ä¸Şĺ†ć”Żĺ—?",
- CherryPick: "拣选",
- CannotRebaseOntoFirstCommit: "您不č˝ä»Ąäş¤äş’方式基于第一次ćŹäş¤",
- CannotSquashOntoSecondCommit: "ć‚¨ä¸Ťč˝ ĺŽ‹çĽ©/äż®ćŁ(fixup)第二个ćŹäş¤",
+ CherryPick: "拣选 (Cherry-Pick)",
+ CannotRebaseOntoFirstCommit: "您不č˝ä»Ąäş¤äş’方式ĺŹĺźş (rebase) 至第一次ćŹäş¤",
+ CannotSquashOntoSecondCommit: "您不č˝ĺŽ‹çĽ© (squash) ć–äż®ćŁ (fixup) 第二个ćŹäş¤",
Donate: "ćŤĺŠ©",
- AskQuestion: "é—®é˘ĺ’¨čŻ˘",
+ AskQuestion: "ćŹé—®ĺ’¨čŻ˘",
PrevLine: "选择上一行",
NextLine: "选择下一行",
PrevHunk: "选择上一个区块",
@@ -238,26 +241,26 @@ func chineseTranslationSet() TranslationSet {
FixingStatus: "ćŁĺś¨äż®ćŁ",
DeletingStatus: "ćŁĺś¨ĺ 除",
MovingStatus: "ćŁĺś¨ç§»ĺЍ",
- RebasingStatus: "ĺŹĺźş",
- AmendingStatus: "修改",
- CherryPickingStatus: "拣选ä¸",
+ RebasingStatus: "ćŁĺś¨ĺŹĺźş",
+ AmendingStatus: "ćŁĺś¨äż®ć”ą",
+ CherryPickingStatus: "ćŁĺś¨ć‹Łé€‰",
UndoingStatus: "ćŁĺś¨ć’¤é”€",
RedoingStatus: "ćŁĺś¨é‡Ťĺš",
- CheckingOutStatus: "检出",
+ CheckingOutStatus: "长ĺ检出",
CommittingStatus: "ćŁĺś¨ćŹäş¤",
CommitFiles: "ćŹäş¤ć–‡ä»¶",
- LcViewCommitFiles: "查看ćŹäş¤çš„文件",
+ LcViewItemFiles: "查看ćŹäş¤çš„文件",
CommitFilesTitle: "ćŹäş¤ć–‡ä»¶",
LcCheckoutCommitFile: "检出文件",
LcDiscardOldFileChange: "放ĺĽĺŻąć¤ć–‡ä»¶çš„ćŹäş¤ć›´ć”ą",
DiscardFileChangesTitle: "放ĺĽć–‡ä»¶ć›´ć”ą",
DiscardFileChangesPrompt: "您确定č¦čŤĺĽć¤ćŹäş¤ĺŻąčŻĄć–‡ä»¶çš„ć›´ć”ąĺ—?如果ć¤ć–‡ä»¶ćŻĺś¨ć¤ćŹäş¤ä¸ĺ›ĺ»şçš„,ĺ®ĺ°†č˘«ĺ 除",
DisabledForGPG: "该功č˝ä¸Ťé€‚用于使用 GPG 的用ć·",
- CreateRepo: "不在 git 仓库ä¸ă€‚ĺ›ĺ»şä¸€ä¸Şć–°çš„ git 仓库ĺ—?(y/n): ",
+ CreateRepo: "当前目录不在 git 仓库ä¸ă€‚ćŻĺ¦ĺś¨ć¤ç›®ĺ˝•ĺ›ĺ»şä¸€ä¸Şć–°çš„ git 仓库?(y/n): ",
AutoStashTitle: "自动ĺ储?",
AutoStashPrompt: "您必须éšč—Źĺą¶ĺĽąĺ‡şć›´ć”ąä»Ąä˝żć›´ć”ąç”źć•。自动执行?(enter/esc)",
StashPrefix: "自动éšč—Źć›´ć”ą ",
- LcViewDiscardOptions: "查看'放ĺĽć›´ć”ąâ€é€‰éˇą",
+ LcViewDiscardOptions: "查看'放ĺĽć›´ć”ą'选项",
LcCancel: "取ć¶",
LcDiscardAllChanges: "放ĺĽć‰€ćś‰ć›´ć”ą",
LcDiscardUnstagedChanges: "放ĺĽćśŞćš‚ĺçš„ĺŹć›´",
@@ -278,12 +281,15 @@ func chineseTranslationSet() TranslationSet {
SkipHookPrefixNotConfigured: "您尚未配置用于跳过钩ĺçš„ćŹäş¤ć¶ćŻĺ‰ŤçĽ€ă€‚请在您的配置ä¸č®ľç˝® `git.skipHookPrefix ='WIP'`",
LcResetTo: `重置为`,
PressEnterToReturn: "按下 Enter 键返回 lazygit",
- LcViewStashOptions: "查看éšč—Źé€‰éˇą",
+ LcViewStashOptions: "查看贮藏选项",
LcStashAllChanges: "ĺ°†ć‰€ćś‰ć›´ć”ąĺŠ ĺ…Ąč´®č—Ź",
- LcStashStagedChanges: "将已暂ĺçš„ć›´ć”ąĺŠ ĺ…Ąč´®č—Ź",
+ LcStashAllChangesKeepIndex: "将已暂ĺçš„ć›´ć”ąĺŠ ĺ…Ąč´®č—Ź",
LcStashOptions: "贮藏选项",
NotARepository: "错误:必须在 git 仓库ä¸čżčˇŚ",
LcJump: "č·łĺ°éť˘ćťż",
+ LcScrollLeftRight: "左右滚动",
+ LcScrollLeft: "ĺ‘左滚动",
+ LcScrollRight: "ĺ‘右滚动",
DiscardPatch: "丢ĺĽčˇĄä¸",
DiscardPatchConfirm: "您一次只č˝é€ščż‡ä¸€ä¸ŞćŹäş¤ć–贮藏条目构建补ä¸ă€‚需č¦ć”ľĺĽĺ˝“前补ä¸ĺ—?",
CantPatchWhileRebasingError: "处于ĺĺą¶ć–ĺŹĺźşçжć€ć—¶ďĽŚć‚¨ć— 法构建修补程序ć–čżčˇŚäż®čˇĄç¨‹ĺşŹĺ‘˝ä»¤",
@@ -292,12 +298,13 @@ func chineseTranslationSet() TranslationSet {
PatchOptionsTitle: "补ä¸é€‰éˇą",
NoPatchError: "尚未ĺ›ĺ»şčˇĄä¸ă€‚ä˝ ĺŹŻä»Ąĺś¨ćŹäş¤ä¸çš„ć–‡ä»¶ä¸ŠćŚ‰ä¸‹â€śç©şć Ľâ€ťć–ä˝żç”¨â€śĺ›žč˝¦â€ťć·»ĺŠ ĺ…¶ä¸çš„特定行以开始构建补ä¸",
LcEnterFile: "čľ“ĺ…Ąć–‡ä»¶ä»Ąĺ°†ć‰€é€‰čˇŚć·»ĺŠ ĺ°čˇĄä¸ä¸ďĽć–ĺ‡ćŤ˘ç›®ĺ˝•ćŠĺŹ ďĽ‰",
- ExitLineByLineMode: `退出é€čˇŚć¨ˇĺĽŹ`,
- EnterUpstream: `以这种形式输入上游:“<远程仓库> <ĺ†ć”ŻĺŤç§°>”`,
+ ExitCustomPatchBuilder: `退出é€čˇŚć¨ˇĺĽŹ`,
+ EnterUpstream: `ä»Ąčż™ç§Ťć ĽĺĽŹčľ“ĺ…Ąä¸Šć¸¸ďĽš'<远程仓库> <ĺ†ć”ŻĺŤç§°>'`,
+ InvalidUpstream: "ä¸Šć¸¸ć ĽĺĽŹć— ć•ďĽŚć ĽĺĽŹĺş”ĺ˝“ä¸şďĽš' '",
ReturnToRemotesList: `返回远程仓库ĺ—表`,
LcAddNewRemote: `ć·»ĺŠ ć–°çš„čżśç¨‹ä»“ĺş“`,
- LcNewRemoteName: `新的远程仓库ĺŤç§°:`,
- LcNewRemoteUrl: `新的远程仓库 URL:`,
+ LcNewRemoteName: `新远程仓库ĺŤç§°:`,
+ LcNewRemoteUrl: `新远程仓库 URL:`,
LcEditRemoteName: `输入远程仓库 {{.remoteName}} 的新ĺŤç§°ďĽš`,
LcEditRemoteUrl: `输入远程仓库 {{.remoteName}} 的新 URL:`,
LcRemoveRemote: `ĺ 除远程`,
@@ -305,18 +312,23 @@ func chineseTranslationSet() TranslationSet {
DeleteRemoteBranch: "ĺ 除远程ĺ†ć”Ż",
DeleteRemoteBranchMessage: "您确定č¦ĺ 除远程ĺ†ć”Żĺ—?",
LcSetUpstream: "设置为检出ĺ†ć”Żçš„上游",
+ LcSetAsUpstream: "设置为检出ĺ†ć”Żçš„上游",
SetUpstreamTitle: "设置上游ĺ†ć”Ż",
SetUpstreamMessage: "您确定č¦ĺ°† {{.checkedOut}} 的上游ĺ†ć”Żč®ľç˝®ä¸ş {{.selected}} ĺ—?",
LcEditRemote: "编辑远程仓库",
LcTagCommit: "ć ‡çľćŹäş¤",
- TagNameTitle: "ć ‡çľĺŤďĽš",
+ TagMenuTitle: "ĺ›ĺ»şć ‡çľ",
+ TagNameTitle: "ć ‡çľĺŤç§°ďĽš",
+ TagMessageTitle: "ć ‡çľć¶ćŻďĽš",
+ LcAnnotatedTag: "é™„ćł¨ć ‡çľ",
+ LcLightweightTag: "č˝»é‡Źć ‡çľ",
LcDeleteTag: "ĺ 除㠇çľ",
DeleteTagTitle: "ĺ 除㠇çľ",
DeleteTagPrompt: "您确定č¦ĺ é™¤ć ‡çľ {{.tagName}} ĺ—?",
PushTagTitle: "ĺ°† {{.tagName}} 推é€ĺ°čżśç¨‹ä»“库:",
LcPushTag: "推é€ć ‡çľ",
LcCreateTag: "ĺ›ĺ»şć ‡çľ",
- CreateTagTitle: "ć ‡çľĺŤďĽš",
+ CreateTagTitle: "ć ‡çľĺŤç§°ďĽš",
LcFetchRemote: "抓取远程仓库",
FetchingRemoteStatus: "抓取远程仓库ä¸",
LcCheckoutCommit: "检出ćŹäş¤",
@@ -336,7 +348,6 @@ func chineseTranslationSet() TranslationSet {
NewBranchNamePrompt: "输入ĺ†ć”Żçš„ć–°ĺŤç§°",
RenameBranchWarning: "该ĺ†ć”ŻćŁĺś¨č·źč¸Şčżśç¨‹ä»“库。ć¤ć“Ťä˝śĺ°†ä»…会重命ĺŤćś¬ĺś°ĺ†ć”ŻĺŤç§°ďĽŚč€Śä¸ŤäĽšé‡Ťĺ‘˝ĺŤčżśç¨‹ĺ†ć”Żçš„ĺŤç§°ă€‚确定继ç»ďĽź",
LcOpenMenu: "打开菜单",
- LcCloseMenu: "ĺ…łé—菜单",
LcResetCherryPick: "重置已拣选ďĽĺ¤Ťĺ¶ďĽ‰çš„ćŹäş¤",
LcNextTab: "ä¸‹ä¸€ä¸Şć ‡çľ",
LcPrevTab: "ä¸Šä¸€ä¸Şć ‡çľ",
@@ -360,32 +371,32 @@ func chineseTranslationSet() TranslationSet {
MustExitFilterModeTitle: "命令不可用",
MustExitFilterModePrompt: "命令在过滤模式下不可用。退出过滤模式?",
LcDiff: "差异",
- LcEnterRefToDiff: "输入 ref 以 diff", // TODO
+ LcEnterRefToDiff: "输入 ref 以 diff",
LcEnteRefName: "输入 ref:",
LcExitDiffMode: "退出差异模式",
- DiffingMenuTitle: "diff ä¸", // TODO
+ DiffingMenuTitle: "ćŁĺś¨ diff",
LcSwapDiff: "ĺŹŤĺ‘ diff",
LcOpenDiffingMenu: "打开 diff 菜单",
+ // 实际视图 (actual view) ćŻé™„ĺŠ č§†ĺ›ľ (extras view),未来,ć‘ć‰“ç®—ä¸şé™„ĺŠ č§†ĺ›ľćŹäľ›ć›´ĺ¤šé€‰éˇąĺŤˇďĽŚä˝†çŽ°ĺś¨ďĽŚä¸Šéť˘çš„ć–‡ćś¬ĺŹŞéś€č¦ćŹĺŹŠâ€śĺ‘˝ä»¤ć—Ąĺż—â€ťčż™ä¸Şé¨ĺ†
LcOpenExtrasMenu: "打开命令日志菜单",
- LcShowingGitDiff: "ćľç¤şčľ“出:", // TODO
+ LcShowingGitDiff: "ćľç¤şčľ“出:",
LcCopyCommitShaToClipboard: "ĺ°†ćŹäş¤çš„ SHA 复ĺ¶ĺ°ĺ‰Şč´´ćťż",
LcCopyCommitMessageToClipboard: "ĺ°†ćŹäş¤ć¶ćŻĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż",
LcCopyBranchNameToClipboard: "ĺ°†ĺ†ć”ŻĺŤç§°ĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż",
LcCopyFileNameToClipboard: "将文件ĺŤĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż",
LcCopyCommitFileNameToClipboard: "ĺ°†ćŹäş¤çš„文件ĺŤĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż",
+ LcCopySelectedTexToClipboard: "将选ä¸ć–‡ćś¬ĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż",
LcCommitPrefixPatternError: "ćŹäş¤ĺ‰ŤçĽ€ć¨ˇĺĽŹé”™čŻŻ",
NoFilesStagedTitle: "没有暂ĺ文件",
NoFilesStagedPrompt: "您尚未暂ĺ任何文件。ćŹäş¤ć‰€ćś‰ć–‡ä»¶ďĽź",
BranchNotFoundTitle: "找不ĺ°ĺ†ć”Ż",
BranchNotFoundPrompt: "找不ĺ°ĺ†ć”Żă€‚ĺ›ĺ»şä¸€ä¸Şć–°ĺ†ć”Żĺ‘˝ĺŤä¸şďĽš",
- UnstageLinesTitle: "未暂ĺ的行",
+ UnstageLinesTitle: "取ć¶ćš‚ĺ选ä¸çš„行",
UnstageLinesPrompt: "您确定č¦ĺ 除所选的行ďĽgit reset)ĺ—?这ćŻä¸ŤĺŹŻé€†çš„ă€‚\nč¦ç¦ç”¨ć¤ĺŻąčŻťćˇ†ďĽŚčŻ·ĺ°† 'gui.skipUnstageLineWarning' 的配置键设置为 true",
LcCreateNewBranchFromCommit: "从ćŹäş¤ĺ›ĺ»şć–°ĺ†ć”Ż",
- LcViewStashFiles: "查看贮藏条目ä¸çš„文件",
LcBuildingPatch: "ćŁĺś¨ćž„建补ä¸",
LcViewCommits: "查看ćŹäş¤",
- MinGitVersionError: "Git ç‰ćś¬ĺż…须至少为 2.0ďĽĺŤłä»Ž 2014 年开始)。请升级您的 git ç‰ćś¬ă€‚ć–者在 https://github.com/jesseduffield/lazygit/issues 上ćŹĺ‡şä¸€ä¸Şé—®é˘ďĽŚä»Ąä˝ż lazygit ć›´ĺŠ ĺ‘ĺŽĺ…Ľĺ®ąă€‚",
- MinGhVersionError: "GHç‰ćś¬ĺż…须至少ćŻ2.0 请升级您的ghç‰ćś¬ă€‚ć–者在https://github.com/jesseduffield/lazygit/issues ćŹĺ‡şä¸€ä¸Şé—®é˘ďĽŚä»Ąä˝żlazygitć›´ĺŠ ĺ‘ĺŽĺ…Ľĺ®ąă€‚",
+ MinGitVersionError: "Git ç‰ćś¬ĺż…须至少为 2.0ďĽĺŤłä»Ž 2014 年开始的ç‰ćś¬ďĽ‰ă€‚请更新 git。ć–者在 https://github.com/jesseduffield/lazygit/issues 上ćŹĺ‡şä¸€ä¸Şé—®é˘ďĽŚä»Ąä˝ż lazygit ć›´ĺŠ ĺ‘ĺŽĺ…Ľĺ®ąă€‚",
LcRunningCustomCommandStatus: "ćŁĺś¨čżčˇŚč‡Şĺ®šäą‰ĺ‘˝ä»¤",
LcSubmoduleStashAndReset: "ĺ放未ćŹäş¤çš„ĺ模块更改和更新",
LcAndResetSubmodules: "和重置ĺ模块",
@@ -393,14 +404,14 @@ func chineseTranslationSet() TranslationSet {
LcCopySubmoduleNameToClipboard: "ĺ°†ĺ模块ĺŤç§°ĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż",
RemoveSubmodule: "ĺ 除ĺ模块",
LcRemoveSubmodule: "ĺ 除ĺ模块",
- RemoveSubmodulePrompt: "您确定č¦ĺ 除ĺ模块 %s 及其对应的目录ĺ—?这ćŻä¸ŤĺŹŻé€†çš„ă€‚",
+ RemoveSubmodulePrompt: "您确定č¦ĺ 除ĺ模块 '%s' 及其对应的目录ĺ—?这ćŻä¸ŤĺŹŻé€†çš„ă€‚",
LcResettingSubmoduleStatus: "ćŁĺś¨é‡Ťç˝®ĺ模块",
LcNewSubmoduleName: "ć–°çš„ĺ模块ĺŤç§°ďĽš",
LcNewSubmoduleUrl: "ć–°çš„ĺ模块 URL:",
LcNewSubmodulePath: "ć–°çš„ĺ模块路径:",
LcAddSubmodule: "ć·»ĺŠ ć–°çš„ĺ模块",
LcAddingSubmoduleStatus: "ć·»ĺŠ ĺ模块",
- LcUpdateSubmoduleUrl: "ć›´ć–°ĺ模块 %s çš„ URL",
+ LcUpdateSubmoduleUrl: "ć›´ć–°ĺ模块 '%s' çš„ URL",
LcUpdatingSubmoduleUrlStatus: "ć›´ć–° URL ä¸",
LcEditSubmoduleUrl: "ć›´ć–°ĺ模块 URL",
LcInitializingSubmoduleStatus: "ćŁĺś¨ĺťĺ§‹ĺŚ–ĺ模块",
@@ -417,6 +428,8 @@ func chineseTranslationSet() TranslationSet {
SubmodulesTitle: "ĺ模块",
NavigationTitle: "ĺ—表面板导čŞ",
SuggestionsCheatsheetTitle: "意č§ĺ»şč®®",
+ SuggestionsTitle: "意č§ĺ»şč®® (点击 %s 以čšç„¦)",
+ ExtrasTitle: "é™„ĺŠ ",
PushingTagStatus: "推é€ć ‡çľ",
PullRequestURLCopiedToClipboard: "抓取请求网址已复ĺ¶ĺ°ĺ‰Şč´´ćťż",
CommitMessageCopiedToClipboard: "ćŹäş¤ć¶ćŻĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż",
@@ -428,23 +441,38 @@ func chineseTranslationSet() TranslationSet {
ToggleShowCommandLog: "ĺ‡ćŤ˘ ćľç¤ş/éšč—Ź ĺ‘˝ä»¤ć—Ąĺż—",
FocusCommandLog: "焦点命令日志",
CommandLogHeader: "您可以通过按 '%s' éšč—Źć–集ä¸ćľç¤şčŻĄéť˘ćťżďĽŚć–使用 `gui.showCommandLog: false`\n将其永久éšč—Źĺś¨ć‚¨çš„é…Ťç˝®ä¸",
- RandomTip: "随机ćŹç¤ş",
- SelectRemoteRepository: "选择ĺ储库",
- LcSelectingRemote: "选择éĄćŽ§ĺ™¨",
+ RandomTip: "随机小ćŹç¤ş",
SelectParentCommitForMerge: "选择ç¶ćŹäş¤čż›čˇŚĺĺą¶",
- ToggleWhitespaceInDiffView: "ĺ‡ćŤ˘ćŻĺ¦ĺś¨ĺ·®ĺĽ‚视图ä¸ćľç¤şç©şç™˝ć›´ć”ą",
- IgnoringWhitespaceInDiffView: "差异视图ä¸çš„ç©şć Ľĺ°†č˘«ĺż˝ç•Ą",
- ShowingWhitespaceInDiffView: "空白将ćľç¤şĺś¨ĺ·®ĺĽ‚视图ä¸",
- LcCreateOrShowPullRequest: "ĺ›ĺ»şćŠ“ĺŹ–čŻ·ć±‚",
- CreateOrOpenPullRequestOptions: "ĺ›ĺ»şćŠ“ĺŹ–čŻ·ć±‚é€‰éˇą",
+ ToggleWhitespaceInDiffView: "ĺ‡ćŤ˘ćŻĺ¦ĺś¨ĺ·®ĺĽ‚视图ä¸ćľç¤şç©şç™˝ĺ—符差异",
+ IgnoringWhitespaceInDiffView: "将会在差异视图ä¸ĺż˝ç•Ąç©şć Ľĺ—符差异",
+ ShowingWhitespaceInDiffView: "将会在差异视图ä¸ćľç¤şç©şç™˝ĺ—符差异",
+ IncreaseContextInDiffView: "扩大差异视图ä¸ćľç¤şçš„上下文čŚĺ›´",
+ DecreaseContextInDiffView: "缩小差异视图ä¸ćľç¤şçš„上下文čŚĺ›´",
+ CreatePullRequest: "ĺ›ĺ»şćŠ“ĺŹ–čŻ·ć±‚",
+ CreatePullRequestOptions: "ĺ›ĺ»şćŠ“ĺŹ–čŻ·ć±‚é€‰éˇą",
+ LcCreatePullRequestOptions: "ĺ›ĺ»şćŠ“ĺŹ–čŻ·ć±‚é€‰éˇą",
LcDefaultBranch: "é»č®¤ĺ†ć”Ż",
LcSelectBranch: "选择ĺ†ć”Ż",
- CreatingPullRequestAtUrl: "在 URL ĺ›ĺ»şćŠ“ĺŹ–čŻ·ć±‚: %s",
- OpenPr: "公开公关 #",
+ SelectConfigFile: "选择配置文件",
+ NoConfigFileFoundErr: "找不ĺ°é…Ťç˝®ć–‡ä»¶",
+ LcLoadingFileSuggestions: "ćŁĺś¨ĺŠ č˝˝ć–‡ä»¶ĺ»şč®®",
+ LcLoadingCommits: "ćŁĺś¨ĺŠ č˝˝ćŹäş¤",
+ MustSpecifyOriginError: "指定ĺ†ć”Żć—¶ďĽŚĺż…须ĺŚć—¶ćŚ‡ĺ®ščżśç¨‹",
+ GitOutput: "Git 输出:",
+ GitCommandFailed: "Git ĺ‘˝ä»¤ć‰§čˇŚĺ¤±č´Ąă€‚ćźĄçś‹ĺ‘˝ä»¤ć—Ąĺż—äş†č§ŁčŻ¦ć… (使用 %s 打开)",
+ AbortTitle: "ć”ľĺĽ %s",
+ AbortPrompt: "您确定č¦ć”ľĺĽĺ˝“前 %s ĺ—?",
+ LcOpenLogMenu: "打开日志菜单",
+ LogMenuTitle: "ćŹäş¤ć—Ąĺż—选项",
+ ToggleShowGitGraphAll: "ĺ‡ćŤ˘ćľç¤şĺ®Ść•´ git ĺ†ć”Żĺ›ľ (ĺ‘ `git log` ĺ‘˝ä»¤äĽ ĺ…Ą `--all` 选项)",
+ ShowGitGraph: "ćľç¤ş git ĺ†ć”Żĺ›ľ",
+ SortCommits: "ćŹäş¤ćŽ’ĺşŹ",
+ CantChangeContextSizeError: "ć— ćł•ĺś¨čˇĄä¸ćž„ĺ»şć¨ˇĺĽŹä¸‹ć›´ć”ąä¸Šä¸‹ć–‡ďĽŚĺ› ä¸şć‘们在发ĺ¸čŻĄĺŠźč˝ć—¶ć‡’得支ćŚĺ®ă€‚ ĺ¦‚ćžśä˝ çśźçš„ćłč¦čż™äąĺšďĽŚčŻ·ĺ‘ŠčŻ‰ć‘们ďĽ",
+ LcOpenCommitInBrowser: "在浏č§ĺ™¨ä¸ć‰“开ćŹäş¤",
+ LcViewBisectOptions: "查看二ĺ†ćźĄć‰ľé€‰éˇą",
Actions: Actions{
// TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)
CheckoutCommit: "检出ćŹäş¤",
- CheckoutReflogCommit: "检出reflogćŹäş¤",
CheckoutTag: "ćŁ€ĺ‡şć ‡çľ",
CheckoutBranch: "检出ĺ†ć”Ż",
ForceCheckoutBranch: "强ĺ¶ćŁ€ĺ‡şĺ†ć”Ż",
@@ -453,7 +481,7 @@ func chineseTranslationSet() TranslationSet {
RebaseBranch: "ĺŹĺźşĺ†ć”Ż",
RenameBranch: "重命ĺŤĺ†ć”Ż",
CreateBranch: "建立ĺ†ć”Ż",
- CherryPick: "拣选ćŹäş¤",
+ CherryPick: "(拣选) ç˛č´´ćŹäş¤",
CheckoutFile: "检出文件",
DiscardOldFileChange: "放ĺĽć—§ć–‡ä»¶ć›´ć”ą",
SquashCommitDown: "ĺ‘下压缩ćŹäş¤",
@@ -464,31 +492,33 @@ func chineseTranslationSet() TranslationSet {
AmendCommit: "修改ćŹäş¤",
RevertCommit: "čżĺŽźćŹäş¤",
CreateFixupCommit: "ĺ›ĺ»şäż®ćŁćŹäş¤",
- SquashAllAboveFixupCommits: "压缩所有以上的修ćŁćŹäş¤",
- CreateLightweightTag: "ĺ›ĺ»şč˝»é‡Źçş§ć ‡çľ",
+ SquashAllAboveFixupCommits: "压缩以上所有的修ćŁćŹäş¤",
+ CreateLightweightTag: "ĺ›ĺ»şč˝»é‡Źć ‡çľ",
+ CreateAnnotatedTag: "ĺ›ĺ»şé™„ćł¨ć ‡çľ",
CopyCommitMessageToClipboard: "ĺ°†ćŹäş¤ć¶ćŻĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż",
- MoveCommitUp: "ĺ‘上ćŹäş¤",
+ MoveCommitUp: "上移ćŹäş¤",
MoveCommitDown: "下移ćŹäş¤",
CustomCommand: "自定义命令",
- DiscardAllChangesInDirectory: "放ĺĽç›®ĺ˝•ä¸çš„所有更改",
- DiscardUnstagedChangesInDirectory: "放ĺĽç›®ĺ˝•ä¸ćśŞćš‚ĺ的更改",
- DiscardAllChangesInFile: "放ĺĽć–‡ä»¶ä¸çš„所有更改",
+ DiscardAllChangesInDirectory: "丢ĺĽç›®ĺ˝•ä¸çš„所有更改",
+ DiscardUnstagedChangesInDirectory: "丢ĺĽç›®ĺ˝•ä¸ćśŞćš‚ĺ的更改",
+ DiscardAllChangesInFile: "丢ĺĽć–‡ä»¶ä¸çš„所有更改",
DiscardAllUnstagedChangesInFile: "丢ĺĽć–‡ä»¶ä¸ć‰€ćś‰ćśŞćš‚ĺ的更改",
StageFile: "ćš‚ĺ文件",
- UnstageFile: "未暂ĺ文件",
+ UnstageFile: "取ć¶ćš‚ĺ文件",
UnstageAllFiles: "取ć¶ćš‚ĺ所有文件",
StageAllFiles: "ćš‚ĺ所有文件",
- IgnoreFile: "忽略文件",
- Commit: "ćŹäş¤(Commit)",
+ LcIgnoreExcludeFile: "忽略文件",
+ Commit: "ćŹäş¤ (Commit)",
EditFile: "编辑文件",
- Push: "推é€(Push)",
- Pull: "拉取(Pull)",
+ Push: "ćŽ¨é€ (Push)",
+ Pull: "拉取 (Pull)",
OpenFile: "打开文件",
- StashAllChanges: "Stash所有更改",
- StashStagedChanges: "Stashćš‚ĺ更改",
- GitFlowFinish: "Gitćµĺ®Ść",
- GitFlowStart: "Git Flow开始",
+ StashAllChanges: "贮藏所有更改",
+ StashStagedChanges: "贮藏暂ĺ的更改",
+ GitFlowFinish: "Git flow 结果",
+ GitFlowStart: "Git Flow 开始",
CopyToClipboard: "复ĺ¶ĺ°ĺ‰Şč´´ćťż",
+ CopySelectedTextToClipboard: "将选ä¸ć–‡ćś¬ĺ¤Ťĺ¶ĺ°ĺ‰Şč´´ćťż",
RemovePatchFromCommit: "从ćŹäş¤ä¸ĺ 除补ä¸",
MovePatchToSelectedCommit: "将补ä¸ç§»ĺЍĺ°é€‰ĺ®šçš„ćŹäş¤",
MovePatchIntoIndex: "将补ä¸ç§»ĺ°ç´˘ĺĽ•",
@@ -497,22 +527,21 @@ func chineseTranslationSet() TranslationSet {
SetBranchUpstream: "设置ĺ†ć”Żä¸Šć¸¸",
AddRemote: "ć·»ĺŠ čżśç¨‹",
RemoveRemote: "移除远程",
- UpdateRemote: "远程更新",
- ApplyPatch: "套用补ä¸",
- Stash: "Stash",
+ UpdateRemote: "更新远程",
+ ApplyPatch: "应用补ä¸",
+ Stash: "贮藏 (Stash)",
RemoveSubmodule: "ĺ 除ĺ模块",
ResetSubmodule: "重置ĺ模块",
AddSubmodule: "ć·»ĺŠ ĺ模块",
- UpdateSubmoduleUrl: "ć›´ć–°ĺ模块URL",
+ UpdateSubmoduleUrl: "ć›´ć–°ĺ模块 URL",
InitialiseSubmodule: "ĺťĺ§‹ĺŚ–ĺ模块",
BulkInitialiseSubmodules: "批量ĺťĺ§‹ĺŚ–ĺ模块",
BulkUpdateSubmodules: "批量更新ĺ模块",
- BulkStashAndResetSubmodules: "批量ĺ储和重置ĺ模块",
BulkDeinitialiseSubmodules: "批量取ć¶ĺťĺ§‹ĺŚ–ĺ模块",
UpdateSubmodule: "ć›´ć–°ĺ模块",
DeleteTag: "ĺ 除㠇çľ",
PushTag: "推é€ć ‡çľ",
- NukeWorkingTree: "Nukeĺ·Ąä˝ść ‘",
+ NukeWorkingTree: "Nuke ĺ·Ąä˝ść ‘",
DiscardUnstagedFileChanges: "放ĺĽćśŞćš‚ĺ的文件更改",
RemoveUntrackedFiles: "ĺ 除未跟踪的文件",
SoftReset: "软重置",
@@ -521,6 +550,26 @@ func chineseTranslationSet() TranslationSet {
FastForwardBranch: "快进ĺ†ć”Ż",
Undo: "撤销",
Redo: "重ĺš",
+ CopyPullRequestURL: "复ĺ¶ć‹‰ĺŹ–čŻ·ć±‚ URL",
+ OpenMergeTool: "打开ĺĺą¶ĺ·Ąĺ…·",
+ OpenCommitInBrowser: "在浏č§ĺ™¨ä¸ć‰“开ćŹäş¤",
+ OpenPullRequest: "在浏č§ĺ™¨ä¸ć‰“开拉取请求",
+ StartBisect: "开始二ĺ†ćźĄć‰ľ (Bisect)",
+ ResetBisect: "重置二ĺ†ćźĄć‰ľ",
+ BisectSkip: "二ĺ†ćźĄć‰ľč·łčż‡",
+ BisectMark: "二ĺ†ćźĄć‰ľć ‡č®°",
+ },
+ Bisect: Bisect{
+ Mark: "ĺ°† %s ć ‡č®°ä¸ş %s",
+ MarkStart: "ĺ°† %s ć ‡č®°ä¸ş %s (start bisect)",
+ Skip: "跳过 %s",
+ ResetTitle: "重置 'git bisect'",
+ ResetPrompt: "您确定č¦é‡Ťç˝® 'git bisect' ĺ—?",
+ ResetOption: "重置二ĺ†ćźĄć‰ľ",
+ BisectMenuTitle: "二ĺ†ćźĄć‰ľ",
+ CompleteTitle: "二ĺ†ćźĄć‰ľĺ®Ść",
+ CompletePrompt: "二ĺ†ćźĄć‰ľĺ®ŚćďĽä»Ąä¸‹ćŹäş¤ĺĽ•入了ć¤ĺŹć›´ďĽš\n\n%s\n\n您现在č¦é‡Ťç˝® 'git bisect' ĺ—?",
+ CompletePromptIndeterminate: "二ĺ†ćźĄć‰ľĺ®ŚćďĽä¸€äş›ćŹäş¤č˘«č·łčż‡äş†ďĽŚć‰€ä»Ąä¸‹ĺ—ćŹäş¤ä¸çš„任何一个é˝ĺŹŻč˝ĺĽ•入了ć¤ĺŹć›´ďĽš\n\n%s\n\n您现在č¦é‡Ťç˝® 'git bisect' ĺ—?",
},
}
}
diff --git a/pkg/i18n/dutch.go b/pkg/i18n/dutch.go
index 09e79473c..1aaee6952 100644
--- a/pkg/i18n/dutch.go
+++ b/pkg/i18n/dutch.go
@@ -18,8 +18,9 @@ func dutchTranslationSet() TranslationSet {
CredentialsPassword: "Wachtwoord",
CredentialsPassphrase: "Voer een wachtwoordzin in voor de SSH-sleutel",
PassUnameWrong: "Wachtwoord en/of gebruikersnaam verkeerd",
- CommitChanges: "Commit veranderingen",
+ CommitChanges: "commit veranderingen",
AmendLastCommit: "wijzig laatste commit",
+ AmendLastCommitTitle: "Wijzig Laatste Commit",
SureToAmend: "Weet je zeker dat je de laatste commit wilt wijzigen? U kunt het commit-bericht wijzigen vanuit het commits-paneel.",
NoCommitToAmend: "Er is geen commits om te wijzigen.",
CommitChangesWithEditor: "commit veranderingen met de git editor",
@@ -33,7 +34,6 @@ func dutchTranslationSet() TranslationSet {
LcPush: "push",
LcPull: "pull",
LcScroll: "scroll",
- LcCommitFileFilter: "Commit dossiers filteren",
FilterStagedFiles: "Show only staged files",
FilterUnstagedFiles: "Show only unstaged files",
ResetCommitFilterState: "Reset commit file state filter",
@@ -68,7 +68,6 @@ func dutchTranslationSet() TranslationSet {
LcClose: "sluiten",
LcQuit: "quit",
LcSquashDown: "squash beneden",
- LcResetToThisCommit: "reset naar deze commit",
LcFixupCommit: "Fixup commit",
OnlySquashTopmostCommit: "Kan alleen bovenste commit squashen",
YouNoCommitsToSquash: "Je hebt geen commits om mee te squashen",
@@ -107,7 +106,6 @@ func dutchTranslationSet() TranslationSet {
NoTrackedStagedFilesStash: "Je hebt geen tracked/staged bestanden om te laten stashen",
StashChanges: "Stash veranderingen",
NoChangedFiles: "Geen veranderde bestanden",
- MergeAborted: "Merge afgebroken",
OpenConfig: "open config bestand",
EditConfig: "verander config bestand",
ForcePush: "Forceer push",
@@ -143,7 +141,7 @@ func dutchTranslationSet() TranslationSet {
ToggleDragSelect: `toggle drag selecteer`,
ToggleSelectHunk: `toggle selecteer hunk`,
ToggleSelectionForPatch: `voeg toe/verwijder lijn(en) in patch`,
- TogglePanel: `ga naar een ander paneel`,
+ ToggleStagingPanel: `ga naar een ander paneel`,
ReturnToFilesPanel: `ga terug naar het bestanden paneel`,
FastForward: `fast-forward deze branch vanaf zijn upstream`,
Fetching: "fetching en fast-forwarding {{.from}} -> {{.to}} ...",
@@ -157,17 +155,16 @@ func dutchTranslationSet() TranslationSet {
MergeOptionsTitle: "Merge Opties",
RebaseOptionsTitle: "Rebase Opties",
CommitMessageTitle: "Commit Bericht",
- LocalBranchesTitle: "Branches Tabblad",
+ LocalBranchesTitle: "Branches",
SearchTitle: "Zoek",
- TagsTitle: "Tags Tabblad",
+ TagsTitle: "Tags",
MenuTitle: "Menu",
- RemotesTitle: "Remotes Tabblad",
- CredentialsTitle: "Credentials",
- RemoteBranchesTitle: "Remote Branches (in Remotes tabblad)",
+ RemotesTitle: "Remotes",
+ RemoteBranchesTitle: "Remote Branches",
PatchBuildingTitle: "Patch Bouwen",
InformationTitle: "Informatie",
SecondaryTitle: "Secondary",
- ReflogCommitsTitle: "Reflog Tabblad",
+ ReflogCommitsTitle: "Reflog",
GlobalTitle: "Globale Sneltoetsen",
ConflictsResolved: "alle merge conflicten zijn opgelost. Wilt je verder gaan?",
RebasingTitle: "Rebasen",
@@ -215,7 +212,7 @@ func dutchTranslationSet() TranslationSet {
RedoingStatus: "redoing",
CheckingOutStatus: "uitchecken",
CommitFiles: "Commit bestanden",
- LcViewCommitFiles: "bekijk gecommite bestanden",
+ LcViewItemFiles: "bekijk gecommite bestanden",
CommitFilesTitle: "Commit bestanden",
LcCheckoutCommitFile: "bestand uitchecken",
LcDiscardOldFileChange: "uitsluit deze commit zijn veranderingen aan dit bestand",
@@ -241,7 +238,7 @@ func dutchTranslationSet() TranslationSet {
SureSquashAboveCommits: `Weet je zeker dat je alles wil squash/fixup! voor de bovenstaand commits {{.commit}}?`,
CreateFixupCommit: `Creëer fixup commit`,
SureCreateFixupCommit: `Weet je zeker dat je een fixup wil maken! commit voor commit {{.commit}}?`,
- LcExecuteCustomCommand: "voor aangepaste commando uit",
+ LcExecuteCustomCommand: "voer aangepaste commando uit",
CustomCommand: "Aangepaste commando:",
LcCommitChangesWithoutHook: "commit veranderingen zonder pre-commit hook",
SkipHookPrefixNotConfigured: "Je hebt nog niet een commit bericht voorvoegsel ingesteld voor het overslaan van hooks. Set `git.skipHookPrefix = 'WIP'` in je config",
@@ -249,7 +246,7 @@ func dutchTranslationSet() TranslationSet {
PressEnterToReturn: "Press om terug te gaan naar lazygit",
LcViewStashOptions: "bekijk stash opties",
LcStashAllChanges: "stash-bestanden",
- LcStashStagedChanges: "stash staged wijzigingen",
+ LcStashAllChangesKeepIndex: "stash staged wijzigingen",
LcStashOptions: "Stash opties",
NotARepository: "Fout: moet in een git repository uitgevoerd worden",
LcJump: "ga naar paneel",
@@ -261,9 +258,9 @@ func dutchTranslationSet() TranslationSet {
PatchOptionsTitle: "Patch Opties",
NoPatchError: "Nog geen patch gecreëerd. Om een patch te bouwen gebruik 'space' op een commit bestand of 'enter' om een spesiefieke lijnen toe te voegen",
LcEnterFile: "enter bestand om geselecteerde regels toe te voegen aan de patch",
- ExitLineByLineMode: `sluit lijn-bij-lijn modus`,
+ ExitCustomPatchBuilder: `sluit lijn-bij-lijn modus`,
EnterUpstream: `Enter upstream als ' '`,
- ReturnToRemotesList: `Ga terug naar remotes lijst`,
+ ReturnToRemotesList: `ga terug naar remotes lijst`,
LcAddNewRemote: `voeg een nieuwe remote toe`,
LcNewRemoteName: `Nieuwe remote name:`,
LcNewRemoteUrl: `Nieuwe remote url:`,
@@ -274,6 +271,7 @@ func dutchTranslationSet() TranslationSet {
DeleteRemoteBranch: "Verwijder Remote Branch",
DeleteRemoteBranchMessage: "Weet je zeker dat je deze remote branch wilt verwijderen",
LcSetUpstream: "stel in als upstream van uitgecheckte branch",
+ LcSetAsUpstream: "stel in als upstream van uitgecheckte branch",
SetUpstreamTitle: "Stel in als upstream branch",
SetUpstreamMessage: "Weet je zeker dat je de upstream branch van '{{.checkedOut}}' naar '{{.selected}}' wilt zetten",
LcEditRemote: "wijzig remote",
@@ -305,7 +303,6 @@ func dutchTranslationSet() TranslationSet {
NewBranchNamePrompt: "Noem een nieuwe branch naam",
RenameBranchWarning: "Deze branch volgt een remote. Deze actie zal alleen de locale branch name wijzigen niet de naam van de remote branch. Verder gaan?",
LcOpenMenu: "open menu",
- LcCloseMenu: "sluit menu",
LcResetCherryPick: "reset cherry-picked (gekopieerde) commits selectie",
LcNextTab: "volgende tabblad",
LcPrevTab: "vorige tabblad",
@@ -358,9 +355,6 @@ func dutchTranslationSet() TranslationSet {
LcAddSubmodule: "voeg nieuwe submodule toe",
LcInitSubmodule: "initialiseer submodule",
LcViewBulkSubmoduleOptions: "bekijk bulk submodule opties",
- LcViewStashFiles: "bekijk bestanden van stash entry",
- CreateOrOpenPullRequestOptions: "Bekijk opties voor pull-aanvraag",
- LcCreateOrOpenPullRequestOptions: "bekijk opties voor pull-aanvraag",
CreatePullRequestOptions: "Bekijk opties voor pull-aanvraag",
LcCreatePullRequestOptions: "bekijk opties voor pull-aanvraag",
ConfirmRevertCommit: "Weet u zeker dat u {{.selectedCommit}} ongedaan wilt maken?",
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index 222ad4d89..d993d70d0 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -22,7 +22,9 @@ type TranslationSet struct {
MainTitle string
StagingTitle string
MergingTitle string
+ MergeConfirmTitle string
NormalTitle string
+ LogTitle string
CommitMessage string
CredentialsUsername string
CredentialsPassword string
@@ -30,6 +32,7 @@ type TranslationSet struct {
PassUnameWrong string
CommitChanges string
AmendLastCommit string
+ AmendLastCommitTitle string
SureToAmend string
NoCommitToAmend string
CommitChangesWithEditor string
@@ -46,7 +49,7 @@ type TranslationSet struct {
LcPush string
LcPull string
LcScroll string
- LcCommitFileFilter string
+ LcFileFilter string
FilterStagedFiles string
FilterUnstagedFiles string
ResetCommitFilterState string
@@ -82,7 +85,6 @@ type TranslationSet struct {
LcClose string
LcQuit string
LcSquashDown string
- LcResetToThisCommit string
LcFixupCommit string
OnlySquashTopmostCommit string
YouNoCommitsToSquash string
@@ -98,6 +100,9 @@ type TranslationSet struct {
LcMoveUpCommit string
LcEditCommit string
LcAmendToCommit string
+ LcResetCommitAuthor string
+ SetAuthorPromptTitle string
+ SureResetCommitAuthor string
LcRenameCommitEditor string
NoCommitsThisBranch string
Error string
@@ -108,6 +113,8 @@ type TranslationSet struct {
LcUndo string
LcUndoReflog string
LcRedoReflog string
+ UndoTooltip string
+ RedoTooltip string
LcPop string
LcDrop string
LcApply string
@@ -119,8 +126,8 @@ type TranslationSet struct {
StashApply string
SureApplyStashEntry string
NoTrackedStagedFilesStash string
+ NoFilesToStash string
StashChanges string
- MergeAborted string
OpenConfig string
EditConfig string
ForcePush string
@@ -129,9 +136,18 @@ type TranslationSet struct {
UpdatesRejectedAndForcePushDisabled string
LcCheckForUpdate string
CheckingForUpdates string
+ UpdateAvailableTitle string
+ UpdateAvailable string
+ UpdateInProgressWaitingStatus string
+ UpdateCompletedTitle string
+ UpdateCompleted string
+ FailedToRetrieveLatestVersionErr string
OnLatestVersionErr string
MajorVersionErr string
CouldNotFindBinaryErr string
+ UpdateFailedErr string
+ ConfirmQuitDuringUpdateTitle string
+ ConfirmQuitDuringUpdate string
MergeToolTitle string
MergeToolPrompt string
IntroPopupMessage string
@@ -139,6 +155,7 @@ type TranslationSet struct {
LcEditFile string
LcOpenFile string
LcIgnoreFile string
+ LcExcludeFile string
LcRefreshFiles string
LcMergeIntoCurrentBranch string
ConfirmQuit string
@@ -158,7 +175,8 @@ type TranslationSet struct {
ToggleDragSelect string
ToggleSelectHunk string
ToggleSelectionForPatch string
- TogglePanel string
+ EditHunk string
+ ToggleStagingPanel string
ReturnToFilesPanel string
FastForward string
Fetching string
@@ -177,7 +195,6 @@ type TranslationSet struct {
TagsTitle string
MenuTitle string
RemotesTitle string
- CredentialsTitle string
RemoteBranchesTitle string
PatchBuildingTitle string
InformationTitle string
@@ -231,7 +248,10 @@ type TranslationSet struct {
CheckingOutStatus string
CommittingStatus string
CommitFiles string
- LcViewCommitFiles string
+ SubCommitsDynamicTitle string
+ CommitFilesDynamicTitle string
+ RemoteBranchesDynamicTitle string
+ LcViewItemFiles string
CommitFilesTitle string
LcCheckoutCommitFile string
LcDiscardOldFileChange string
@@ -239,6 +259,9 @@ type TranslationSet struct {
DiscardFileChangesPrompt string
DisabledForGPG string
CreateRepo string
+ InitialBranch string
+ NoRecentRepositories string
+ IncorrectNotARepository string
AutoStashTitle string
AutoStashPrompt string
StashPrefix string
@@ -249,6 +272,7 @@ type TranslationSet struct {
LcDiscardAllChangesToAllFiles string
LcDiscardAnyUnstagedChanges string
LcDiscardUntrackedFiles string
+ LcDiscardStagedChanges string
LcHardReset string
LcViewResetOptions string
LcCreateFixupCommit string
@@ -266,6 +290,8 @@ type TranslationSet struct {
LcViewStashOptions string
LcStashAllChanges string
LcStashStagedChanges string
+ LcStashAllChangesKeepIndex string
+ LcStashUnstagedChanges string
LcStashOptions string
NotARepository string
LcJump string
@@ -276,11 +302,13 @@ type TranslationSet struct {
DiscardPatchConfirm string
CantPatchWhileRebasingError string
LcToggleAddToPatch string
+ LcToggleAllInPatch string
+ LcUpdatingPatch string
ViewPatchOptions string
PatchOptionsTitle string
NoPatchError string
LcEnterFile string
- ExitLineByLineMode string
+ ExitCustomPatchBuilder string
EnterUpstream string
InvalidUpstream string
ReturnToRemotesList string
@@ -293,7 +321,9 @@ type TranslationSet struct {
LcRemoveRemotePrompt string
DeleteRemoteBranch string
DeleteRemoteBranchMessage string
+ LcSetAsUpstream string
LcSetUpstream string
+ LcUnsetUpstream string
SetUpstreamTitle string
SetUpstreamMessage string
LcEditRemote string
@@ -318,7 +348,9 @@ type TranslationSet struct {
NotAGitFlowBranch string
NewBranchNamePrompt string
IgnoreTracked string
+ ExcludeTracked string
IgnoreTrackedPrompt string
+ ExcludeTrackedPrompt string
LcViewResetToUpstreamOptions string
LcNextScreenMode string
LcPrevScreenMode string
@@ -326,10 +358,10 @@ type TranslationSet struct {
Panel string
Keybindings string
LcRenameBranch string
+ LcSetUnsetUpstream string
NewGitFlowBranchPrompt string
RenameBranchWarning string
LcOpenMenu string
- LcCloseMenu string
LcResetCherryPick string
LcNextTab string
LcPrevTab string
@@ -361,8 +393,14 @@ type TranslationSet struct {
LcOpenDiffingMenu string
LcOpenExtrasMenu string
LcShowingGitDiff string
+ LcCommitDiff string
LcCopyCommitShaToClipboard string
+ LcCommitSha string
+ LcCommitURL string
LcCopyCommitMessageToClipboard string
+ LcCommitMessage string
+ LcCommitAuthor string
+ LcCopyCommitAttributeToClipboard string
LcCopyBranchNameToClipboard string
LcCopyFileNameToClipboard string
LcCopyCommitFileNameToClipboard string
@@ -372,10 +410,10 @@ type TranslationSet struct {
NoFilesStagedPrompt string
BranchNotFoundTitle string
BranchNotFoundPrompt string
+ LcBranchUnknown string
UnstageLinesTitle string
UnstageLinesPrompt string
LcCreateNewBranchFromCommit string
- LcViewStashFiles string
LcBuildingPatch string
LcViewCommits string
MinGitVersionError string
@@ -416,7 +454,11 @@ type TranslationSet struct {
ExtrasTitle string
PushingTagStatus string
PullRequestURLCopiedToClipboard string
+ CommitDiffCopiedToClipboard string
+ CommitSHACopiedToClipboard string
+ CommitURLCopiedToClipboard string
CommitMessageCopiedToClipboard string
+ CommitAuthorCopiedToClipboard string
LcCopiedToClipboard string
ErrCannotEditDirectory string
ErrStageDirWithInlineMergeConflicts string
@@ -462,6 +504,16 @@ type TranslationSet struct {
LcOpenCommitInBrowser string
LcViewBisectOptions string
ConfirmRevertCommit string
+ RewordInEditorTitle string
+ RewordInEditorPrompt string
+ CheckoutPrompt string
+ HardResetAutostashPrompt string
+ UpstreamGone string
+ NukeDescription string
+ DiscardStagedChangesDescription string
+ EmptyOutput string
+ Patch string
+ CustomPatch string
Actions Actions
Bisect Bisect
}
@@ -483,7 +535,6 @@ type Bisect struct {
type Actions struct {
CheckoutCommit string
- CheckoutReflogCommit string
CheckoutTag string
CheckoutBranch string
ForceCheckoutBranch string
@@ -491,6 +542,7 @@ type Actions struct {
Merge string
RebaseBranch string
RenameBranch string
+ SetUnsetUpstream string
CreateBranch string
FastForwardBranch string
CherryPick string
@@ -502,12 +554,19 @@ type Actions struct {
DropCommit string
EditCommit string
AmendCommit string
+ ResetCommitAuthor string
+ SetCommitAuthor string
RevertCommit string
CreateFixupCommit string
SquashAllAboveFixupCommits string
MoveCommitUp string
MoveCommitDown string
CopyCommitMessageToClipboard string
+ CopyCommitDiffToClipboard string
+ CopyCommitSHAToClipboard string
+ CopyCommitURLToClipboard string
+ CopyCommitAuthorToClipboard string
+ CopyCommitAttributeToClipboard string
CustomCommand string
DiscardAllChangesInDirectory string
DiscardUnstagedChangesInDirectory string
@@ -518,14 +577,20 @@ type Actions struct {
UnstageFile string
UnstageAllFiles string
StageAllFiles string
- IgnoreFile string
+ LcIgnoreExcludeFile string
+ IgnoreFileErr string
+ ExcludeFile string
+ ExcludeFileErr string
+ ExcludeGitIgnoreErr string
Commit string
EditFile string
Push string
Pull string
OpenFile string
StashAllChanges string
+ StashAllChangesKeepIndex string
StashStagedChanges string
+ StashUnstagedChanges string
GitFlowFinish string
GitFlowStart string
CopyToClipboard string
@@ -548,7 +613,6 @@ type Actions struct {
InitialiseSubmodule string
BulkInitialiseSubmodules string
BulkUpdateSubmodules string
- BulkStashAndResetSubmodules string
BulkDeinitialiseSubmodules string
UpdateSubmodule string
CreateLightweightTag string
@@ -558,6 +622,7 @@ type Actions struct {
NukeWorkingTree string
DiscardUnstagedFileChanges string
RemoveUntrackedFiles string
+ RemoveStagedFiles string
SoftReset string
MixedReset string
HardReset string
@@ -602,9 +667,11 @@ func EnglishTranslationSet() TranslationSet {
UnstagedChanges: `Unstaged Changes`,
StagedChanges: `Staged Changes`,
MainTitle: "Main",
- StagingTitle: "Staging",
- MergingTitle: "Merging",
- NormalTitle: "Normal",
+ MergeConfirmTitle: "Merge",
+ StagingTitle: "Main Panel (Staging)",
+ MergingTitle: "Main Panel (Merging)",
+ NormalTitle: "Main Panel (Normal)",
+ LogTitle: "Log",
CommitMessage: "Commit message",
CredentialsUsername: "Username",
CredentialsPassword: "Password",
@@ -612,6 +679,7 @@ func EnglishTranslationSet() TranslationSet {
PassUnameWrong: "Password, passphrase and/or username wrong",
CommitChanges: "commit changes",
AmendLastCommit: "amend last commit",
+ AmendLastCommitTitle: "Amend Last Commit",
SureToAmend: "Are you sure you want to amend last commit? Afterwards, you can change commit message from the commits panel.",
NoCommitToAmend: "There's no commit to amend.",
CommitChangesWithEditor: "commit changes using git editor",
@@ -629,7 +697,7 @@ func EnglishTranslationSet() TranslationSet {
LcScroll: "scroll",
MergeConflictsTitle: "Merge Conflicts",
LcCheckout: "checkout",
- LcCommitFileFilter: "Filter commit files",
+ LcFileFilter: "Filter files (staged/unstaged)",
FilterStagedFiles: "Show only staged files",
FilterUnstagedFiles: "Show only unstaged files",
ResetCommitFilterState: "Reset filter",
@@ -659,11 +727,10 @@ func EnglishTranslationSet() TranslationSet {
NoBranchesThisRepo: "No branches for this repo",
CommitMessageConfirm: "{{.keyBindClose}}: close, {{.keyBindNewLine}}: new line, {{.keyBindConfirm}}: confirm",
CommitWithoutMessageErr: "You cannot commit without a commit message",
- CloseConfirm: "{{.keyBindClose}}: close, {{.keyBindConfirm}}: confirm",
+ CloseConfirm: "{{.keyBindClose}}: close/cancel, {{.keyBindConfirm}}: confirm",
LcClose: "close",
LcQuit: "quit",
LcSquashDown: "squash down",
- LcResetToThisCommit: "reset to this commit",
LcFixupCommit: "fixup commit",
NoCommitsThisBranch: "No commits for this branch",
OnlySquashTopmostCommit: "Can only squash topmost commit",
@@ -680,6 +747,9 @@ func EnglishTranslationSet() TranslationSet {
LcMoveUpCommit: "move commit up one",
LcEditCommit: "edit commit",
LcAmendToCommit: "amend commit with staged changes",
+ LcResetCommitAuthor: "reset commit author",
+ SetAuthorPromptTitle: "Set author (must look like 'Name ')",
+ SureResetCommitAuthor: "The author field of this commit will be updated to match the configured user. This also renews the author timestamp. Continue?",
LcRenameCommitEditor: "reword commit with editor",
Error: "Error",
LcSelectHunk: "select hunk",
@@ -689,6 +759,8 @@ func EnglishTranslationSet() TranslationSet {
LcUndo: "undo",
LcUndoReflog: "undo (via reflog) (experimental)",
LcRedoReflog: "redo (via reflog) (experimental)",
+ UndoTooltip: "The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration.",
+ RedoTooltip: "The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration.",
LcPop: "pop",
LcDrop: "drop",
LcApply: "apply",
@@ -700,8 +772,8 @@ func EnglishTranslationSet() TranslationSet {
StashApply: "Stash apply",
SureApplyStashEntry: "Are you sure you want to apply this stash entry?",
NoTrackedStagedFilesStash: "You have no tracked/staged files to stash",
+ NoFilesToStash: "You have no files to stash",
StashChanges: "Stash changes",
- MergeAborted: "Merge aborted",
OpenConfig: "open config file",
EditConfig: "edit config file",
ForcePush: "Force push",
@@ -710,9 +782,18 @@ func EnglishTranslationSet() TranslationSet {
UpdatesRejectedAndForcePushDisabled: "Updates were rejected and you have disabled force pushing",
LcCheckForUpdate: "check for update",
CheckingForUpdates: "Checking for updates...",
+ UpdateAvailableTitle: "Update available!",
+ UpdateAvailable: "Download and install version {{.newVersion}}?",
+ UpdateInProgressWaitingStatus: "updating",
+ UpdateCompletedTitle: "Update completed!",
+ UpdateCompleted: "Update has been installed successfully. Restart lazygit for it to take effect.",
+ FailedToRetrieveLatestVersionErr: "Failed to retrieve version information",
OnLatestVersionErr: "You already have the latest version",
MajorVersionErr: "New version ({{.newVersion}}) has non-backwards compatible changes compared to the current version ({{.currentVersion}})",
CouldNotFindBinaryErr: "Could not find any binary at {{.url}}",
+ UpdateFailedErr: "Update failed: {{.errMessage}}",
+ ConfirmQuitDuringUpdateTitle: "Currently Updating",
+ ConfirmQuitDuringUpdate: "An update is in progress. Are you sure you want to quit?",
MergeToolTitle: "Merge tool",
MergeToolPrompt: "Are you sure you want to open `git mergetool`?",
IntroPopupMessage: englishIntroPopupMessage,
@@ -720,6 +801,7 @@ func EnglishTranslationSet() TranslationSet {
LcEditFile: `edit file`,
LcOpenFile: `open file`,
LcIgnoreFile: `add to .gitignore`,
+ LcExcludeFile: `add to .git/info/exclude`,
LcRefreshFiles: `refresh files`,
LcMergeIntoCurrentBranch: `merge into currently checked out branch`,
ConfirmQuit: `Are you sure you want to quit?`,
@@ -739,7 +821,8 @@ func EnglishTranslationSet() TranslationSet {
ToggleDragSelect: `toggle drag select`,
ToggleSelectHunk: `toggle select hunk`,
ToggleSelectionForPatch: `add/remove line(s) to patch`,
- TogglePanel: `switch to other panel`,
+ EditHunk: `edit hunk`,
+ ToggleStagingPanel: `switch to other panel (staged/unstaged changes)`,
ReturnToFilesPanel: `return to files panel`,
FastForward: `fast-forward this branch from its upstream`,
Fetching: "fetching and fast-forwarding {{.from}} -> {{.to}} ...",
@@ -753,17 +836,16 @@ func EnglishTranslationSet() TranslationSet {
MergeOptionsTitle: "Merge Options",
RebaseOptionsTitle: "Rebase Options",
CommitMessageTitle: "Commit Message",
- LocalBranchesTitle: "Branches Tab",
+ LocalBranchesTitle: "Local Branches",
SearchTitle: "Search",
- TagsTitle: "Tags Tab",
+ TagsTitle: "Tags",
MenuTitle: "Menu",
- RemotesTitle: "Remotes Tab",
- CredentialsTitle: "Credentials",
- RemoteBranchesTitle: "Remote Branches (in Remotes tab)",
- PatchBuildingTitle: "Patch Building",
+ RemotesTitle: "Remotes",
+ RemoteBranchesTitle: "Remote Branches",
+ PatchBuildingTitle: "Main Panel (Patch Building)",
InformationTitle: "Information",
SecondaryTitle: "Secondary",
- ReflogCommitsTitle: "Reflog Tab",
+ ReflogCommitsTitle: "Reflog",
GlobalTitle: "Global Keybindings",
ConflictsResolved: "all merge conflicts resolved. Continue?",
RebasingTitle: "Rebasing",
@@ -813,7 +895,10 @@ func EnglishTranslationSet() TranslationSet {
CheckingOutStatus: "checking out",
CommittingStatus: "committing",
CommitFiles: "Commit files",
- LcViewCommitFiles: "view commit's files",
+ SubCommitsDynamicTitle: "Commits (%s)",
+ CommitFilesDynamicTitle: "Diff files (%s)",
+ RemoteBranchesDynamicTitle: "Remote branches (%s)",
+ LcViewItemFiles: "view selected item's files",
CommitFilesTitle: "Commit Files",
LcCheckoutCommitFile: "checkout file",
LcDiscardOldFileChange: "discard this commit's changes to this file",
@@ -821,6 +906,9 @@ func EnglishTranslationSet() TranslationSet {
DiscardFileChangesPrompt: "Are you sure you want to discard this commit's changes to this file? If this file was created in this commit, it will be deleted",
DisabledForGPG: "Feature not available for users using GPG",
CreateRepo: "Not in a git repository. Create a new git repository? (y/n): ",
+ InitialBranch: "Branch name? (leave empty for git's default): ",
+ NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.",
+ IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.",
AutoStashTitle: "Autostash?",
AutoStashPrompt: "You must stash and pop your changes to bring them across. Do this automatically? (enter/esc)",
StashPrefix: "Auto-stashing changes for ",
@@ -831,6 +919,7 @@ func EnglishTranslationSet() TranslationSet {
LcDiscardAllChangesToAllFiles: "nuke working tree",
LcDiscardAnyUnstagedChanges: "discard unstaged changes",
LcDiscardUntrackedFiles: "discard untracked files",
+ LcDiscardStagedChanges: "discard staged changes",
LcHardReset: "hard reset",
LcViewResetOptions: `view reset options`,
LcCreateFixupCommit: `create fixup commit for this commit`,
@@ -846,8 +935,10 @@ func EnglishTranslationSet() TranslationSet {
LcResetTo: `reset to`,
PressEnterToReturn: "Press enter to return to lazygit",
LcViewStashOptions: "view stash options",
- LcStashAllChanges: "stash changes",
+ LcStashAllChanges: "stash all changes",
LcStashStagedChanges: "stash staged changes",
+ LcStashAllChangesKeepIndex: "stash all changes and keep index",
+ LcStashUnstagedChanges: "stash unstaged changes",
LcStashOptions: "Stash options",
NotARepository: "Error: must be run inside a git repository",
LcJump: "jump to panel",
@@ -858,11 +949,13 @@ func EnglishTranslationSet() TranslationSet {
DiscardPatchConfirm: "You can only build a patch from one commit/stash-entry at a time. Discard current patch?",
CantPatchWhileRebasingError: "You cannot build a patch or run patch commands while in a merging or rebasing state",
LcToggleAddToPatch: "toggle file included in patch",
+ LcToggleAllInPatch: "toggle all files included in patch",
+ LcUpdatingPatch: "updating patch",
ViewPatchOptions: "view custom patch options",
PatchOptionsTitle: "Patch Options",
NoPatchError: "No patch created yet. To start building a patch, use 'space' on a commit file or enter to add specific lines",
LcEnterFile: "enter file to add selected lines to the patch (or toggle directory collapsed)",
- ExitLineByLineMode: `exit line-by-line mode`,
+ ExitCustomPatchBuilder: `exit custom patch builder`,
EnterUpstream: `Enter upstream as ' '`,
InvalidUpstream: "Invalid upstream. Must be in the format ' '",
ReturnToRemotesList: `Return to remotes list`,
@@ -875,7 +968,9 @@ func EnglishTranslationSet() TranslationSet {
LcRemoveRemotePrompt: "Are you sure you want to remove remote",
DeleteRemoteBranch: "Delete Remote Branch",
DeleteRemoteBranchMessage: "Are you sure you want to delete remote branch",
- LcSetUpstream: "set as upstream of checked-out branch",
+ LcSetAsUpstream: "set as upstream of checked-out branch",
+ LcSetUpstream: "set upstream of selected branch",
+ LcUnsetUpstream: "unset upstream of selected branch",
SetUpstreamTitle: "Set upstream branch",
SetUpstreamMessage: "Are you sure you want to set the upstream branch of '{{.checkedOut}}' to '{{.selected}}'",
LcEditRemote: "edit remote",
@@ -901,6 +996,8 @@ func EnglishTranslationSet() TranslationSet {
NewGitFlowBranchPrompt: "new {{.branchType}} name:",
IgnoreTracked: "Ignore tracked file",
IgnoreTrackedPrompt: "Are you sure you want to ignore a tracked file?",
+ ExcludeTracked: "Exclude tracked file",
+ ExcludeTrackedPrompt: "Are you sure you want to exclude a tracked file?",
LcViewResetToUpstreamOptions: "view upstream reset options",
LcNextScreenMode: "next screen mode (normal/half/fullscreen)",
LcPrevScreenMode: "prev screen mode",
@@ -908,10 +1005,10 @@ func EnglishTranslationSet() TranslationSet {
Panel: "Panel",
Keybindings: "Keybindings",
LcRenameBranch: "rename branch",
+ LcSetUnsetUpstream: "set/unset upstream",
NewBranchNamePrompt: "Enter new branch name for branch",
RenameBranchWarning: "This branch is tracking a remote. This action will only rename the local branch name, not the name of the remote branch. Continue?",
LcOpenMenu: "open menu",
- LcCloseMenu: "close menu",
LcResetCherryPick: "reset cherry-picked (copied) commits selection",
LcNextTab: "next tab",
LcPrevTab: "previous tab",
@@ -944,8 +1041,14 @@ func EnglishTranslationSet() TranslationSet {
// the actual view is the extras view which I intend to give more tabs in future but for now we'll only mention the command log part
LcOpenExtrasMenu: "open command log menu",
LcShowingGitDiff: "showing output for:",
+ LcCommitDiff: "commit diff",
LcCopyCommitShaToClipboard: "copy commit SHA to clipboard",
+ LcCommitSha: "commit SHA",
+ LcCommitURL: "commit URL",
LcCopyCommitMessageToClipboard: "copy commit message to clipboard",
+ LcCommitMessage: "commit message",
+ LcCommitAuthor: "commit author",
+ LcCopyCommitAttributeToClipboard: "copy commit attribute",
LcCopyBranchNameToClipboard: "copy branch name to clipboard",
LcCopyFileNameToClipboard: "copy the file name to the clipboard",
LcCopyCommitFileNameToClipboard: "copy the committed file name to the clipboard",
@@ -955,10 +1058,10 @@ func EnglishTranslationSet() TranslationSet {
NoFilesStagedPrompt: "You have not staged any files. Commit all files?",
BranchNotFoundTitle: "Branch not found",
BranchNotFoundPrompt: "Branch not found. Create a new branch named",
+ LcBranchUnknown: "branch unknown",
UnstageLinesTitle: "Unstage lines",
UnstageLinesPrompt: "Are you sure you want to delete the selected lines (git reset)? It is irreversible.\nTo disable this dialogue set the config key of 'gui.skipUnstageLineWarning' to true",
LcCreateNewBranchFromCommit: "create new branch off of commit",
- LcViewStashFiles: "view stash entry's files",
LcBuildingPatch: "building patch",
LcViewCommits: "view commits",
MinGitVersionError: "Git version must be at least 2.0 (i.e. from 2014 onwards). Please upgrade your git version. Alternatively raise an issue at https://github.com/jesseduffield/lazygit/issues for lazygit to be more backwards compatible.",
@@ -995,12 +1098,16 @@ func EnglishTranslationSet() TranslationSet {
NavigationTitle: "List Panel Navigation",
SuggestionsCheatsheetTitle: "Suggestions",
SuggestionsTitle: "Suggestions (press %s to focus)",
- ExtrasTitle: "Extras",
+ ExtrasTitle: "Command Log",
PushingTagStatus: "pushing tag",
SelectRemoteRepository: "select base remote repository",
LcSelectingRemote: "selecting remote",
PullRequestURLCopiedToClipboard: "Pull request URL copied to clipboard",
+ CommitDiffCopiedToClipboard: "Commit diff copied to clipboard",
+ CommitSHACopiedToClipboard: "Commit SHA copied to clipboard",
+ CommitURLCopiedToClipboard: "Commit URL copied to clipboard",
CommitMessageCopiedToClipboard: "Commit message copied to clipboard",
+ CommitAuthorCopiedToClipboard: "Commit author copied to clipboard",
LcCopiedToClipboard: "copied to clipboard",
ErrCannotEditDirectory: "Cannot edit directory: you can only edit individual files",
ErrStageDirWithInlineMergeConflicts: "Cannot stage/unstage directory containing files with inline merge conflicts. Please fix up the merge conflicts first",
@@ -1043,10 +1150,19 @@ func EnglishTranslationSet() TranslationSet {
LcOpenCommitInBrowser: "open commit in browser",
LcViewBisectOptions: "view bisect options",
ConfirmRevertCommit: "Are you sure you want to revert {{.selectedCommit}}?",
+ RewordInEditorTitle: "Reword in editor",
+ RewordInEditorPrompt: "Are you sure you want to reword this commit in your editor?",
+ HardResetAutostashPrompt: "Are you sure you want to hard reset to '%s'? An auto-stash will be performed if necessary.",
+ CheckoutPrompt: "Are you sure you want to checkout '%s'?",
+ UpstreamGone: "(upstream gone)",
+ NukeDescription: "If you want to make all the changes in the worktree go away, this is the way to do it. If there are dirty submodule changes this will stash those changes in the submodule(s).",
+ DiscardStagedChangesDescription: "This will create a new stash entry containing only staged files and then drop it, so that the working tree is left with only unstaged changes",
+ EmptyOutput: "",
+ Patch: "Patch",
+ CustomPatch: "Custom patch",
Actions: Actions{
// TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)
CheckoutCommit: "Checkout commit",
- CheckoutReflogCommit: "Checkout reflog commit",
CheckoutTag: "Checkout tag",
CheckoutBranch: "Checkout branch",
ForceCheckoutBranch: "Force checkout branch",
@@ -1054,6 +1170,7 @@ func EnglishTranslationSet() TranslationSet {
Merge: "Merge",
RebaseBranch: "Rebase branch",
RenameBranch: "Rename branch",
+ SetUnsetUpstream: "Set/unset upstream",
CreateBranch: "Create branch",
CherryPick: "(Cherry-pick) Paste commits",
CheckoutFile: "Checkout file",
@@ -1064,12 +1181,19 @@ func EnglishTranslationSet() TranslationSet {
DropCommit: "Drop commit",
EditCommit: "Edit commit",
AmendCommit: "Amend commit",
+ ResetCommitAuthor: "Reset commit author",
+ SetCommitAuthor: "Set commit author",
RevertCommit: "Revert commit",
CreateFixupCommit: "Create fixup commit",
SquashAllAboveFixupCommits: "Squash all above fixup commits",
CreateLightweightTag: "Create lightweight tag",
CreateAnnotatedTag: "Create annotated tag",
CopyCommitMessageToClipboard: "Copy commit message to clipboard",
+ CopyCommitDiffToClipboard: "Copy commit diff to clipboard",
+ CopyCommitSHAToClipboard: "Copy commit SHA to clipboard",
+ CopyCommitURLToClipboard: "Copy commit URL to clipboard",
+ CopyCommitAuthorToClipboard: "Copy commit author to clipboard",
+ CopyCommitAttributeToClipboard: "Copy to clipboard",
MoveCommitUp: "Move commit up",
MoveCommitDown: "Move commit down",
CustomCommand: "Custom command",
@@ -1082,14 +1206,20 @@ func EnglishTranslationSet() TranslationSet {
UnstageFile: "Unstage file",
UnstageAllFiles: "Unstage all files",
StageAllFiles: "Stage all files",
- IgnoreFile: "Ignore file",
+ LcIgnoreExcludeFile: "ignore or exclude file",
+ IgnoreFileErr: "Cannot ignore .gitignore",
+ ExcludeFile: "Exclude file",
+ ExcludeFileErr: "Cannot exclude .git/info/exclude",
+ ExcludeGitIgnoreErr: "Cannot exclude .gitignore",
Commit: "Commit",
EditFile: "Edit file",
Push: "Push",
Pull: "Pull",
OpenFile: "Open file",
StashAllChanges: "Stash all changes",
+ StashAllChangesKeepIndex: "Stash all changes and keep index",
StashStagedChanges: "Stash staged changes",
+ StashUnstagedChanges: "Stash unstaged changes",
GitFlowFinish: "Git flow finish",
GitFlowStart: "Git Flow start",
CopyToClipboard: "Copy to clipboard",
@@ -1112,7 +1242,6 @@ func EnglishTranslationSet() TranslationSet {
InitialiseSubmodule: "Initialise submodule",
BulkInitialiseSubmodules: "Bulk initialise submodules",
BulkUpdateSubmodules: "Bulk update submodules",
- BulkStashAndResetSubmodules: "Bulk stash and reset submodules",
BulkDeinitialiseSubmodules: "Bulk deinitialise submodules",
UpdateSubmodule: "Update submodule",
DeleteTag: "Delete tag",
@@ -1120,6 +1249,7 @@ func EnglishTranslationSet() TranslationSet {
NukeWorkingTree: "Nuke working tree",
DiscardUnstagedFileChanges: "Discard unstaged file changes",
RemoveUntrackedFiles: "Remove untracked files",
+ RemoveStagedFiles: "Remove staged files",
SoftReset: "Soft reset",
MixedReset: "Mixed reset",
HardReset: "Hard reset",
diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go
index f6c57da8d..6819ecd87 100644
--- a/pkg/i18n/i18n.go
+++ b/pkg/i18n/i18n.go
@@ -50,6 +50,8 @@ func GetTranslationSets() map[string]TranslationSet {
"nl": dutchTranslationSet(),
"en": EnglishTranslationSet(),
"zh": chineseTranslationSet(),
+ "ja": japaneseTranslationSet(),
+ "ko": koreanTranslationSet(),
}
}
diff --git a/pkg/i18n/japanese.go b/pkg/i18n/japanese.go
new file mode 100644
index 000000000..03d949549
--- /dev/null
+++ b/pkg/i18n/japanese.go
@@ -0,0 +1,601 @@
+package i18n
+
+const japaneseIntroPopupMessage = `
+Thanks for using lazygit! Seriously you rock. Three things to share with you:
+
+ 1) If you want to learn about lazygit's features, watch this vid:
+ https://youtu.be/CPLdltN7wgE
+
+ 2) Be sure to read the latest release notes at:
+ https://github.com/jesseduffield/lazygit/releases
+
+ 3) If you're using git, that makes you a programmer! With your help we can make
+ lazygit better, so consider becoming a contributor and joining the fun at
+ https://github.com/jesseduffield/lazygit
+ You can also sponsor me and tell me what to work on by clicking the donate
+ button at the bottom right.
+ Or even just star the repo to share the love!
+`
+
+// exporting this so we can use it in tests
+func japaneseTranslationSet() TranslationSet {
+ return TranslationSet{
+ NotEnoughSpace: "ă‘ăŤă«ă®ćŹŹç”»ă«ĺŤĺ†ăŞç©şé–“ăŚă‚りăľă›ă‚“",
+ DiffTitle: "ĺ·®ĺ†",
+ FilesTitle: "ă•ァイă«",
+ BranchesTitle: "ă–ă©ăłă",
+ CommitsTitle: "コăźăă",
+ StashTitle: "Stash",
+ UnstagedChanges: `スă†ăĽă‚¸ă•れă¦ă„ăŞă„変更`,
+ StagedChanges: `スă†ăĽă‚¸ă•れăźĺ¤‰ć›´`,
+ MainTitle: "ăˇă‚¤ăł",
+ MergeConfirmTitle: "ăžăĽă‚¸",
+ StagingTitle: "ăˇă‚¤ăłă‘ăŤă« (Staging)",
+ MergingTitle: "ăˇă‚¤ăłă‘ăŤă« (Merging)",
+ NormalTitle: "ăˇă‚¤ăłă‘ăŤă« (Normal)",
+ LogTitle: "ăă‚°",
+ CommitMessage: "コăźăăăˇăă‚»ăĽă‚¸",
+ CredentialsUsername: "ă¦ăĽă‚¶ĺŤ",
+ CredentialsPassword: "ă‘スăŻăĽă‰",
+ CredentialsPassphrase: "SSH鍵ă®ă‘スă•ă¬ăĽă‚şă‚’入力",
+ PassUnameWrong: "ă‘スăŻăĽă‰, ă‘スă•ă¬ăĽă‚şăľăźăŻă¦ăĽă‚¶ĺŤăŚé–“é•ăŁă¦ă„ăľă™ă€‚",
+ CommitChanges: "変更をコăźăă",
+ AmendLastCommit: "最新ă®ă‚łăźăăă«amend",
+ AmendLastCommitTitle: "最新ă®ă‚łăźăăă«amend",
+ SureToAmend: "最新ă®ă‚łăźăăă«ĺ¤‰ć›´ă‚’amendă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹? コăźăăăˇăă‚»ăĽă‚¸ăŻă‚łăźăăă‘ăŤă«ă‹ă‚‰ĺ¤‰ć›´ă§ăŤăľă™ă€‚",
+ NoCommitToAmend: "amend可č˝ăŞă‚łăźăăăŚĺ在ă—ăľă›ă‚“。",
+ CommitChangesWithEditor: "gitエă‡ă‚Łă‚żă‚’使用ă—ă¦ĺ¤‰ć›´ă‚’コăźăă",
+ StatusTitle: "スă†ăĽă‚żă‚ą",
+ LcNavigate: "移動",
+ LcMenu: "ăˇă‹ăĄăĽ",
+ LcExecute: "実行",
+ LcToggleStaged: "スă†ăĽă‚¸/アăłă‚ąă†ăĽă‚¸",
+ LcToggleStagedAll: "ă™ăąă¦ă®ĺ¤‰ć›´ă‚’スă†ăĽă‚¸/アăłă‚ąă†ăĽă‚¸",
+ LcToggleTreeView: "ă•ァイă«ă„ăŞăĽă®čˇ¨ç¤şă‚’ĺ‡ă‚Šć›żă",
+ LcOpenMergeTool: "git mergetoolă‚’é–‹ăŹ",
+ LcRefresh: "ăŞă•ă¬ăă‚·ăĄ",
+ LcPush: "push",
+ LcPull: "pull",
+ LcScroll: "スクăăĽă«",
+ MergeConflictsTitle: "ăžăĽă‚¸ă‚łăłă•ăŞă‚Żă",
+ LcCheckout: "ăă‚§ăクアウă",
+ LcFileFilter: "ă•ァイă«ă‚’ă•ィă«ă‚ż (スă†ăĽă‚¸/アăłă‚ąă†ăĽă‚¸)",
+ FilterStagedFiles: "スă†ăĽă‚¸ă•れăźă•ァイă«ă®ăżă‚’表示",
+ FilterUnstagedFiles: "スă†ăĽă‚¸ă•れă¦ă„ăŞă„ă•ァイă«ă®ăżă‚’表示",
+ ResetCommitFilterState: "ă•ィă«ă‚żă‚’ăŞă‚»ăă",
+ // NoChangedFiles: "No changed files",
+ // NoFilesDisplay: "No file to display",
+ // NotAFile: "Not a file",
+ PullWait: "Pullä¸...",
+ PushWait: "Pushä¸...",
+ FetchWait: "Fetchä¸...",
+ LcSoftReset: "softăŞă‚»ăă",
+ AlreadyCheckedOutBranch: "ă–ă©ăłăăŻă™ă§ă«ăă‚§ăクアウăă•れă¦ă„ăľă™ă€‚",
+ // SureForceCheckout: "Are you sure you want force checkout? You will lose all local changes",
+ // ForceCheckoutBranch: "Force Checkout Branch",
+ BranchName: "ă–ă©ăłăĺŤ",
+ NewBranchNameBranchOff: "新規ă–ă©ăłăĺŤ ('{{.branchName}}' ă«ä˝ść)",
+ CantDeleteCheckOutBranch: "ăă‚§ăクアウăä¸ă®ă–ă©ăłăăŻĺ‰Šé™¤ă§ăŤăľă›ă‚“!",
+ DeleteBranch: "ă–ă©ăłăを削除",
+ DeleteBranchMessage: "ă–ă©ăłă '{{.selectedBranchName}}' を削除ă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?",
+ ForceDeleteBranchMessage: "'{{.selectedBranchName}}' ăŻăžăĽă‚¸ă•れă¦ă„ăľă›ă‚“。本当ă«ĺ‰Šé™¤ă—ăľă™ă‹?",
+ // LcRebaseBranch: "rebase checked-out branch onto this branch",
+ CantRebaseOntoSelf: "ă–ă©ăłăを自ĺ†č‡Şčş«ă«ăŞă™ăĽă‚ąă™ă‚‹ă“ă¨ăŻă§ăŤăľă›ă‚“。",
+ CantMergeBranchIntoItself: "ă–ă©ăłăを自ĺ†č‡Şčş«ă«ăžăĽă‚¸ă™ă‚‹ă“ă¨ăŻă§ăŤăľă›ă‚“。",
+ // LcForceCheckout: "force checkout",
+ // LcCheckoutByName: "checkout by name",
+ LcNewBranch: "ć–°ă—ă„ă–ă©ăłăを作ć",
+ LcDeleteBranch: "ă–ă©ăłăを削除",
+ NoBranchesThisRepo: "ăŞăťă‚¸ăăŞă«ă–ă©ăłăăŚĺ在ă—ăľă›ă‚“",
+ CommitMessageConfirm: "{{.keyBindClose}}: é–‰ăă‚‹, {{.keyBindNewLine}}: 改行, {{.keyBindConfirm}}: 確定",
+ CommitWithoutMessageErr: "コăźăăăˇăă‚»ăĽă‚¸ă‚’入力ă—ă¦ăŹă ă•ă„",
+ CloseConfirm: "{{.keyBindClose}}: é–‰ăă‚‹/ă‚ăŁăłă‚»ă«, {{.keyBindConfirm}}: 確認",
+ LcClose: "é–‰ăă‚‹",
+ LcQuit: "終了",
+ // LcSquashDown: "squash down",
+ // LcFixupCommit: "fixup commit",
+ // NoCommitsThisBranch: "No commits for this branch",
+ // OnlySquashTopmostCommit: "Can only squash topmost commit",
+ // YouNoCommitsToSquash: "You have no commits to squash with",
+ // Fixup: "Fixup",
+ // SureFixupThisCommit: "Are you sure you want to 'fixup' this commit? It will be merged into the commit below",
+ // SureSquashThisCommit: "Are you sure you want to squash this commit into the commit below?",
+ // Squash: "Squash",
+ // LcPickCommit: "pick commit (when mid-rebase)",
+ LcRevertCommit: "コăźăăă‚’revert",
+ LcRewordCommit: "コăźăăăˇăă‚»ăĽă‚¸ă‚’変更",
+ LcDeleteCommit: "コăźăăを削除",
+ LcMoveDownCommit: "コăźăăă‚’1ă¤ä¸‹ă«ç§»ĺ‹•",
+ LcMoveUpCommit: "コăźăăă‚’1ă¤ä¸Šă«ç§»ĺ‹•",
+ LcEditCommit: "コăźăăを編集",
+ LcAmendToCommit: "スă†ăĽă‚¸ă•れăźĺ¤‰ć›´ă§amendコăźăă",
+ LcRenameCommitEditor: "エă‡ă‚Łă‚żă§ă‚łăźăăăˇăă‚»ăĽă‚¸ă‚’編集",
+ Error: "エă©ăĽ",
+ LcSelectHunk: "hunkă‚’é¸ćŠž",
+ LcNavigateConflicts: "コăłă•ăŞă‚Żăを移動",
+ // LcPickHunk: "pick hunk",
+ // LcPickAllHunks: "pick all hunks",
+ LcUndo: "アăłă‰ă‚Ą",
+ LcUndoReflog: "アăłă‰ă‚Ą (via reflog) (experimental)",
+ LcRedoReflog: "ăŞă‰ă‚Ą (via reflog) (experimental)",
+ LcPop: "pop",
+ LcDrop: "drop",
+ LcApply: "é©ç”¨",
+ NoStashEntries: "StashăŚĺ在ă—ăľă›ă‚“",
+ StashDrop: "Stashを削除",
+ SureDropStashEntry: "Stashを削除ă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?",
+ StashPop: "Stashă‚’pop",
+ SurePopStashEntry: "Stashă‚’popă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?",
+ StashApply: "Stashă‚’é©ç”¨",
+ SureApplyStashEntry: "Stashă‚’é©ç”¨ă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?",
+ // NoTrackedStagedFilesStash: "You have no tracked/staged files to stash",
+ StashChanges: "変更をStash",
+ OpenConfig: "č¨ĺ®šă•ァイă«ă‚’é–‹ăŹ",
+ EditConfig: "č¨ĺ®šă•ァイă«ă‚’編集",
+ ForcePush: "Force push",
+ ForcePushPrompt: "ă–ă©ăłăăŚăŞă˘ăĽăă–ă©ăłăă‹ă‚‰ĺ†ĺ˛ă—ă¦ă„ăľă™ă€‚'esc'ă§ă‚ăŁăłă‚»ă«, ăľăźăŻ'enter'ă§force pushă—ăľă™ă€‚",
+ ForcePushDisabled: "ă–ă©ăłăăŚăŞă˘ăĽăă–ă©ăłăă‹ă‚‰ĺ†ĺ˛ă—ă¦ă„ăľă™ă€‚force pushăŻç„ˇĺŠąĺŚ–ă•れă¦ă„ăľă™ă€‚",
+ // UpdatesRejectedAndForcePushDisabled: "Updates were rejected and you have disabled force pushing",
+ LcCheckForUpdate: "更新を確認",
+ CheckingForUpdates: "更新を確認ä¸...",
+ UpdateAvailableTitle: "最新ăŞăŞăĽă‚ą!",
+ UpdateAvailable: "ăăĽă‚¸ă§ăł {{.newVersion}} をイăłă‚ąăăĽă«ă—ăľă™ă‹?",
+ UpdateInProgressWaitingStatus: "ć›´ć–°ä¸",
+ UpdateCompletedTitle: "更新完了!",
+ UpdateCompleted: "ć›´ć–°ă®ă‚¤ăłă‚ąăăĽă«ă«ć功ă—ăľă—ăźă€‚lazygitを再起動ă—ă¦ăŹă ă•ă„。",
+ FailedToRetrieveLatestVersionErr: "ăăĽă‚¸ă§ăłć…ĺ ±ă®ĺŹ–ĺľ—ă«ĺ¤±ć•—ă—ăľă—ăź",
+ OnLatestVersionErr: "使用ä¸ă®ăăĽă‚¸ă§ăłăŻćś€ć–°ă§ă™",
+ MajorVersionErr: "ć–°ăăĽă‚¸ă§ăł ({{.newVersion}}) ăŻçŹľĺś¨ă®ăăĽă‚¸ă§ăł ({{.currentVersion}}) ă¨ĺľŚć–ąäş’換性ăŚă‚りăľă›ă‚“。",
+ CouldNotFindBinaryErr: "{{.url}} ă«ăイăŠăŞăŚĺ在ă—ăľă›ă‚“ă§ă—ăźă€‚",
+ UpdateFailedErr: "更新失敗: {{.errMessage}}",
+ ConfirmQuitDuringUpdateTitle: "現在更新ä¸",
+ ConfirmQuitDuringUpdate: "現在更新を実行ä¸ă§ă™ă€‚終了ă—ăľă™ă‹?",
+ MergeToolTitle: "ăžăĽă‚¸ă„ăĽă«",
+ MergeToolPrompt: "`git mergetool`ă‚’é–‹ăŤăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?",
+ IntroPopupMessage: japaneseIntroPopupMessage,
+ // GitconfigParseErr: `Gogit failed to parse your gitconfig file due to the presence of unquoted '\' characters. Removing these should fix the issue.`,
+ LcEditFile: `ă•ァイă«ă‚’編集`,
+ LcOpenFile: `ă•ァイă«ă‚’é–‹ăŹ`,
+ LcIgnoreFile: `.gitignoreă«čż˝ĺŠ `,
+ LcRefreshFiles: `ă•ァイă«ă‚’ăŞă•ă¬ăă‚·ăĄ`,
+ LcMergeIntoCurrentBranch: `現在ă®ă–ă©ăłăă«ăžăĽă‚¸`,
+ ConfirmQuit: `終了ă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?`,
+ SwitchRepo: `最近使用ă—ăźăŞăťă‚¸ăăŞă«ĺ‡ă‚Šć›żă`,
+ LcAllBranchesLogGraph: `ă™ăąă¦ă®ă–ă©ăłăăグを表示`,
+ UnsupportedGitService: `サăťăĽăă•れă¦ă„ăŞă„GitサăĽă“スă§ă™ă€‚`,
+ LcCreatePullRequest: `Pull Requestを作ć`,
+ LcCopyPullRequestURL: `Pull Requestă®URLをクăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ`,
+ NoBranchOnRemote: `ă–ă©ăłăăŚăŞă˘ăĽăă«ĺ在ă—ăľă›ă‚“。ăŞă˘ăĽăă«pushă—ă¦ăŹă ă•ă„。`,
+ LcFetch: `fetch`,
+ // NoAutomaticGitFetchTitle: `No automatic git fetch`,
+ // NoAutomaticGitFetchBody: `Lazygit can't use "git fetch" in a private repo; use 'f' in the files panel to run "git fetch" manually`,
+ // FileEnter: `stage individual hunks/lines for file, or collapse/expand for directory`,
+ // FileStagingRequirements: `Can only stage individual lines for tracked files`,
+ StageSelection: `é¸ćŠžčˇŚă‚’ă‚ąă†ăĽă‚¸/アăłă‚ąă†ăĽă‚¸`,
+ ResetSelection: `変更を削除 (git reset)`,
+ ToggleDragSelect: `範囲é¸ćŠžă‚’ĺ‡ă‚Šć›żă`,
+ ToggleSelectHunk: `hunké¸ćŠžă‚’ĺ‡ă‚Šć›żă`,
+ ToggleSelectionForPatch: `行をă‘ăăă«čż˝ĺŠ /削除`,
+ ToggleStagingPanel: `ă‘ăŤă«ă‚’ĺ‡ă‚Šć›żă`,
+ ReturnToFilesPanel: `ă•ァイă«ä¸€č¦§ă«ć»ă‚‹`,
+ // FastForward: `fast-forward this branch from its upstream`,
+ // Fetching: "fetching and fast-forwarding {{.from}} -> {{.to}} ...",
+ // FoundConflicts: "Conflicts! To abort press 'esc', otherwise press 'enter'",
+ // FoundConflictsTitle: "Auto-merge failed",
+ // PickHunk: "pick hunk",
+ // PickAllHunks: "pick all hunks",
+ // ViewMergeRebaseOptions: "view merge/rebase options",
+ // NotMergingOrRebasing: "You are currently neither rebasing nor merging",
+ RecentRepos: "最近使用ă—ăźăŞăťă‚¸ăăŞ",
+ // MergeOptionsTitle: "Merge Options",
+ // RebaseOptionsTitle: "Rebase Options",
+ CommitMessageTitle: "コăźăăăˇăă‚»ăĽă‚¸",
+ LocalBranchesTitle: "ă–ă©ăłă",
+ SearchTitle: "検索",
+ TagsTitle: "タグ",
+ MenuTitle: "ăˇă‹ăĄăĽ",
+ RemotesTitle: "ăŞă˘ăĽă",
+ RemoteBranchesTitle: "ăŞă˘ăĽăă–ă©ăłă",
+ PatchBuildingTitle: "ăˇă‚¤ăłă‘ăŤă« (Patch Building)",
+ InformationTitle: "Information",
+ SecondaryTitle: "Secondary",
+ ReflogCommitsTitle: "参照ăă‚°",
+ GlobalTitle: "ă‚°ăăĽăă«ă‚ăĽăイăłă‰",
+ // ConflictsResolved: "all merge conflicts resolved. Continue?",
+ // RebasingTitle: "Rebasing",
+ // ConfirmRebase: "Are you sure you want to rebase '{{.checkedOutBranch}}' onto '{{.selectedBranch}}'?",
+ // ConfirmMerge: "Are you sure you want to merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}'?",
+ // FwdNoUpstream: "Cannot fast-forward a branch with no upstream",
+ // FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally",
+ // FwdCommitsToPush: "Cannot fast-forward a branch with commits to push",
+ ErrorOccurred: "エă©ăĽăŚç™şç”źă—ăľă—ăź! issueを作ćă—ă¦ăŹă ă•ă„: ",
+ // NoRoom: "Not enough room",
+ YouAreHere: "現在位置",
+ // LcRewordNotSupported: "rewording commits while interactively rebasing is not currently supported",
+ LcCherryPickCopy: "コăźăăをコă”㼠(cherry-pick)",
+ LcCherryPickCopyRange: "コăźăăを範囲コă”㼠(cherry-pick)",
+ LcPasteCommits: "コăźăăを貼りä»ă‘ (cherry-pick)",
+ // SureCherryPick: "Are you sure you want to cherry-pick the copied commits onto this branch?",
+ CherryPick: "Cherry-Pick",
+ // CannotRebaseOntoFirstCommit: "You cannot interactive rebase onto the first commit",
+ // CannotSquashOntoSecondCommit: "You cannot squash/fixup onto the second commit",
+ Donate: "支援",
+ AskQuestion: "質問",
+ PrevLine: "前ă®čˇŚă‚’é¸ćŠž",
+ NextLine: "次ă®čˇŚă‚’é¸ćŠž",
+ PrevHunk: "前ă®hunkă‚’é¸ćŠž",
+ NextHunk: "次ă®hunkă‚’é¸ćŠž",
+ PrevConflict: "前ă®ă‚łăłă•ăŞă‚Żăă‚’é¸ćŠž",
+ NextConflict: "次ă®ă‚łăłă•ăŞă‚Żăă‚’é¸ćŠž",
+ SelectPrevHunk: "前ă®hunkă‚’é¸ćŠž",
+ SelectNextHunk: "次ă®hunkă‚’é¸ćŠž",
+ ScrollDown: "下ă«ă‚ąă‚ŻăăĽă«",
+ ScrollUp: "上ă«ă‚ąă‚ŻăăĽă«",
+ LcScrollUpMainPanel: "ăˇă‚¤ăłă‘ăŤă«ă‚’上ă«ă‚ąă‚ŻăăĽă«",
+ LcScrollDownMainPanel: "ăˇă‚¤ăłă‘ăŤă«ă‚’下ă«ă‚ąă‚ŻăăĽă«",
+ AmendCommitTitle: "amendコăźăă",
+ AmendCommitPrompt: "スă†ăĽă‚¸ă•れăźă•ァイă«ă§çŹľĺś¨ă®ă‚łăźăăă‚’amendă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?",
+ DeleteCommitTitle: "コăźăăを削除",
+ DeleteCommitPrompt: "é¸ćŠžă•れăźă‚łăźăăを削除ă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?",
+ // SquashingStatus: "squashing",
+ // FixingStatus: "fixing up",
+ // DeletingStatus: "deleting",
+ // MovingStatus: "moving",
+ // RebasingStatus: "rebasing",
+ // AmendingStatus: "amending",
+ // CherryPickingStatus: "cherry-picking",
+ // UndoingStatus: "undoing",
+ // RedoingStatus: "redoing",
+ // CheckingOutStatus: "checking out",
+ // CommittingStatus: "committing",
+ CommitFiles: "Commit files",
+ SubCommitsDynamicTitle: "コăźăă (%s)",
+ CommitFilesDynamicTitle: "Diff files (%s)",
+ RemoteBranchesDynamicTitle: "ăŞă˘ăĽăă–ă©ăłă (%s)",
+ // LcViewItemFiles: "view selected item's files",
+ CommitFilesTitle: "コăźăăă•ァイă«",
+ // LcCheckoutCommitFile: "checkout file",
+ // LcDiscardOldFileChange: "discard this commit's changes to this file",
+ DiscardFileChangesTitle: "ă•ァイă«ă®ĺ¤‰ć›´ă‚’ç ´ćŁ„",
+ // DiscardFileChangesPrompt: "Are you sure you want to discard this commit's changes to this file? If this file was created in this commit, it will be deleted",
+ // DisabledForGPG: "Feature not available for users using GPG",
+ CreateRepo: "GităŞăťă‚¸ăăŞă§ăŻă‚りăľă›ă‚“。ăŞăťă‚¸ăăŞă‚’作ćă—ăľă™ă‹? (y/n): ",
+ // AutoStashTitle: "Autostash?",
+ // AutoStashPrompt: "You must stash and pop your changes to bring them across. Do this automatically? (enter/esc)",
+ // StashPrefix: "Auto-stashing changes for ",
+ // LcViewDiscardOptions: "view 'discard changes' options",
+ LcCancel: "ă‚ăŁăłă‚»ă«",
+ LcDiscardAllChanges: "ă™ăąă¦ă®ĺ¤‰ć›´ă‚’ç ´ćŁ„",
+ // LcDiscardUnstagedChanges: "discard unstaged changes",
+ // LcDiscardAllChangesToAllFiles: "nuke working tree",
+ // LcDiscardAnyUnstagedChanges: "discard unstaged changes",
+ // LcDiscardUntrackedFiles: "discard untracked files",
+ LcHardReset: "hardăŞă‚»ăă",
+ // LcViewResetOptions: `view reset options`,
+ LcCreateFixupCommit: `ă“ă®ă‚łăźăăă«ĺŻľă™ă‚‹fixupコăźăăを作ć`,
+ // LcSquashAboveCommits: `squash all 'fixup!' commits above selected commit (autosquash)`,
+ // SquashAboveCommits: `Squash all 'fixup!' commits above selected commit (autosquash)`,
+ SureSquashAboveCommits: `{{.commit}}ă«ĺŻľă™ă‚‹ă™ăąă¦ă® fixup! コăźăăă‚’squashă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?`,
+ CreateFixupCommit: `fixupコăźăăを作ć`,
+ SureCreateFixupCommit: `{{.commit}} ă«ĺŻľă™ă‚‹ fixup! コăźăăを作ćă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?`,
+ LcExecuteCustomCommand: "カスタă コăžăłă‰ă‚’実行",
+ CustomCommand: "カスタă コăžăłă‰:",
+ LcCommitChangesWithoutHook: "pre-commită•ăクを実行ă›ăšă«ĺ¤‰ć›´ă‚’コăźăă",
+ // SkipHookPrefixNotConfigured: "You have not configured a commit message prefix for skipping hooks. Set `git.skipHookPrefix = 'WIP'` in your config",
+ // LcResetTo: `reset to`,
+ PressEnterToReturn: "Enterを入力ă—ă¦ăŹă ă•ă„",
+ // LcViewStashOptions: "view stash options",
+ LcStashAllChanges: "変更をstash",
+ // LcStashStagedChanges: "stash staged changes",
+ // LcStashOptions: "Stash options",
+ // NotARepository: "Error: must be run inside a git repository",
+ LcJump: "ă‘ăŤă«ă«ç§»ĺ‹•",
+ LcScrollLeftRight: "左右ă«ă‚ąă‚ŻăăĽă«",
+ LcScrollLeft: "左スクăăĽă«",
+ LcScrollRight: "右スクăăĽă«",
+ DiscardPatch: "ă‘ăăă‚’ç ´ćŁ„",
+ // DiscardPatchConfirm: "You can only build a patch from one commit/stash-entry at a time. Discard current patch?",
+ // CantPatchWhileRebasingError: "You cannot build a patch or run patch commands while in a merging or rebasing state",
+ // LcToggleAddToPatch: "toggle file included in patch",
+ // LcToggleAllInPatch: "toggle all files included in patch",
+ // LcUpdatingPatch: "updating patch",
+ // ViewPatchOptions: "view custom patch options",
+ // PatchOptionsTitle: "Patch Options",
+ // NoPatchError: "No patch created yet. To start building a patch, use 'space' on a commit file or enter to add specific lines",
+ // LcEnterFile: "enter file to add selected lines to the patch (or toggle directory collapsed)",
+ // ExitCustomPatchBuilder: ``,
+ EnterUpstream: `' ' ă®ĺ˝˘ĺĽŹă§upstreamを入力`,
+ InvalidUpstream: "upstreamă®ĺ˝˘ĺĽŹăŚćŁă—ăŹă‚りăľă›ă‚“。' ' ă®ĺ˝˘ĺĽŹă§ĺ…ĄĺŠ›ă—ă¦ăŹă ă•ă„。",
+ ReturnToRemotesList: `ăŞă˘ăĽă一覧ă«ć»ă‚‹`,
+ LcAddNewRemote: `ăŞă˘ăĽăă‚’ć–°č¦Źčż˝ĺŠ `,
+ LcNewRemoteName: `新規ăŞă˘ăĽăĺŤ:`,
+ LcNewRemoteUrl: `新規ăŞă˘ăĽăURL:`,
+ LcEditRemoteName: `{{.remoteName}} ă®ć–°ă—ă„ăŞă˘ăĽăĺŤă‚’入力:`,
+ LcEditRemoteUrl: `{{.remoteName}} ă®ć–°ă—ă„ăŞă˘ăĽăURLを入力:`,
+ LcRemoveRemote: `ăŞă˘ăĽăを削除`,
+ LcRemoveRemotePrompt: "ăŞă˘ăĽăを削除ă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?",
+ DeleteRemoteBranch: "ăŞă˘ăĽăă–ă©ăłăを削除",
+ DeleteRemoteBranchMessage: "ăŞă˘ăĽăă–ă©ăłăを削除ă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹",
+ // LcSetUpstream: "set as upstream of checked-out branch",
+ // SetUpstreamTitle: "Set upstream branch",
+ // SetUpstreamMessage: "Are you sure you want to set the upstream branch of '{{.checkedOut}}' to '{{.selected}}'",
+ LcEditRemote: "ăŞă˘ăĽăを編集",
+ LcTagCommit: "タグを作ć",
+ TagMenuTitle: "タグを作ć",
+ TagNameTitle: "タグĺŤ:",
+ TagMessageTitle: "タグăˇăă‚»ăĽă‚¸: ",
+ LcAnnotatedTag: "注é‡ä»ăŤă‚żă‚°",
+ LcLightweightTag: "軽量タグ",
+ LcDeleteTag: "タグを削除",
+ DeleteTagTitle: "タグを削除",
+ DeleteTagPrompt: "タグ '{{.tagName}}' を削除ă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?",
+ PushTagTitle: "ăŞă˘ăĽăă«ă‚żă‚° '{{.tagName}}' ă‚’push",
+ LcPushTag: "タグをpush",
+ LcCreateTag: "タグを作ć",
+ CreateTagTitle: "タグĺŤ:",
+ LcFetchRemote: "ăŞă˘ăĽăă‚’fetch",
+ FetchingRemoteStatus: "ăŞă˘ăĽăă‚’fetch",
+ LcCheckoutCommit: "コăźăăă‚’ăă‚§ăクアウă",
+ SureCheckoutThisCommit: "é¸ćŠžă•れăźă‚łăźăăă‚’ăă‚§ăクアウăă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?",
+ // LcGitFlowOptions: "show git-flow options",
+ // NotAGitFlowBranch: "This does not seem to be a git flow branch",
+ // NewGitFlowBranchPrompt: "new {{.branchType}} name:",
+ // IgnoreTracked: "Ignore tracked file",
+ // IgnoreTrackedPrompt: "Are you sure you want to ignore a tracked file?",
+ // LcViewResetToUpstreamOptions: "view upstream reset options",
+ LcNextScreenMode: "次ă®ă‚ąă‚ŻăŞăĽăłă˘ăĽă‰ (normal/half/fullscreen)",
+ LcPrevScreenMode: "前ă®ă‚ąă‚ŻăŞăĽăłă˘ăĽă‰",
+ LcStartSearch: "検索を開始",
+ Panel: "ă‘ăŤă«",
+ Keybindings: "ă‚ăĽăイăłă‰",
+ LcRenameBranch: "ă–ă©ăłăĺŤă‚’変更",
+ NewBranchNamePrompt: "ć–°ă—ă„ă–ă©ăłăĺŤă‚’入力",
+ // RenameBranchWarning: "This branch is tracking a remote. This action will only rename the local branch name, not the name of the remote branch. Continue?",
+ LcOpenMenu: "ăˇă‹ăĄăĽă‚’é–‹ăŹ",
+ // LcResetCherryPick: "reset cherry-picked (copied) commits selection",
+ LcNextTab: "次ă®ă‚żă–",
+ LcPrevTab: "前ă®ă‚żă–",
+ LcCantUndoWhileRebasing: "ăŞă™ăĽă‚ąä¸ăŻă‚˘ăłă‰ă‚Ąă§ăŤăľă›ă‚“。",
+ LcCantRedoWhileRebasing: "ăŞă™ăĽă‚ąä¸ăŻăŞă‰ă‚Ąă§ăŤăľă›ă‚“。",
+ // MustStashWarning: "Pulling a patch out into the index requires stashing and unstashing your changes. If something goes wrong, you'll be able to access your files from the stash. Continue?",
+ // MustStashTitle: "Must stash",
+ ConfirmationTitle: "確認ă‘ăŤă«",
+ LcPrevPage: "前ă®ăšăĽă‚¸",
+ LcNextPage: "次ă®ăšăĽă‚¸",
+ LcGotoTop: "最上é¨ăľă§ă‚ąă‚ŻăăĽă«",
+ LcGotoBottom: "最下é¨ăľă§ă‚ąă‚ŻăăĽă«",
+ // LcFilteringBy: "filtering by",
+ // ResetInParentheses: "(reset)",
+ // LcOpenFilteringMenu: "view filter-by-path options",
+ // LcFilterBy: "filter by",
+ // LcExitFilterMode: "stop filtering by path",
+ // LcFilterPathOption: "enter path to filter by",
+ // EnterFileName: "Enter path:",
+ // FilteringMenuTitle: "Filtering",
+ // MustExitFilterModeTitle: "Command not available",
+ // MustExitFilterModePrompt: "Command not available in filtered mode. Exit filtered mode?",
+ LcDiff: "ĺ·®ĺ†",
+ // LcEnterRefToDiff: "enter ref to diff",
+ LcEnteRefName: "参照を入力:",
+ LcExitDiffMode: "ĺ·®ĺ†ă˘ăĽă‰ă‚’終了",
+ DiffingMenuTitle: "ĺ·®ĺ†",
+ // LcSwapDiff: "reverse diff direction",
+ LcOpenDiffingMenu: "ĺ·®ĺ†ăˇă‹ăĄăĽă‚’é–‹ăŹ",
+ // // the actual view is the extras view which I intend to give more tabs in future but for now we'll only mention the command log part
+ LcOpenExtrasMenu: "コăžăłă‰ăă‚°ăˇă‹ăĄăĽă‚’é–‹ăŹ",
+ // LcShowingGitDiff: "showing output for:",
+ LcCommitDiff: "コăźăăă®ĺ·®ĺ†",
+ LcCopyCommitShaToClipboard: "コăźăăă®SHAをクăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ LcCommitSha: "コăźăăă®SHA",
+ LcCommitURL: "コăźăăă®URL",
+ LcCopyCommitMessageToClipboard: "コăźăăăˇăă‚»ăĽă‚¸ă‚’クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ LcCommitMessage: "コăźăăăˇăă‚»ăĽă‚¸",
+ LcCommitAuthor: "コăźăăă®ä˝ść者ĺŤ",
+ LcCopyCommitAttributeToClipboard: "コăźăăă®ć…ĺ ±ă‚’ă‚łă”ăĽ",
+ LcCopyBranchNameToClipboard: "ă–ă©ăłăĺŤă‚’クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ LcCopyFileNameToClipboard: "ă•ァイă«ĺŤă‚’クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ LcCopyCommitFileNameToClipboard: "コăźăăă•れăźă•ァイă«ĺŤă‚’クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ LcCopySelectedTexToClipboard: "é¸ćŠžă•れăźă†ă‚スăをクăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ // LcCommitPrefixPatternError: "Error in commitPrefix pattern",
+ NoFilesStagedTitle: "ă•ァイă«ăŚă‚ąă†ăĽă‚¸ă•れă¦ă„ăľă›ă‚“",
+ NoFilesStagedPrompt: "ă•ァイă«ăŚă‚ąă†ăĽă‚¸ă•れă¦ă„ăľă›ă‚“。ă™ăąă¦ă®ĺ¤‰ć›´ă‚’コăźăăă—ăľă™ă‹?",
+ BranchNotFoundTitle: "ă–ă©ăłăăŚč¦‹ă¤ă‹ă‚Šăľă›ă‚“ă§ă—ăźă€‚",
+ BranchNotFoundPrompt: "ă–ă©ăłăăŚč¦‹ă¤ă‹ă‚Šăľă›ă‚“ă§ă—ăźă€‚ć–°ă—ăŹă–ă©ăłăを作ćă—ăľă™ ",
+ UnstageLinesTitle: "é¸ćŠžčˇŚă‚’ă‚˘ăłă‚ąă†ăĽă‚¸",
+ UnstageLinesPrompt: "é¸ćŠžă•れăźčˇŚă‚’削除 (git reset) ă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹? ă“ă®ć“Ťä˝śăŻĺŹ–ă‚Šć¶ă›ăľă›ă‚“。\nă“ă®č¦ĺ‘Šă‚’無効化ă™ă‚‹ă«ăŻč¨ĺ®šă•ァイă«ă® 'gui.skipUnstageLineWarning' ă‚’ true ă«č¨ĺ®šă—ă¦ăŹă ă•ă„。",
+ LcCreateNewBranchFromCommit: "コăźăăă«ă–ă©ăłăを作ć",
+ LcBuildingPatch: "ă‘ăăを構築",
+ LcViewCommits: "コăźăăを閲覧",
+ MinGitVersionError: "lazygită®ĺ®źčˇŚă«ăŻGit 2.0以降ă®ăăĽă‚¸ă§ăłăŚĺż…č¦ă§ă™ă€‚Gită‚’ć›´ć–°ă—ă¦ăŹă ă•ă„。もă—ăŹăŻă€lazygită®ĺľŚć–ąäş’換性を改善ă™ă‚‹ăźă‚ă« https://github.com/jesseduffield/lazygit/issues ă«issueを作ćă—ă¦ăŹă ă•ă„。",
+ LcRunningCustomCommandStatus: "カスタă コăžăłă‰ă‚’実行",
+ // LcSubmoduleStashAndReset: "stash uncommitted submodule changes and update",
+ // LcAndResetSubmodules: "and reset submodules",
+ LcEnterSubmodule: "サă–ă˘ă‚¸ăĄăĽă«ă‚’é–‹ăŹ",
+ LcCopySubmoduleNameToClipboard: "サă–ă˘ă‚¸ăĄăĽă«ĺŤă‚’クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ RemoveSubmodule: "サă–ă˘ă‚¸ăĄăĽă«ă‚’削除",
+ LcRemoveSubmodule: "サă–ă˘ă‚¸ăĄăĽă«ă‚’削除",
+ RemoveSubmodulePrompt: "サă–ă˘ă‚¸ăĄăĽă« '%s' ă¨ăťă®ă‡ă‚Łă¬ă‚ŻăăŞă‚’削除ă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹? ă“ă®ć“Ťä˝śăŻĺŹ–ă‚Šć¶ă›ăľă›ă‚“。",
+ LcResettingSubmoduleStatus: "サă–ă˘ă‚¸ăĄăĽă«ă‚’ăŞă‚»ăă",
+ LcNewSubmoduleName: "新規サă–ă˘ă‚¸ăĄăĽă«ĺŤ:",
+ LcNewSubmoduleUrl: "新規サă–ă˘ă‚¸ăĄăĽă«ă®URL:",
+ LcNewSubmodulePath: "新規サă–ă˘ă‚¸ăĄăĽă«ă®ă‘ス:",
+ LcAddSubmodule: "サă–ă˘ă‚¸ăĄăĽă«ă‚’ć–°č¦Źčż˝ĺŠ ",
+ LcAddingSubmoduleStatus: "サă–ă˘ă‚¸ăĄăĽă«ă‚’ć–°č¦Źčż˝ĺŠ ",
+ LcUpdateSubmoduleUrl: "サă–ă˘ă‚¸ăĄăĽă« '%s' ă®URLă‚’ć›´ć–°",
+ LcUpdatingSubmoduleUrlStatus: "URLă‚’ć›´ć–°",
+ LcEditSubmoduleUrl: "サă–ă˘ă‚¸ăĄăĽă«ă®URLă‚’ć›´ć–°",
+ LcInitializingSubmoduleStatus: "サă–ă˘ă‚¸ăĄăĽă«ă‚’ĺťćśźĺŚ–",
+ LcInitSubmodule: "サă–ă˘ă‚¸ăĄăĽă«ă‚’ĺťćśźĺŚ–",
+ LcSubmoduleUpdate: "サă–ă˘ă‚¸ăĄăĽă«ă‚’ć›´ć–°",
+ LcUpdatingSubmoduleStatus: "サă–ă˘ă‚¸ăĄăĽă«ă‚’ć›´ć–°",
+ LcBulkInitSubmodules: "サă–ă˘ă‚¸ăĄăĽă«ă‚’一括ĺťćśźĺŚ–",
+ LcBulkUpdateSubmodules: "サă–ă˘ă‚¸ăĄăĽă«ă‚’一括更新",
+ // LcBulkDeinitSubmodules: "bulk deinit submodules",
+ // LcViewBulkSubmoduleOptions: "view bulk submodule options",
+ // LcBulkSubmoduleOptions: "bulk submodule options",
+ // LcRunningCommand: "running command",
+ // SubCommitsTitle: "Sub-commits",
+ SubmodulesTitle: "サă–ă˘ă‚¸ăĄăĽă«",
+ NavigationTitle: "一覧ă‘ăŤă«ă®ć“Ťä˝ś",
+ // SuggestionsCheatsheetTitle: "Suggestions",
+ // SuggestionsTitle: "Suggestions (press %s to focus)",
+ ExtrasTitle: "コăžăłă‰ăă‚°",
+ // PushingTagStatus: "pushing tag",
+ PullRequestURLCopiedToClipboard: "pull requestă®URLăŚă‚ŻăŞăă—ăśăĽă‰ă«ă‚łă”ăĽă•れăľă—ăź",
+ CommitDiffCopiedToClipboard: "コăźăăă®ĺ·®ĺ†ăŚă‚ŻăŞăă—ăśăĽă‰ă«ă‚łă”ăĽă•れăľă—ăź",
+ CommitSHACopiedToClipboard: "コăźăăă®SHAăŚă‚ŻăŞăă—ăśăĽă‰ă«ă‚łă”ăĽă•れăľă—ăź",
+ CommitURLCopiedToClipboard: "コăźăăă®URLăŚă‚ŻăŞăă—ăśăĽă‰ă«ă‚łă”ăĽă•れăľă—ăź",
+ CommitMessageCopiedToClipboard: "コăźăăăˇăă‚»ăĽă‚¸ăŚă‚ŻăŞăă—ăśăĽă‰ă«ă‚łă”ăĽă•れăľă—ăź",
+ CommitAuthorCopiedToClipboard: "コăźăăă®ä˝ść者ĺŤăŚă‚ŻăŞăă—ăśăĽă‰ă«ă‚łă”ăĽă•れăľă—ăź",
+ LcCopiedToClipboard: "クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽă•れăľă—ăź",
+ ErrCannotEditDirectory: "ă‡ă‚Łă¬ă‚ŻăăŞăŻç·¨é›†ă§ăŤăľă›ă‚“。",
+ ErrStageDirWithInlineMergeConflicts: "ăžăĽă‚¸ă‚łăłă•ăŞă‚Żăă®ç™şç”źă—ăźă•ァイă«ă‚’ĺ«ă‚€ă‡ă‚Łă¬ă‚ŻăăŞăŻă‚ąă†ăĽă‚¸/アăłă‚ąă†ăĽă‚¸ă§ăŤăľă›ă‚“。ăžăĽă‚¸ă‚łăłă•ăŞă‚Żăを解決ă—ă¦ăŹă ă•ă„。",
+ ErrRepositoryMovedOrDeleted: "ăŞăťă‚¸ăăŞăŚč¦‹ă¤ă‹ă‚Šăľă›ă‚“。ă™ă§ă«ĺ‰Šé™¤ă•れăźă‹ă€ç§»ĺ‹•ă•れăźĺŹŻč˝ć€§ăŚă‚りăľă™ ÂŻ\\_(ă„)_/ÂŻ",
+ CommandLog: "コăžăłă‰ăă‚°",
+ ToggleShowCommandLog: "コăžăłă‰ăă‚°ă®čˇ¨ç¤ş/非表示をĺ‡ă‚Šć›żă",
+ FocusCommandLog: "コăžăłă‰ăă‚°ă«ă•ă‚©ăĽă‚«ă‚ą",
+ CommandLogHeader: "コăžăłă‰ăă‚°ă®čˇ¨ç¤ş/非表示㯠'%s' ă§ĺ‡ă‚Šć›żăられăľă™ă€‚\n",
+ RandomTip: "ă©ăłă€ă Tips",
+ // SelectParentCommitForMerge: "Select parent commit for merge",
+ ToggleWhitespaceInDiffView: "空白文ĺ—ă®ĺ·®ĺ†ă®čˇ¨ç¤şćś‰ç„ˇă‚’ĺ‡ă‚Šć›żă",
+ IgnoringWhitespaceInDiffView: "空白文ĺ—ă®ĺ¤‰ć›´ăŻĺ·®ĺ†ç”»éť˘ă«čˇ¨ç¤şă•れăľă›ă‚“",
+ ShowingWhitespaceInDiffView: "空白文ĺ—ă®ĺ¤‰ć›´ăŻĺ·®ĺ†ç”»éť˘ă«čˇ¨ç¤şă•れăľă™",
+ // IncreaseContextInDiffView: "Increase the size of the context shown around changes in the diff view",
+ // DecreaseContextInDiffView: "Decrease the size of the context shown around changes in the diff view",
+ CreatePullRequest: "pull requestを作ć",
+ // CreatePullRequestOptions: "Create pull request options",
+ // LcCreatePullRequestOptions: "create pull request options",
+ LcDefaultBranch: "ă‡ă•ă‚©ă«ăă–ă©ăłă",
+ LcSelectBranch: "ă–ă©ăłăă‚’é¸ćŠž",
+ SelectConfigFile: "č¨ĺ®šă•ァイă«ă‚’é¸ćŠž",
+ NoConfigFileFoundErr: "č¨ĺ®šă•ァイă«ăŚč¦‹ă¤ă‹ă‚Šăľă›ă‚“ă§ă—ăźă€‚",
+ // LcLoadingFileSuggestions: "loading file suggestions",
+ // LcLoadingCommits: "loading commits",
+ // MustSpecifyOriginError: "Must specify a remote if specifying a branch",
+ // GitOutput: "Git output:",
+ // GitCommandFailed: "Git command failed. Check command log for details (open with %s)",
+ AbortTitle: "%să‚’ä¸ć˘",
+ AbortPrompt: "実施ä¸ă®%să‚’ä¸ć˘ă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?",
+ LcOpenLogMenu: "ăă‚°ăˇă‹ăĄăĽă‚’é–‹ăŹ",
+ LogMenuTitle: "コăźăăăグオă—ă‚·ă§ăł",
+ // ToggleShowGitGraphAll: "toggle show whole git graph (pass the `--all` flag to `git log`)",
+ ShowGitGraph: "コăźăăă‚°ă©ă•ă®čˇ¨ç¤ş",
+ SortCommits: "コăźăăă®čˇ¨ç¤şé †",
+ // CantChangeContextSizeError: "Cannot change context while in patch building mode because we were too lazy to support it when releasing the feature. If you really want it, please let us know!",
+ LcOpenCommitInBrowser: "ă–ă©ă‚¦ă‚¶ă§ă‚łăźăăă‚’é–‹ăŹ",
+ // LcViewBisectOptions: "view bisect options",
+ // ConfirmRevertCommit: "Are you sure you want to revert {{.selectedCommit}}?",
+ RewordInEditorTitle: "コăźăăăˇăă‚»ăĽă‚¸ă‚’エă‡ă‚Łă‚żă§ç·¨é›†",
+ // RewordInEditorPrompt: "Are you sure you want to reword this commit in your editor?",
+ // HardResetAutostashPrompt: "Are you sure you want to hard reset to '%s'? An auto-stash will be performed if necessary.",
+ // CheckoutPrompt: "Are you sure you want to checkout '%s'?",
+ // UpstreamGone: "(upstream gone)",
+ Actions: Actions{
+ // TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)
+ CheckoutCommit: "コăźăăă‚’ăă‚§ăクアウă",
+ CheckoutTag: "タグをăă‚§ăクアウă",
+ CheckoutBranch: "ă–ă©ăłăă‚’ăă‚§ăクアウă",
+ ForceCheckoutBranch: "ă–ă©ăłăを強ĺ¶çš„ă«ăă‚§ăクアウă",
+ DeleteBranch: "ă–ă©ăłăを削除",
+ Merge: "ăžăĽă‚¸",
+ // RebaseBranch: "Rebase branch",
+ RenameBranch: "ă–ă©ăłăĺŤă‚’変更",
+ CreateBranch: "ă–ă©ăłăを作ć",
+ // CherryPick: "(Cherry-pick) Paste commits",
+ CheckoutFile: "ă•ァイă«ă‚’ăă‚§ăクアウăs",
+ // DiscardOldFileChange: "Discard old file change",
+ // SquashCommitDown: "Squash commit down",
+ FixupCommit: "fixupコăźăă",
+ RewordCommit: "コăźăăăˇăă‚»ăĽă‚¸ă‚’変更",
+ DropCommit: "コăźăăを削除",
+ EditCommit: "コăźăăを編集",
+ AmendCommit: "amendコăźăă",
+ RevertCommit: "コăźăăă‚’revert",
+ CreateFixupCommit: "fixupコăźăăを作ć",
+ // SquashAllAboveFixupCommits: "Squash all above fixup commits",
+ CreateLightweightTag: "軽量タグを作ć",
+ CreateAnnotatedTag: "注é‡ä»ăŤă‚żă‚°ă‚’作ć",
+ CopyCommitMessageToClipboard: "コăźăăăˇăă‚»ăĽă‚¸ă‚’クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ CopyCommitDiffToClipboard: "コăźăăă®ĺ·®ĺ†ă‚’クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ CopyCommitSHAToClipboard: "コăźăăSHAをクăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ CopyCommitURLToClipboard: "コăźăăă®URLをクăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ CopyCommitAuthorToClipboard: "コăźăăă®ä˝ść者ĺŤă‚’クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ CopyCommitAttributeToClipboard: "クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ MoveCommitUp: "コăźăăを上ă«ç§»ĺ‹•",
+ MoveCommitDown: "コăźăăを下ă«ç§»ĺ‹•",
+ CustomCommand: "カスタă コăžăłă‰",
+ DiscardAllChangesInDirectory: "ă‡ă‚Łă¬ă‚ŻăăŞĺ†…ă®ă™ăąă¦ă®ĺ¤‰ć›´ă‚’ç ´ćŁ„",
+ DiscardUnstagedChangesInDirectory: "ă‡ă‚Łă¬ă‚ŻăăŞĺ†…ă®ă™ăąă¦ă®ă‚ąă†ăĽă‚¸ă•れă¦ă„ăŞă„ĺ¤‰ć›´ă‚’ç ´ćŁ„",
+ DiscardAllChangesInFile: "ă•ァイă«ĺ†…ă®ă™ăąă¦ă®ĺ¤‰ć›´ă‚’ç ´ćŁ„",
+ DiscardAllUnstagedChangesInFile: "ă•ァイă«ĺ†…ă®ă™ăąă¦ă®ă‚ąă†ăĽă‚¸ă•れă¦ă„ăŞă„ĺ¤‰ć›´ă‚’ç ´ćŁ„",
+ StageFile: "ă•ァイă«ă‚’スă†ăĽă‚¸",
+ StageResolvedFiles: "ăžăĽă‚¸ă‚łăłă•ăŞă‚ŻăăŚč§Łć±şă•れăźă™ăąă¦ă®ă•ァイă«ă‚’スă†ăĽă‚¸",
+ UnstageFile: "ă•ァイă«ă‚’アăłă‚ąă†ăĽă‚¸",
+ UnstageAllFiles: "ă™ăąă¦ă®ă•ァイă«ă‚’アăłă‚ąă†ăĽă‚¸",
+ StageAllFiles: "ă™ăąă¦ă®ă•ァイă«ă‚’スă†ăĽă‚¸",
+ LcIgnoreExcludeFile: "ă•ァイă«ă‚’ignore",
+ Commit: "コăźăă",
+ EditFile: "ă•ァイă«ă‚’編集",
+ Push: "Push",
+ Pull: "Pull",
+ OpenFile: "ă•ァイă«ă‚’é–‹ăŹ",
+ StashAllChanges: "ă™ăąă¦ă®ĺ¤‰ć›´ă‚’Stash",
+ StashStagedChanges: "スă†ăĽă‚¸ă•れăźĺ¤‰ć›´ă‚’Stash",
+ GitFlowFinish: "Git flow finish",
+ GitFlowStart: "Git Flow start",
+ CopyToClipboard: "クăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ CopySelectedTextToClipboard: "é¸ćŠžă•れăźă†ă‚スăをクăŞăă—ăśăĽă‰ă«ă‚łă”ăĽ",
+ RemovePatchFromCommit: "ă‘ăăをコăźăăă‹ă‚‰ĺ‰Šé™¤",
+ MovePatchToSelectedCommit: "ă‘ăăă‚’é¸ćŠžă—ăźă‚łăźăăă«ç§»ĺ‹•",
+ MovePatchIntoIndex: "ă‘ăăă‚’indexă«ç§»ĺ‹•",
+ MovePatchIntoNewCommit: "ă‘ăăを次ă®ă‚łăźăăă«ç§»ĺ‹•",
+ DeleteRemoteBranch: "ăŞă˘ăĽăă–ă©ăłăを削除",
+ SetBranchUpstream: "upstreamă–ă©ăłăă‚’č¨ĺ®š",
+ AddRemote: "ăŞă˘ăĽăă‚’čż˝ĺŠ ",
+ RemoveRemote: "ăŞă˘ăĽăを削除",
+ UpdateRemote: "ăŞă˘ăĽăă‚’ć›´ć–°",
+ ApplyPatch: "ă‘ăăă‚’é©ç”¨",
+ Stash: "Stash",
+ RemoveSubmodule: "サă–ă˘ă‚¸ăĄăĽă«ă‚’削除",
+ ResetSubmodule: "サă–ă˘ă‚¸ăĄăĽă«ă‚’ăŞă‚»ăă",
+ AddSubmodule: "サă–ă˘ă‚¸ăĄăĽă«ă‚’čż˝ĺŠ ",
+ UpdateSubmoduleUrl: "サă–ă˘ă‚¸ăĄăĽă«ă®URLă‚’ć›´ć–°",
+ InitialiseSubmodule: "サă–ă˘ă‚¸ăĄăĽă«ă‚’ĺťćśźĺŚ–",
+ BulkInitialiseSubmodules: "サă–ă˘ă‚¸ăĄăĽă«ă‚’一括ĺťćśźĺŚ–",
+ BulkUpdateSubmodules: "サă–ă˘ă‚¸ăĄăĽă«ă‚’一括更新",
+ // BulkDeinitialiseSubmodules: "Bulk deinitialise submodules",
+ UpdateSubmodule: "サă–ă˘ă‚¸ăĄăĽă«ă‚’ć›´ć–°",
+ DeleteTag: "タグを削除",
+ PushTag: "タグをpush",
+ // NukeWorkingTree: "Nuke working tree",
+ // DiscardUnstagedFileChanges: "Discard unstaged file changes",
+ // RemoveUntrackedFiles: "Remove untracked files",
+ SoftReset: "SoftăŞă‚»ăă",
+ MixedReset: "MixedăŞă‚»ăă",
+ HardReset: "HardăŞă‚»ăă",
+ FastForwardBranch: "ă–ă©ăłăă‚’fast forward",
+ Undo: "アăłă‰ă‚Ą",
+ Redo: "ăŞă‰ă‚Ą",
+ CopyPullRequestURL: "pull requestă®URLをコă”ăĽ",
+ OpenMergeTool: "ăžăĽă‚¸ă„ăĽă«ă‚’é–‹ăŹ",
+ OpenCommitInBrowser: "コăźăăă‚’ă–ă©ă‚¦ă‚¶ă§é–‹ăŹ",
+ OpenPullRequest: "pull requestă‚’ă–ă©ă‚¦ă‚¶ă§é–‹ăŹ",
+ StartBisect: "bisectă‚’é–‹ĺ§‹",
+ ResetBisect: "bisectă‚’ăŞă‚»ăă",
+ BisectSkip: "bisectをスă‚ăă—",
+ BisectMark: "bisectă‚’ăžăĽă‚Ż",
+ },
+ Bisect: Bisect{
+ // Mark: "mark %s as %s",
+ // MarkStart: "mark %s as %s (start bisect)",
+ Skip: "%s をスă‚ăă—ă™ă‚‹",
+ ResetTitle: "'git bisect' ă‚’ăŞă‚»ăă",
+ ResetPrompt: "'git bisect' ă‚’ăŞă‚»ăăă—ăľă™ă€‚ă‚ろă—ă„ă§ă™ă‹?",
+ ResetOption: "bisectă‚’ăŞă‚»ăă",
+ BisectMenuTitle: "bisect",
+ CompleteTitle: "Bisect完了",
+ // CompletePrompt: "Bisect complete! The following commit introduced the change:\n\n%s\n\nDo you want to reset 'git bisect' now?",
+ // CompletePromptIndeterminate: "Bisect complete! Some commits were skipped, so any of the following commits may have introduced the change:\n\n%s\n\nDo you want to reset 'git bisect' now?",
+ },
+ }
+}
diff --git a/pkg/i18n/korean.go b/pkg/i18n/korean.go
new file mode 100644
index 000000000..afda25c6d
--- /dev/null
+++ b/pkg/i18n/korean.go
@@ -0,0 +1,607 @@
+package i18n
+
+const koreanIntroPopupMessage = `
+lazygit!를 이용해주셔서 ę°ě‚¬í•©ë‹ë‹¤. Seriously you rock. Three things to share with you:
+
+ 1) lazygitěť ę¸°ëŠĄě— ëŚ€í•´ ě•Śě•„ëł´ë ¤ë©´ 다음 비디ě¤ëĄĽ 참조í•세요.
+ https://youtu.be/CPLdltN7wgE
+
+ 2) 다음 사이트ě—서 ěµśě‹ ë¦´ë¦¬ěŠ¤ 노트를 읽어보세요.:
+ https://github.com/jesseduffield/lazygit/releases
+
+ 3) ë§Śě•˝ ë‹ąě‹ ěť´ Gitěť„ 사용한다면, ę·¸ę˛ěť€ ë‹ąě‹ ěť„ 프로그ëžë¨¸ëˇś 만들 ę˛ěž…ë‹ë‹¤!
+ ë‹ąě‹ ěť ëŹ„ě›€ěśĽëˇś 우리는 lazygitěť„ 더 좋게 만들 ě ěžěеë‹ë‹¤, ę·¸ëź¬ë‹ ę¸°ě—¬ěžę°€ ë는 ę˛ěť„ ęł ë ¤í•´ëł´ě„¸ěš”. ę·¸ë¦¬ęł ěž¬ëŻ¸ě— ě°¸ě—¬í•세요:
+ https://github.com/jesseduffield/lazygit
+ ë한 ě¤ëĄ¸ěŞ˝ í•ë‹¨ěť ę¸°ë¶€ 버튼을 í´ë¦í•ě—¬ ě €ëĄĽ 후ě›í•ęł ěž‘ě—…í• ë‚´ěš©ěť„ ě•Śë ¤ěŁĽě‹¤ ě ěžěеë‹ë‹¤.
+ ë는 ě €ěžĄě†Śě— ěŠ¤í€ëĄĽ ëŚëź¬ ě‚¬ëž‘ěť„ ęłµěś í• ě도 ěžěеë‹ë‹¤!
+`
+
+// exporting this so we can use it in tests
+func koreanTranslationSet() TranslationSet {
+ return TranslationSet{
+ NotEnoughSpace: "패ë„ěť„ ë ŚëŤ”ë§ í• ęłµę°„ěť´ 부족합ë‹ë‹¤.",
+ DiffTitle: "Diff",
+ FilesTitle: "파일",
+ BranchesTitle: "브랜ěą",
+ CommitsTitle: "커밋",
+ StashTitle: "Stash",
+ UnstagedChanges: `Stagedëě§€ 않은 변경 ë‚´ěš©`,
+ StagedChanges: `Stagedëś ëł€ę˛˝ ë‚´ěš©`,
+ MainTitle: "메인",
+ MergeConfirmTitle: "병합",
+ StagingTitle: "메인 íŚ¨ë„ (Staging)",
+ MergingTitle: "메인 íŚ¨ë„ (Merging)",
+ NormalTitle: "메인 íŚ¨ë„ (Normal)",
+ LogTitle: "로그",
+ CommitMessage: "커밋 메시지",
+ CredentialsUsername: "ě‚¬ěš©ěž ěť´ë¦„",
+ CredentialsPassword: "패스워드",
+ CredentialsPassphrase: "SSHí‚¤ěť passphrase ěž…ë Ą",
+ PassUnameWrong: "패스워드, passphrase ë는 ě‚¬ěš©ěž ěť´ë¦„ěť´ ěžëŞ»ëě—습ë‹ë‹¤.",
+ CommitChanges: "커밋 변경내용",
+ AmendLastCommit: "ë§ě§€ë§› 커밋 ěě •",
+ AmendLastCommitTitle: "ë§ě§€ë§‰ 커밋 ěě •",
+ SureToAmend: "ë§ě§€ë§‰ 커밋을 ěě •í•ě‹śę˛ ěŠµë‹ęąŚ? 그런 다음 커밋 패ë„ě—서 커밋 메시지를 ëł€ę˛˝í• ě ěžěеë‹ë‹¤.",
+ NoCommitToAmend: "amend 가능한 커밋이 없습ë‹ë‹¤.",
+ CommitChangesWithEditor: "Git 편집기를 사용í•ě—¬ 변경 내용을 커밋합ë‹ë‹¤.",
+ StatusTitle: "ěíś",
+ LcNavigate: "이동",
+ LcMenu: "메뉴",
+ LcExecute: "실행",
+ LcToggleStaged: "Staged ě „í™",
+ LcToggleStagedAll: "ëŞ¨ë“ ëł€ę˛˝ěť„ Staged/unstaged으로 ě „í™",
+ LcToggleTreeView: "파일 트리뷰로 ě „í™",
+ LcOpenMergeTool: "git mergetool를 열기",
+ LcRefresh: "ěëˇśęł ěą¨",
+ LcPush: "푸시",
+ LcPull: "업데이트",
+ LcScroll: "스í¬ëˇ¤",
+ MergeConflictsTitle: "병합 충돌 내용",
+ LcCheckout: "체í¬ě•„ě›",
+ LcFileFilter: "파일을 í•„í„°í•기 (Staged/unstaged)",
+ FilterStagedFiles: "Stagedëś íŚŚěťĽë§Ś 표시",
+ FilterUnstagedFiles: "Stageëě§€ 않은 파일만 표시",
+ ResetCommitFilterState: "필터 리셋",
+ NoChangedFiles: "ëł€ę˛˝ëś íŚŚěťĽěť´ 없습ë‹ë‹¤.",
+ NoFilesDisplay: "í‘śě‹śí• íŚŚěťĽěť´ 없습ë‹ë‹¤",
+ NotAFile: "파일이 ě•„ë‹™ë‹ë‹¤.",
+ PullWait: "업데이트 중...",
+ PushWait: "푸시 중...",
+ FetchWait: "íŚ¨ěą ě¤‘...",
+ LcSoftReset: "소프트 리셋",
+ AlreadyCheckedOutBranch: "브랜ěąę°€ 이미 체í¬ě•„ě› ëě—습ë‹ë‹¤",
+ SureForceCheckout: "ę°•ě śëˇś 체í¬ě•„ě›í•ě‹śę˛ ěŠµë‹ęąŚ? ëŞ¨ë“ ëˇśě»¬ 변경 사í•ěť„ ěžę˛Ś ë©ë‹ë‹¤.",
+ ForceCheckoutBranch: "ë¸Śëžśěą ę°•ě ś 체í¬ě•„ě›",
+ BranchName: "ë¸Śëžśěą ěť´ë¦„",
+ NewBranchNameBranchOff: "ě ë¸Śëžśěą ěť´ë¦„ (Branch is off of '{{.branchName}}')",
+ CantDeleteCheckOutBranch: "체í¬ě•„ě›í•는 브랜ěąëŠ” ě‚ě śí• ě 없습ë‹ë‹¤!",
+ DeleteBranch: "ë¸Śëžśěą ě‚ě ś",
+ DeleteBranchMessage: "ě •ë§ëˇś ë¸Śëžśěą '{{.selectedBranchName}}' 를 ě‚ě śí•ě‹śę˛ ěŠµë‹ęąŚ?",
+ ForceDeleteBranchMessage: "'{{.selectedBranchName}}'는 ě™„ě „íž ëł‘í•©ëě§€ 않ě•습ë‹ë‹¤. ě •ë§ ě‚ě śí•ě‹śę˛ ěŠµë‹ęąŚ?",
+ LcRebaseBranch: "체í¬ě•„ě›ëś 브랜ěąëĄĽ ěť´ 브랜ěąě— ë¦¬ë˛ ěť´ěŠ¤",
+ CantRebaseOntoSelf: "브랜ěąëĄĽ ěžę¸° ěžě‹ ě—게 ë¦¬ë˛ ěť´ěŠ¤í• ě는 없습ë‹ë‹¤.",
+ CantMergeBranchIntoItself: "브랜ěąëĄĽ ěžę¸° ěžě‹ ě—게 ëł‘í•©í• ě는 없습ë‹ë‹¤.",
+ LcForceCheckout: "ę°•ě ś 체í¬ě•„ě›",
+ LcCheckoutByName: "이름으로 체í¬ě•„ě›",
+ LcNewBranch: "ě ë¸Śëžśěą ěťě„±",
+ LcDeleteBranch: "ë¸Śëžśěą ě‚ě ś",
+ NoBranchesThisRepo: "ě €ěžĄě†Śě— ë¸Śëžśěąę°€ 존재í•ě§€ 않습ë‹ë‹¤.",
+ CommitMessageConfirm: "{{.keyBindClose}}: 닫기, {{.keyBindNewLine}}: 개행, {{.keyBindConfirm}}: 확인",
+ CommitWithoutMessageErr: "커밋 메시지를 ěž…ë Ąí•세요.",
+ CloseConfirm: "{{.keyBindClose}}: 닫기/취소, {{.keyBindConfirm}}: 확인",
+ LcClose: "닫기",
+ LcQuit: "종료",
+ LcSquashDown: "squash down",
+ LcFixupCommit: "fixup commit",
+ NoCommitsThisBranch: "ěť´ 브랜ěąě— 커밋이 없습ë‹ë‹¤.",
+ OnlySquashTopmostCommit: "Can only squash topmost commit",
+ YouNoCommitsToSquash: "You have no commits to squash with",
+ Fixup: "Fixup",
+ SureFixupThisCommit: "Are you sure you want to 'fixup' this commit? It will be merged into the commit below",
+ SureSquashThisCommit: "Are you sure you want to squash this commit into the commit below?",
+ Squash: "Squash",
+ LcPickCommit: "pick commit (when mid-rebase)",
+ LcRevertCommit: "커밋 ë돌리기",
+ LcRewordCommit: "커밋메시지 변경",
+ LcDeleteCommit: "커밋 ě‚ě ś",
+ LcMoveDownCommit: "커밋을 1ę°ś ě•„ëžëˇś 이동",
+ LcMoveUpCommit: "커밋을 1개 위로 이동",
+ LcEditCommit: "커밋을 편집",
+ LcAmendToCommit: "amend commit with staged changes",
+ LcResetCommitAuthor: "reset commit author",
+ SureResetCommitAuthor: "The author field of this commit will be updated to match the configured user. This also renews the author timestamp. Continue?",
+ LcRenameCommitEditor: "ě—디터ě—서 커밋메시지 ěě •",
+ Error: "ě¤ëĄ",
+ LcSelectHunk: "hunk를 ě„ íť",
+ LcNavigateConflicts: "navigate conflicts",
+ LcPickHunk: "pick hunk",
+ LcPickAllHunks: "pick all hunks",
+ LcUndo: "ë돌리기",
+ LcUndoReflog: "ë돌리기 (reflog) (실í—ě )",
+ LcRedoReflog: "다시 실행 (reflog) (실í—ě )",
+ LcPop: "pop",
+ LcDrop: "drop",
+ LcApply: "ě ěš©",
+ NoStashEntries: "Stashę°€ 존재í•ě§€ 않습ë‹ë‹¤.",
+ StashDrop: "Stash를 ě‚ě ś",
+ SureDropStashEntry: "ě •ë§ëˇś Stash를 ě‚ě śí•ě‹śę˛ ěŠµë‹ęąŚ?",
+ StashPop: "Stash를 pop",
+ SurePopStashEntry: "ě •ë§ëˇś Stash를 popí•ě‹śę˛ ěŠµë‹ęąŚ?",
+ StashApply: "Stash ě ěš©",
+ SureApplyStashEntry: "ě •ë§ëˇś Stash를 ě ěš©í•ě‹śę˛ ěŠµë‹ęąŚ?",
+ NoTrackedStagedFilesStash: "You have no tracked/staged files to stash",
+ StashChanges: "변경을 Stash",
+ OpenConfig: "ě„¤ě • 파일 열기",
+ EditConfig: "ě„¤ě • 파일 ěě •",
+ ForcePush: "ę°•ě ś 푸시",
+ ForcePushPrompt: "브랜ěąę°€ ě›ę˛© 브랜ěąě—서 분기í•ęł ěžěеë‹ë‹¤. 'esc'를 ëŚëź¬ ě·¨ě†Śí•ę±°ë‚, 'enter'를 ëŚëź¬ ę°•ě śëˇś 푸시í•세요.",
+ ForcePushDisabled: "브랜ěąę°€ ě›ę˛© 브랜ěąě—서 분기í•ęł ěžěеë‹ë‹¤. force pushę°€ 비활성화 ëě—습ë‹ë‹¤.",
+ UpdatesRejectedAndForcePushDisabled: "업데이트가 ę±°ë¶€ëě—으며 ę°•ě ś 푸시를 비활성화í–습ë‹ë‹¤.",
+ LcCheckForUpdate: "업데이트 확인",
+ CheckingForUpdates: "업데이트 확인 중...",
+ UpdateAvailableTitle: "ě로운 업데이트 사용가능!",
+ UpdateAvailable: "ë˛„ě „ {{.newVersion}} ěť„(를) 설ěąí•ě‹śę˛ ěŠµë‹ęąŚ?",
+ UpdateInProgressWaitingStatus: "업데이트 중",
+ UpdateCompletedTitle: "업데이트 완료!",
+ UpdateCompleted: "업데이트 설ěąě— 성공í–습ë‹ë‹¤. lazygit를 재시작해주세요.",
+ FailedToRetrieveLatestVersionErr: "ë˛„ě „ ě •ëł´ëĄĽ 받아ě¤ëŠ”ëŤ° 실패í–습ë‹ë‹¤.",
+ OnLatestVersionErr: "이미 ěµśě‹ ë˛„ě „ěť„ 사용í•ęł ěžěеë‹ë‹¤.",
+ MajorVersionErr: "ě ë˛„ě „ ({{.newVersion}}) ě— í„재 ë˛„ě „({{.currentVersion}}) 과 ëą„ęµí• 때 í¸í™ëě§€ 않는 변경 사í•ěť´ ěžěеë‹ë‹¤.",
+ CouldNotFindBinaryErr: "{{.url}} ě—서 바이ë„리를 ě°ľěť„ ě 없습ë‹ë‹¤.",
+ UpdateFailedErr: "업데이트 실패: {{.errMessage}}",
+ ConfirmQuitDuringUpdateTitle: "í„재 업데이트 중입ë‹ë‹¤.",
+ ConfirmQuitDuringUpdate: "í„재 업데이트를 ě§„í–‰ 중입ë‹ë‹¤.종료í•ě‹śę˛ ěŠµë‹ęąŚ?",
+ MergeToolTitle: "병합 도구",
+ MergeToolPrompt: "ě •ë§ëˇś `git mergetool`ěť„ ě—¬ě‹śę˛ ěŠµë‹ęąŚ?",
+ IntroPopupMessage: koreanIntroPopupMessage,
+ GitconfigParseErr: `ë”°ě´í‘śëˇś 묶이지 않은 '\' 문ěžę°€ ěžě–´ě„ś Gogitěť´ gitconfig 파일을 분석í•ě§€ 못í–습ë‹ë‹¤. 이를 ě śę±°í•ë©´ ë¬¸ě śę°€ 해결ë©ë‹ë‹¤.`,
+ LcEditFile: `파일 편집`,
+ LcOpenFile: `파일 닫기`,
+ LcIgnoreFile: `.gitignoreě— ě¶”ę°€`,
+ LcRefreshFiles: `파일 ěëˇśęł ěą¨`,
+ LcMergeIntoCurrentBranch: `í„재 브랜ěąě— 병합`,
+ ConfirmQuit: `ě •ë§ëˇś 종료í•ě‹śę˛ ěŠµë‹ęąŚ?`,
+ SwitchRepo: `ěµśę·Ľě— ě‚¬ěš©í•ś ě €ěžĄě†Śëˇś ě „í™`,
+ LcAllBranchesLogGraph: `ëŞ¨ë“ ë¸Śëžśěą ëˇśę·¸ 표시`,
+ UnsupportedGitService: `ě§€ě›ëě§€ 않는 Git 서비스입ë‹ë‹¤.`,
+ LcCreatePullRequest: `í’€ 리í€ěŠ¤íŠ¸ ěťě„±`,
+ LcCopyPullRequestURL: `í’€ 리í€ěŠ¤íŠ¸ URLěť„ í´ë¦˝ëł´ë“śě— 복사`,
+ NoBranchOnRemote: `브랜ěąę°€ ě›ę˛©ě— 없습ë‹ë‹¤. ě›ę˛©ě— ë¨Ľě € 푸시해야합ë‹ë‹¤.`,
+ LcFetch: `fetch`,
+ NoAutomaticGitFetchTitle: `ěžëŹ™ git 업데이트) 없음`,
+ NoAutomaticGitFetchBody: `Lazygit은 private ě €ěžĄě†Śě—서 "git fetch"를 ě‚¬ěš©í• ě 없습ë‹ë‹¤. 파일 패ë„ě—서 'f'를 사용í•ě—¬ "git fetch"를 ě동으로 실행í•세요.`,
+ FileEnter: `stage individual hunks/lines for file, or collapse/expand for directory`,
+ FileStagingRequirements: `ě¶”ě ëś íŚŚěťĽě— ëŚ€í•´ ę°śëł„ 라인만 stageí• ě ěžěеë‹ë‹¤.`,
+ StageSelection: `ě„ íťí•ś 행을 staged / unstaged`,
+ ResetSelection: `변경을 ě‚ě ś (git reset)`,
+ ToggleDragSelect: `드ëžę·¸ ě„ íť ě „í™`,
+ ToggleSelectHunk: `toggle select hunk`,
+ ToggleSelectionForPatch: `line(s)ěť„ 패ěąě— 추가/ě‚ě ś`,
+ ToggleStagingPanel: `íŚ¨ë„ ě „í™`,
+ ReturnToFilesPanel: `파일 목록으로 돌아가기`,
+ FastForward: `fast-forward this branch from its upstream`,
+ Fetching: "fetching and fast-forwarding {{.from}} -> {{.to}} ...",
+ FoundConflicts: "Conflicts! To abort press 'esc', otherwise press 'enter'",
+ FoundConflictsTitle: "Auto-merge failed",
+ PickHunk: "pick hunk",
+ PickAllHunks: "pick all hunks",
+ ViewMergeRebaseOptions: "view merge/rebase options",
+ NotMergingOrRebasing: "You are currently neither rebasing nor merging",
+ RecentRepos: "ěµśę·Ľě— ě‚¬ěš©í•ś ě €ěžĄě†Ś",
+ MergeOptionsTitle: "Merge Options",
+ RebaseOptionsTitle: "Rebase Options",
+ CommitMessageTitle: "커밋메시지",
+ LocalBranchesTitle: "브랜ěą",
+ SearchTitle: "검ě‰",
+ TagsTitle: "íśę·¸",
+ MenuTitle: "메뉴",
+ RemotesTitle: "ě›ę˛©",
+ RemoteBranchesTitle: "ě›ę˛© 브랜ěą",
+ PatchBuildingTitle: "메인 íŚ¨ë„ (Patch Building)",
+ InformationTitle: "ě •ëł´",
+ SecondaryTitle: "Secondary",
+ ReflogCommitsTitle: "Reflog",
+ GlobalTitle: "글로벌 키 바인딩",
+ ConflictsResolved: "ëŞ¨ë“ ëł‘í•© 충돌이 해결ëě—습ë‹ë‹¤. 계속 í• ęąŚěš”?",
+ RebasingTitle: "ë¦¬ë˛ ěť´ěŠ¤ 중",
+ ConfirmRebase: "ě •ë§ëˇś '{{.checkedOutBranch}}' ěť„(를) '{{.selectedBranch}}'ě— ë¦¬ë˛ ěť´ěŠ¤ í•ě‹śę˛ ěŠµë‹ęąŚ?",
+ ConfirmMerge: "ě •ë§ëˇś '{{.selectedBranch}}' ěť„(를) '{{.checkedOutBranch}}'ě— ëł‘í•©í•ě‹śę˛ ěŠµë‹ęąŚ?",
+ FwdNoUpstream: "Cannot fast-forward a branch with no upstream",
+ FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally",
+ FwdCommitsToPush: "Cannot fast-forward a branch with commits to push",
+ ErrorOccurred: "ě¤ëĄę°€ ë°śěťí–습ë‹ë‹¤! issue를 작성해 주세요: ",
+ NoRoom: "Not enough room",
+ YouAreHere: "í„재 ěś„ěą",
+ LcRewordNotSupported: "rewording commits while interactively rebasing is not currently supported",
+ LcCherryPickCopy: "커밋을 복사 (cherry-pick)",
+ LcCherryPickCopyRange: "커밋을 범위로 복사 (cherry-pick)",
+ LcPasteCommits: "커밋을 붙여넣기 (cherry-pick)",
+ SureCherryPick: "ě •ë§ëˇś 복사한 커밋을 ěť´ 브랜ěąě— 체리픽í•ě‹śę˛ ěŠµë‹ęąŚ?",
+ CherryPick: "체리픽",
+ CannotRebaseOntoFirstCommit: "첫 ë˛ě§¸ ě»¤ë°‹ě— ëŚ€í•´ 대화식으로 ë¦¬ë˛ ěť´ěŠ¤í• ě 없습ë‹ë‹¤.",
+ CannotSquashOntoSecondCommit: "ë‘ ë˛ě§¸ 커밋을 squash/fixupí• ě 없습ë‹ë‹¤.",
+ Donate: "후ě›",
+ AskQuestion: "ě§ë¬¸í•기",
+ PrevLine: "ěť´ě „ 줄 ě„ íť",
+ NextLine: "다음 줄 ě„ íť",
+ PrevHunk: "ěť´ě „ hunk를 ě„ íť",
+ NextHunk: "다음 hunk를 ě„ íť",
+ PrevConflict: "ěť´ě „ 충돌을 ě„ íť",
+ NextConflict: "다음 충돌을 ě„ íť",
+ SelectPrevHunk: "ěť´ě „ hunk를 ě„ íť",
+ SelectNextHunk: "다음 hunk를 ě„ íť",
+ ScrollDown: "ě•„ëžëˇś 스í¬ëˇ¤",
+ ScrollUp: "위로 스í¬ëˇ¤",
+ LcScrollUpMainPanel: "메인 패ë„ěť„ 위로 스í¬ëˇ¤",
+ LcScrollDownMainPanel: "메인 패ë„ěť„ ě•„ëžëˇśëˇś 스í¬ëˇ¤",
+ AmendCommitTitle: "Amend Commit",
+ AmendCommitPrompt: "Are you sure you want to amend this commit with your staged files?",
+ DeleteCommitTitle: "커밋 ě‚ě ś",
+ DeleteCommitPrompt: "ě •ë§ëˇś ě„ íťí•ś 커밋을 ě‚ě śí•ě‹śę˛ ěŠµë‹ęąŚ?",
+ SquashingStatus: "squashing",
+ FixingStatus: "fixing up",
+ DeletingStatus: "deleting",
+ MovingStatus: "moving",
+ RebasingStatus: "rebasing",
+ AmendingStatus: "amending",
+ CherryPickingStatus: "cherry-picking",
+ UndoingStatus: "undoing",
+ RedoingStatus: "redoing",
+ CheckingOutStatus: "checking out",
+ CommittingStatus: "committing",
+ CommitFiles: "Commit files",
+ SubCommitsDynamicTitle: "커밋 (%s)",
+ CommitFilesDynamicTitle: "Diff files (%s)",
+ RemoteBranchesDynamicTitle: "ě›ę˛©ë¸Śëžśěą (%s)",
+ LcViewItemFiles: "view selected item's files",
+ CommitFilesTitle: "커밋 파일",
+ LcCheckoutCommitFile: "checkout file",
+ LcDiscardOldFileChange: "discard this commit's changes to this file",
+ DiscardFileChangesTitle: "파일 변경 ě‚¬í• ë˛„ë¦¬ę¸°",
+ DiscardFileChangesPrompt: "Are you sure you want to discard this commit's changes to this file? If this file was created in this commit, it will be deleted",
+ DisabledForGPG: "Feature not available for users using GPG",
+ CreateRepo: "Git ě €ěžĄě†Śę°€ ě•„ë‹™ë‹ë‹¤. ě €ěžĄě†ŚëĄĽ ěťě„±í•ě‹śę˛ ěŠµë‹ęąŚ? (y/n): ",
+ AutoStashTitle: "Autostash?",
+ AutoStashPrompt: "You must stash and pop your changes to bring them across. Do this automatically? (enter/esc)",
+ StashPrefix: "Auto-stashing changes for ",
+ LcViewDiscardOptions: "view 'discard changes' options",
+ LcCancel: "취소",
+ LcDiscardAllChanges: "ëŞ¨ë“ ëł€ę˛˝ě‚¬í• ë˛„ë¦¬ę¸°",
+ LcDiscardUnstagedChanges: "discard unstaged changes",
+ LcDiscardAllChangesToAllFiles: "nuke working tree",
+ LcDiscardAnyUnstagedChanges: "discard unstaged changes",
+ LcDiscardUntrackedFiles: "discard untracked files",
+ LcHardReset: "hard reset",
+ LcViewResetOptions: `view reset options`,
+ LcCreateFixupCommit: `create fixup commit for this commit`,
+ LcSquashAboveCommits: `squash all 'fixup!' commits above selected commit (autosquash)`,
+ SquashAboveCommits: `Squash all 'fixup!' commits above selected commit (autosquash)`,
+ SureSquashAboveCommits: `Are you sure you want to squash all fixup! commits above {{.commit}}?`,
+ CreateFixupCommit: `Create fixup commit`,
+ SureCreateFixupCommit: `Are you sure you want to create a fixup! commit for commit {{.commit}}?`,
+ LcExecuteCustomCommand: "execute custom command",
+ CustomCommand: "Custom Command:",
+ LcCommitChangesWithoutHook: "commit changes without pre-commit hook",
+ SkipHookPrefixNotConfigured: "You have not configured a commit message prefix for skipping hooks. Set `git.skipHookPrefix = 'WIP'` in your config",
+ LcResetTo: `reset to`,
+ PressEnterToReturn: "엔터를 ëŚëź¬ lazygit으로 돌아갑ë‹ë‹¤.",
+ LcViewStashOptions: "Stash ěµě… 보기",
+ LcStashAllChanges: "변경사í•ěť„ Stash",
+ LcStashStagedChanges: "stash staged changes",
+ LcStashOptions: "Stash ěµě…",
+ NotARepository: "Error: must be run inside a git repository",
+ LcJump: "패ë„로 이동",
+ LcScrollLeftRight: "좌우로 스í¬ëˇ¤",
+ LcScrollLeft: "ěš° 스í¬ëˇ¤",
+ LcScrollRight: "좌 스í¬ëˇ¤",
+ DiscardPatch: "patch 버리기",
+ DiscardPatchConfirm: "You can only build a patch from one commit/stash-entry at a time. Discard current patch?",
+ CantPatchWhileRebasingError: "You cannot build a patch or run patch commands while in a merging or rebasing state",
+ LcToggleAddToPatch: "toggle file included in patch",
+ LcToggleAllInPatch: "toggle all files included in patch",
+ LcUpdatingPatch: "updating patch",
+ ViewPatchOptions: "커스텀 Patch ěµě… 보기",
+ PatchOptionsTitle: "Patch ěµě…",
+ NoPatchError: "No patch created yet. To start building a patch, use 'space' on a commit file or enter to add specific lines",
+ LcEnterFile: "enter file to add selected lines to the patch (or toggle directory collapsed)",
+ // ExitCustomPatchBuilder: ``,
+ EnterUpstream: `' '와 같은 í•식으로 ěž…ë Ąí•세요.`,
+ InvalidUpstream: "upstreaměť í•식이 ěžëŞ»ëě—습ë‹ë‹¤.' ' 와 같은 í•식으로 ěž…ë Ąí•세요.",
+ ReturnToRemotesList: `ě›ę˛©ëŞ©ëˇťěśĽëˇś 돌아가기`,
+ LcAddNewRemote: `ě로운 Remote 추가`,
+ LcNewRemoteName: `ě로운 Remote 이름:`,
+ LcNewRemoteUrl: `ě로운 Remote URL:`,
+ LcEditRemoteName: `{{.remoteName}} ěť ě로운 Remote 이름 ěž…ë Ą:`,
+ LcEditRemoteUrl: `{{.remoteName}} ěť ě로운 Remote URL ěž…ë Ą:`,
+ LcRemoveRemote: `Remote를 ě‚ě ś`,
+ LcRemoveRemotePrompt: "ě •ë§ëˇś Remote를 ě‚ě śí•ě‹śę˛ ěŠµë‹ęąŚ?",
+ DeleteRemoteBranch: "ě›ę˛© 브랜ěąëĄĽ ě‚ě ś",
+ DeleteRemoteBranchMessage: "ě •ë§ëˇś ě›ę˛© 브랜ěąëĄĽ ě‚ě śí•ě‹śę˛ ěŠµë‹ęąŚ?",
+ LcSetUpstream: "set as upstream of checked-out branch",
+ SetUpstreamTitle: "Set upstream branch",
+ SetUpstreamMessage: "Are you sure you want to set the upstream branch of '{{.checkedOut}}' to '{{.selected}}'",
+ LcEditRemote: "Remote를 ěě •",
+ LcTagCommit: "tag commit",
+ TagMenuTitle: "íśę·¸ 작성",
+ TagNameTitle: "íśę·¸ 이름:",
+ TagMessageTitle: "íśę·¸ 메시지: ",
+ LcAnnotatedTag: "annotated tag",
+ LcLightweightTag: "lightweight tag",
+ LcDeleteTag: "íśę·¸ ě‚ě ś",
+ DeleteTagTitle: "íśę·¸ ě‚ě ś",
+ DeleteTagPrompt: "ě •ë§ëˇś íśę·¸ '{{.tagName}}' 를 ě‚ě śí•ě‹śę˛ ěŠµë‹ęąŚ?",
+ PushTagTitle: "ě›ę˛©ě— íśę·¸ '{{.tagName}}' 를 푸시",
+ LcPushTag: "íśę·¸ëĄĽ push",
+ LcCreateTag: "íśę·¸ëĄĽ ěťě„±",
+ CreateTagTitle: "íśę·¸ 이름:",
+ LcFetchRemote: "ě›ę˛©ěť„ 업데이트",
+ FetchingRemoteStatus: "ě›ę˛©ěť„ 업데이트 중",
+ LcCheckoutCommit: "커밋을 체í¬ě•„ě›",
+ SureCheckoutThisCommit: "ě •ë§ëˇś ě„ íťí•ś 커밋을 체í¬ě•„ě› í•ě‹śę˛ ěŠµë‹ęąŚ?",
+ LcGitFlowOptions: "git-flow ěµě… 보기",
+ NotAGitFlowBranch: "This does not seem to be a git flow branch",
+ NewGitFlowBranchPrompt: "new {{.branchType}} name:",
+ IgnoreTracked: "Ignore tracked file",
+ IgnoreTrackedPrompt: "Are you sure you want to ignore a tracked file?",
+ LcViewResetToUpstreamOptions: "view upstream reset options",
+ LcNextScreenMode: "다음 스í¬ë¦° 모드 (normal/half/fullscreen)",
+ LcPrevScreenMode: "ěť´ě „ 스í¬ë¦° 모드",
+ LcStartSearch: "ę˛€ě‰ ě‹śěž‘",
+ Panel: "패ë„",
+ Keybindings: "키 바인딩",
+ LcRenameBranch: "ë¸Śëžśěą ěť´ë¦„ 변경",
+ NewBranchNamePrompt: "ě로운 ë¸Śëžśěą ěť´ë¦„ ěž…ë Ą",
+ RenameBranchWarning: "This branch is tracking a remote. This action will only rename the local branch name, not the name of the remote branch. Continue?",
+ LcOpenMenu: "매뉴 열기",
+ LcResetCherryPick: "reset cherry-picked (copied) commits selection",
+ LcNextTab: "ěť´ě „ í",
+ LcPrevTab: "다음 í",
+ LcCantUndoWhileRebasing: "ë¦¬ë˛ ěť´ěŠ¤ě¤‘ě—는 ë돌릴 ě 없습ë‹ë‹¤.",
+ LcCantRedoWhileRebasing: "ë¦¬ë˛ ěť´ěŠ¤ě¤‘ě—는 다시 ě‹¤í–‰í• ě 없습ë‹ë‹¤.",
+ MustStashWarning: "Pulling a patch out into the index requires stashing and unstashing your changes. If something goes wrong, you'll be able to access your files from the stash. Continue?",
+ MustStashTitle: "Must stash",
+ ConfirmationTitle: "확인 패ë„",
+ LcPrevPage: "ěť´ě „ íŽěť´ě§€",
+ LcNextPage: "다음 íŽěť´ě§€",
+ LcGotoTop: "맨 위로 스í¬ëˇ¤ ",
+ LcGotoBottom: "맨 ě•„ëžëˇś 스í¬ëˇ¤ ",
+ LcFilteringBy: "filtering by",
+ ResetInParentheses: "(reset)",
+ LcOpenFilteringMenu: "view filter-by-path options",
+ LcFilterBy: "filter by",
+ LcExitFilterMode: "stop filtering by path",
+ LcFilterPathOption: "enter path to filter by",
+ EnterFileName: "Enter path:",
+ FilteringMenuTitle: "Filtering",
+ MustExitFilterModeTitle: "Command not available",
+ MustExitFilterModePrompt: "Command not available in filtered mode. Exit filtered mode?",
+ LcDiff: "Diff",
+ LcEnterRefToDiff: "enter ref to diff",
+ LcEnteRefName: "ref ěž…ë Ą:",
+ LcExitDiffMode: "Diff 모드 종료",
+ DiffingMenuTitle: "Diff",
+ LcSwapDiff: "reverse diff direction",
+ LcOpenDiffingMenu: "Diff 메뉴 열기",
+ // the actual view is the extras view which I intend to give more tabs in future but for now we'll only mention the command log part
+ LcOpenExtrasMenu: "ëŞ…ë ąě–´ 로그 메뉴 열기",
+ LcShowingGitDiff: "showing output for:",
+ LcCommitDiff: "ě»¤ë°‹ěť iff",
+ LcCopyCommitShaToClipboard: "커밋 SHA를 í´ë¦˝ëł´ë“śě— 복사",
+ LcCommitSha: "커밋 SHA",
+ LcCommitURL: "커밋 URL",
+ LcCopyCommitMessageToClipboard: "커밋 메시지를 í´ë¦˝ëł´ë“śě— 복사",
+ LcCommitMessage: "커밋 메시지",
+ LcCommitAuthor: "커밋 작성ěž",
+ LcCopyCommitAttributeToClipboard: "커밋 attribute 복사",
+ LcCopyBranchNameToClipboard: "브랜ěąëŞ…ěť„ í´ë¦˝ëł´ë“śě— 복사",
+ LcCopyFileNameToClipboard: "파일명을 í´ë¦˝ëł´ë“śě— 복사",
+ LcCopyCommitFileNameToClipboard: "커밋한 파일명을 í´ë¦˝ëł´ë“śě— 복사",
+ LcCopySelectedTexToClipboard: "ě„ íťí•ś 텍스트를 í´ë¦˝ëł´ë“śě— 복사",
+ LcCommitPrefixPatternError: "Error in commitPrefix pattern",
+ NoFilesStagedTitle: "파일이 Staged ëě§€ 않ě•습ë‹ë‹¤.",
+ NoFilesStagedPrompt: "파일이 Staged ëě§€ 않ě•습ë‹ë‹¤. ëŞ¨ë“ íŚŚěťĽěť„ 커밋í•ě‹śę˛ ěŠµë‹ęąŚ?",
+ BranchNotFoundTitle: "브랜ěąëĄĽ ě°ľěť„ ě 없습ë‹ë‹¤.",
+ BranchNotFoundPrompt: "브랜ěąëĄĽ ě°ľěť„ ě 없습ë‹ë‹¤. ě로운 브랜ěąëĄĽ ěťě„±í•©ë‹ë‹¤.",
+ UnstageLinesTitle: "ě„ íťí•ś 라인을 unstaged",
+ UnstageLinesPrompt: "ě •ë§ëˇś ě„ íťí•ś 라인을 ě‚ě ś (git reset) í•ě‹śę˛ ěŠµë‹ęąŚ? ěť´ 조작은 ě·¨ě†Śí• ě 없습ë‹ë‹¤.\něť´ ę˛˝ęł ëĄĽ 비활성화 í•ë ¤ë©´ ě„¤ě • íŚŚěťĽěť 'gui.skipUnstageLineWarning' 를 true로 ě„¤ě •í•세요.",
+ LcCreateNewBranchFromCommit: "커밋ě—서 ě 브랜ěąëĄĽ ë§Śë“ë‹ë‹¤.",
+ LcBuildingPatch: "building patch",
+ LcViewCommits: "커밋 보기",
+ MinGitVersionError: "lazygit 실행을 위해서는 Git 2.0 ěť´í›„ěť ë˛„ě „(2014ë…„ 이후ěť)ěť´ 필요합ë‹ë‹¤. Git를 업데이트 해주세요. ě•„ë‹ë©´ lazygitěť´ ěť´ě „ ë˛„ě „ęłĽ 더 ěž í¸í™ë도록 https://github.com/jesseduffield/lazygit/issues ě— issue를 작성해 주세요.",
+ LcRunningCustomCommandStatus: "커스텀 ëŞ…ë ąě–´ 실행",
+ LcSubmoduleStashAndReset: "stash uncommitted submodule changes and update",
+ LcAndResetSubmodules: "and reset submodules",
+ LcEnterSubmodule: "ě„śë¸ŚëŞ¨ë“ ě—´ę¸°",
+ LcCopySubmoduleNameToClipboard: "ě„śë¸ŚëŞ¨ë“ ěť´ë¦„ěť„ í´ë¦˝ëł´ë“śě— 복사",
+ RemoveSubmodule: "ě„śë¸ŚëŞ¨ë“ ě‚ě ś",
+ LcRemoveSubmodule: "ě„śë¸ŚëŞ¨ë“ ě‚ě ś",
+ RemoveSubmodulePrompt: "ě •ë§ëˇś ě„śë¸ŚëŞ¨ë“ '%s'ë°Ź 해당 ë””ë ‰í† ë¦¬ëĄĽ ě śę±°í•ě‹śę˛ ěŠµë‹ęąŚ? ěť´ę˛ěť€ ë돌릴 ě 없습ë‹ë‹¤.",
+ LcResettingSubmoduleStatus: "서브모ë“를 리셋",
+ LcNewSubmoduleName: "ě로운 서브모ë“이름 :",
+ LcNewSubmoduleUrl: "ě로운 서브모ë“ěť URL:",
+ LcNewSubmodulePath: "ě로운 서브모ë“ěť ę˛˝ëˇś",
+ LcAddSubmodule: "ě로운 ě„śë¸ŚëŞ¨ë“ ě¶”ę°€",
+ LcAddingSubmoduleStatus: "ě로운 ě„śë¸ŚëŞ¨ë“ ě¶”ę°€",
+ LcUpdateSubmoduleUrl: "ě„śë¸ŚëŞ¨ë“ '%s' ěť URLěť„ 업데이트",
+ LcUpdatingSubmoduleUrlStatus: "updating URL",
+ LcEditSubmoduleUrl: "서브모ë“ěť URLěť„ ěě •",
+ LcInitializingSubmoduleStatus: "ě„śë¸ŚëŞ¨ë“ ě´ę¸°í™”",
+ LcInitSubmodule: "ě„śë¸ŚëŞ¨ë“ ě´ę¸°í™”",
+ LcSubmoduleUpdate: "ě„śë¸ŚëŞ¨ë“ ě—…ëŤ°ěť´íŠ¸",
+ LcUpdatingSubmoduleStatus: "ě„śë¸ŚëŞ¨ë“ ě—…ëŤ°ěť´íŠ¸",
+ LcBulkInitSubmodules: "ě„śë¸ŚëŞ¨ë“ ěťĽę´„ ě´ę¸°í™”",
+ LcBulkUpdateSubmodules: "ě„śë¸ŚëŞ¨ë“ ěťĽę´„ 업데이트",
+ LcBulkDeinitSubmodules: "bulk deinit submodules",
+ LcViewBulkSubmoduleOptions: "view bulk submodule options",
+ LcBulkSubmoduleOptions: "bulk submodule options",
+ LcRunningCommand: "running command",
+ SubCommitsTitle: "Sub-commits",
+ SubmodulesTitle: "서브모ë“",
+ NavigationTitle: "List Panel Navigation",
+ SuggestionsCheatsheetTitle: "추천",
+ SuggestionsTitle: "추천 (press %s to focus)",
+ ExtrasTitle: "ëŞ…ë ąě–´ 로그",
+ PushingTagStatus: "pushing tag",
+ PullRequestURLCopiedToClipboard: "í’€ 리í€ěŠ¤íŠ¸ěť URLěť„ í´ë¦˝ëł´ë“śě— 복사í–습ë‹ë‹¤.",
+ CommitDiffCopiedToClipboard: "ě»¤ë°‹ěť Diff를 í´ë¦˝ëł´ë“śě— 복사í–습ë‹ë‹¤.",
+ CommitSHACopiedToClipboard: "ě»¤ë°‹ěť SHA를 í´ë¦˝ëł´ë“śě— 복사í–습ë‹ë‹¤.",
+ CommitURLCopiedToClipboard: "ě»¤ë°‹ěť URL를 í´ë¦˝ëł´ë“śě— 복사í–습ë‹ë‹¤.",
+ CommitMessageCopiedToClipboard: "커밋 메시지를 í´ë¦˝ëł´ë“śě— 복사í–습ë‹ë‹¤.",
+ CommitAuthorCopiedToClipboard: "커밋 작성ěžëĄĽ í´ë¦˝ëł´ë“śě— 복사í–습ë‹ë‹¤.",
+ LcCopiedToClipboard: "í´ë¦˝ëł´ë“śě— 복사í–습ë‹ë‹¤.",
+ ErrCannotEditDirectory: "ë””ë ‰í† ë¦¬ëŠ” íŽ¸ě§‘í• ě 없습ë‹ë‹¤.",
+ ErrStageDirWithInlineMergeConflicts: "병합 충돌이 ë°śěťí•ś 파일을 포함í•는 ë””ë ‰í† ë¦¬ëŠ” Staged/untagedí• ě 없습ë‹ë‹¤. 병합 충돌을 ë¨Ľě € 해결í•세요.",
+ ErrRepositoryMovedOrDeleted: "ě €ěžĄě†ŚëĄĽ ě°ľěť„ ě 없습ë‹ë‹¤. 이미 ě‚ě śëě—ę±°ë‚ ěť´ëŹ™ëě—ěť„ 가능성이 ěžěеë‹ë‹¤. ÂŻ\\_(ă„)_/ÂŻ",
+ CommandLog: "ëŞ…ë ąě–´ 로그",
+ ToggleShowCommandLog: "ëŞ…ë ąě–´ 로그 표시 여부 ě „í™",
+ FocusCommandLog: "ëŞ…ë ąě–´ ëˇśę·¸ě— íŹ¬ě»¤ěŠ¤",
+ CommandLogHeader: "ëŞ…ë ąě–´ 로그표시 여부는 '%s' 으로 ě „í™í• ě ěžěеë‹ë‹¤.\n",
+ RandomTip: "랜덤 Tip",
+ SelectParentCommitForMerge: "병합을 위한 ěěś„ 커밋 ě„ íť",
+ ToggleWhitespaceInDiffView: "공백문ěžëĄĽ Diff ë·°ě—서 표시 여부 ě „í™",
+ IgnoringWhitespaceInDiffView: "공백문ěžëĄĽ Diff ë·°ě—서 무시",
+ ShowingWhitespaceInDiffView: "공백문ěžëĄĽ Diff ë·°ě—서 표시",
+ IncreaseContextInDiffView: "diff ëł´ę¸°ěť ëł€ę˛˝ ě‚¬í• ěŁĽěś„ě— í‘śě‹śë는 ě»¨í…ŤěŠ¤íŠ¸ěť í¬ę¸°ëĄĽ ëŠë¦¬ę¸°",
+ DecreaseContextInDiffView: "diff ëł´ę¸°ěť ëł€ę˛˝ ě‚¬í• ěŁĽěś„ě— í‘śě‹śë는 컨텍스트 í¬ę¸° 줄이기",
+ CreatePullRequest: "í’€ 리í€ěŠ¤íŠ¸ ěťě„±",
+ CreatePullRequestOptions: "í’€ 리í€ěŠ¤íŠ¸ ěťě„± ěµě…",
+ LcCreatePullRequestOptions: "í’€ 리í€ěŠ¤íŠ¸ ěťě„± ěµě…",
+ LcDefaultBranch: "기본 브랜ěą",
+ LcSelectBranch: "브랜ěąëĄĽ ě„ íť",
+ SelectConfigFile: "ě„¤ě •íŚŚěťĽ ě„ íť",
+ NoConfigFileFoundErr: "ě„¤ě • 파일을 ě°ľě§€ 못í–습ë‹ë‹¤.",
+ LcLoadingFileSuggestions: "파일 ě śě• ëˇśë”© 중",
+ LcLoadingCommits: "커밋 로딩",
+ MustSpecifyOriginError: "Must specify a remote if specifying a branch",
+ GitOutput: "Git output:",
+ GitCommandFailed: "Git command failed. Check command log for details (open with %s)",
+ AbortTitle: "%s 중지",
+ AbortPrompt: "ě •ë§ëˇś 실행중인 %s 를 ě¤‘ě§€í• ęąŚěš”?",
+ LcOpenLogMenu: "로그 메뉴 열기",
+ LogMenuTitle: "커밋 로그 ěµě…",
+ ToggleShowGitGraphAll: "toggle show whole git graph (pass the `--all` flag to `git log`)",
+ ShowGitGraph: "커밋 ę·¸ëží”„ 표시",
+ SortCommits: "커밋 ě •ë ¬",
+ CantChangeContextSizeError: "Cannot change context while in patch building mode because we were too lazy to support it when releasing the feature. If you really want it, please let us know!",
+ LcOpenCommitInBrowser: "ë¸ŚëťĽěš°ě €ě—서 커밋 열기",
+ LcViewBisectOptions: "bisect ěµě… 보기",
+ ConfirmRevertCommit: "Are you sure you want to revert {{.selectedCommit}}?",
+ RewordInEditorTitle: "커밋 메시지를 ě—디터ě—서 ěě •",
+ RewordInEditorPrompt: "Are you sure you want to reword this commit in your editor?",
+ HardResetAutostashPrompt: "Are you sure you want to hard reset to '%s'? An auto-stash will be performed if necessary.",
+ CheckoutPrompt: "Are you sure you want to checkout '%s'?",
+ UpstreamGone: "(upstream gone)",
+ Actions: Actions{
+ // TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)
+ CheckoutCommit: "커밋 체í¬ě•„ě›",
+ CheckoutTag: "íśę·¸ 체í¬ě•„ě›",
+ CheckoutBranch: "ë¸Śëžśěą ě˛´í¬ě•„ě›",
+ ForceCheckoutBranch: "ë¸Śëžśěą Force 체í¬ě•„ě›",
+ DeleteBranch: "ë¸Śëžśěą ě‚ě ś",
+ Merge: "병합",
+ RebaseBranch: "ë¸Śëžśěą ë¦¬ë˛ ěť´ěŠ¤",
+ RenameBranch: "ë¸Śëžśěą ěť´ë¦„ 변경",
+ SetUnsetUpstream: "Set/unset upstream",
+ CreateBranch: "ë¸Śëžśěą ěťě„±",
+ CherryPick: "(Cherry-pick) 커밋 붙여넣기",
+ CheckoutFile: "체í¬ě•„ě› íŚŚěťĽ",
+ DiscardOldFileChange: "Discard old file change",
+ SquashCommitDown: "Squash commit down",
+ FixupCommit: "커밋 Fixup",
+ RewordCommit: "커밋 Reword",
+ DropCommit: "커밋 Drop",
+ EditCommit: "커밋 ěě •",
+ AmendCommit: "커밋 Amend",
+ ResetCommitAuthor: "커밋 ěž‘ě„±ěž Reset",
+ RevertCommit: "커밋 Revert",
+ CreateFixupCommit: "fixup 커밋 ěťě„±",
+ SquashAllAboveFixupCommits: "Squash all above fixup commits",
+ CreateLightweightTag: "Create lightweight tag",
+ CreateAnnotatedTag: "Create annotated tag",
+ CopyCommitMessageToClipboard: "커밋 메시지를 í´ë¦˝ëł´ë“śě— 복사",
+ CopyCommitDiffToClipboard: "커밋 diff를 í´ë¦˝ëł´ë“śě— 복사",
+ CopyCommitSHAToClipboard: "커밋 SHA를 í´ë¦˝ëł´ë“śě— 복사",
+ CopyCommitURLToClipboard: "커밋 URL를 í´ë¦˝ëł´ë“śě— 복사",
+ CopyCommitAuthorToClipboard: "커밋 작성ěžëĄĽ í´ë¦˝ëł´ë“śě— 복사",
+ CopyCommitAttributeToClipboard: "í´ë¦˝ëł´ë“śě— 복사",
+ MoveCommitUp: "Move commit up",
+ MoveCommitDown: "Move commit down",
+ CustomCommand: "Custom command",
+ DiscardAllChangesInDirectory: "Discard all changes in directory",
+ DiscardUnstagedChangesInDirectory: "Discard unstaged changes in directory",
+ DiscardAllChangesInFile: "Discard all changes in file",
+ DiscardAllUnstagedChangesInFile: "Discard all unstaged changes in file",
+ StageFile: "Stage file",
+ StageResolvedFiles: "Stage files whose merge conflicts were resolved",
+ UnstageFile: "Unstage file",
+ UnstageAllFiles: "Unstage all files",
+ StageAllFiles: "Stage all files",
+ LcIgnoreExcludeFile: "ignore file",
+ Commit: "커밋",
+ EditFile: "파일 ěě •",
+ Push: "푸시",
+ Pull: "업데이트(Pull)",
+ OpenFile: "파일 열기",
+ StashAllChanges: "Stash all changes",
+ StashAllChangesKeepIndex: "Stash all changes and keep index",
+ StashStagedChanges: "Stash staged changes",
+ StashUnstagedChanges: "Stash unstaged changes",
+ GitFlowFinish: "Git flow finish",
+ GitFlowStart: "Git Flow start",
+ CopyToClipboard: "Copy to clipboard",
+ CopySelectedTextToClipboard: "Copy selected text to clipboard",
+ RemovePatchFromCommit: "Remove patch from commit",
+ MovePatchToSelectedCommit: "Move patch to selected commit",
+ MovePatchIntoIndex: "Move patch into index",
+ MovePatchIntoNewCommit: "Move patch into new commit",
+ DeleteRemoteBranch: "Delete remote branch",
+ SetBranchUpstream: "Set branch upstream",
+ AddRemote: "Add remote",
+ RemoveRemote: "Remove remote",
+ UpdateRemote: "Update remote",
+ ApplyPatch: "Apply patch",
+ Stash: "Stash",
+ RemoveSubmodule: "ě„śë¸ŚëŞ¨ë“ ě‚ě ś",
+ ResetSubmodule: "ě„śë¸ŚëŞ¨ë“ Reset",
+ AddSubmodule: "ě„śë¸ŚëŞ¨ë“ ě¶”ę°€",
+ UpdateSubmoduleUrl: "ě„śë¸ŚëŞ¨ë“ URL 업데이트",
+ InitialiseSubmodule: "ě„śë¸ŚëŞ¨ë“ ě´ę¸°í™”",
+ BulkInitialiseSubmodules: "Bulk initialise submodules",
+ BulkUpdateSubmodules: "Bulk update submodules",
+ BulkDeinitialiseSubmodules: "Bulk deinitialise submodules",
+ UpdateSubmodule: "ě„śë¸ŚëŞ¨ë“ ě—…ëŤ°ěť´íŠ¸",
+ DeleteTag: "íśę·¸ ě‚ě ś",
+ PushTag: "íśę·¸ 푸시g",
+ NukeWorkingTree: "Nuke working tree",
+ DiscardUnstagedFileChanges: "unstaged 파일 ëł€ę˛˝ě‚¬í• ë˛„ë¦¬ę¸°",
+ RemoveUntrackedFiles: "untracked 파일 ě‚ě ś",
+ RemoveStagedFiles: "staged 파일 ě‚ě ś",
+ SoftReset: "Soft reset",
+ MixedReset: "Mixed reset",
+ HardReset: "Hard reset",
+ FastForwardBranch: "Fast forward branch",
+ Undo: "ë돌리기",
+ Redo: "다시 실행",
+ CopyPullRequestURL: "í’€ 리í€ěŠ¤íŠ¸ URL 복사",
+ OpenMergeTool: "병합 도구 열기",
+ OpenCommitInBrowser: "ë¸ŚëťĽěš°ě €ě—서 커밋 열기",
+ OpenPullRequest: "ë¸ŚëťĽěš°ě €ě—서 í’€ 리í€ěŠ¤íŠ¸ 열기",
+ StartBisect: "Start bisect",
+ ResetBisect: "Reset bisect",
+ BisectSkip: "Bisect skip",
+ BisectMark: "Bisect mark",
+ },
+ Bisect: Bisect{
+ Mark: "mark %s as %s",
+ MarkStart: "mark %s as %s (start bisect)",
+ Skip: "%s 를 스킵",
+ ResetTitle: "'git bisect' 를 리셋",
+ ResetPrompt: "ě •ë§ëˇś 'git bisect' 를 리셋í•ě‹śę˛ ěŠµë‹ęąŚ?",
+ ResetOption: "bisect를 리셋",
+ BisectMenuTitle: "bisect",
+ CompleteTitle: "Bisect 완료",
+ CompletePrompt: "Bisect complete! The following commit introduced the change:\n\n%s\n\nDo you want to reset 'git bisect' now?",
+ CompletePromptIndeterminate: "Bisect complete! Some commits were skipped, so any of the following commits may have introduced the change:\n\n%s\n\nDo you want to reset 'git bisect' now?",
+ },
+ }
+}
diff --git a/pkg/i18n/polish.go b/pkg/i18n/polish.go
index 2eeecb61c..74ea00673 100644
--- a/pkg/i18n/polish.go
+++ b/pkg/i18n/polish.go
@@ -17,6 +17,7 @@ func polishTranslationSet() TranslationSet {
PassUnameWrong: "Niewłaściwe hasło, fraza lub nazwa użytkownika",
CommitChanges: "ZatwierdĹş zmiany",
AmendLastCommit: "Zmień ostatni commit",
+ AmendLastCommitTitle: "Zmień Ostatni Commit",
SureToAmend: "Czy na pewno chcesz zmienić ostatni commit? Możesz zmienić komunikat commitu z panelu commitów.",
NoCommitToAmend: "Brak commitĂłw do zmiany.",
CommitChangesWithEditor: "ZatwierdĹş zmiany uĹĽywajÄ…c edytora",
@@ -29,7 +30,6 @@ func polishTranslationSet() TranslationSet {
LcToggleStagedAll: "przełącz stan poczekalni wszystkich",
LcRefresh: "odśwież",
LcScroll: "przewiń",
- LcCommitFileFilter: "Filtrowanie commitĂłw",
FilterStagedFiles: "PokaĹĽ tylko pliki w poczekalni",
FilterUnstagedFiles: "PokaĹĽ tylko pliki poza poczekalniÄ…",
ResetCommitFilterState: "Resetuj filtr commitĂłw",
@@ -61,7 +61,6 @@ func polishTranslationSet() TranslationSet {
CloseConfirm: "{{.keyBindClose}}: zamknij, {{.keyBindConfirm}}: potwierdĹş",
LcClose: "zamknij",
LcSquashDown: "ściśnij",
- LcResetToThisCommit: "zresetuj do tego commita",
LcFixupCommit: "napraw commit",
NoCommitsThisBranch: "Brak commitów dla tej gałęzi",
OnlySquashTopmostCommit: "Można tylko spłaszczyć najwyższy commit",
@@ -84,7 +83,6 @@ func polishTranslationSet() TranslationSet {
SureDropStashEntry: "Jesteś pewny, że chcesz porzucić tę pozycję w schowku?",
NoTrackedStagedFilesStash: "Nie masz śledzonych/zatwierdzonych plików do przechowania",
StashChanges: "Przechowaj zmiany",
- MergeAborted: "Scalanie anulowane",
OpenConfig: "otwĂłrz konfiguracjÄ™",
EditConfig: "edytuj konfiguracjÄ™",
ForcePush: "Wymuś wysłanie",
@@ -176,7 +174,7 @@ func polishTranslationSet() TranslationSet {
AmendingStatus: "poprawianie",
CherryPickingStatus: "przebieranie",
CommitFiles: "Pliki commita",
- LcViewCommitFiles: "przeglÄ…daj pliki commita",
+ LcViewItemFiles: "przeglÄ…daj pliki commita",
CommitFilesTitle: "Pliki commita",
LcCheckoutCommitFile: "plik wybierania",
LcDiscardOldFileChange: "porzuć zmiany commita dla tego pliku",
@@ -210,11 +208,11 @@ func polishTranslationSet() TranslationSet {
PressEnterToReturn: "Wciśnij enter żeby wrócić do lazygit",
LcViewStashOptions: "wyświetl opcje schowka",
LcStashAllChanges: "przechowaj zmiany",
- LcStashStagedChanges: "przechowaj zmiany z poczekalni",
+ LcStashAllChangesKeepIndex: "przechowaj zmiany z poczekalni",
LcStashOptions: "Opcje schowka",
NotARepository: "Błąd: nie jesteś w repozytorium",
LcJump: "przeskocz do panelu",
- ExitLineByLineMode: `wyście z trybu "linia po linii"`,
+ ExitCustomPatchBuilder: `wyście z trybu "linia po linii"`,
EnterUpstream: `Podaj gałąź nadrzędną jako ' '`,
ReturnToRemotesList: `wróć do listy repozytoriów zdalnych`,
IgnoreTracked: "Ignoruj plik śledzony",
diff --git a/pkg/integration/README.md b/pkg/integration/README.md
new file mode 100644
index 000000000..ba2365403
--- /dev/null
+++ b/pkg/integration/README.md
@@ -0,0 +1,74 @@
+# Integration Tests
+
+The pkg/integration pacakge is for integration testing: that is, actually running a real lazygit session and having a robot pretend to be a human user and then making assertions that everything works as expected.
+
+## Writing tests
+
+The tests live in pkg/integration/tests. Each test has two important steps: the setup step and the run step.
+
+### Setup step
+
+In the setup step, we prepare a repo with shell commands, for example, creating a merge conflict that will need to be resolved upon opening lazygit. This is all done via the `shell` argument.
+
+### Run step
+
+The run step has four arguments passed in:
+
+1. `shell`
+2. `input`
+3. `assert`
+4. `keys`
+
+`shell` we've already seen in the setup step. The reason it's passed into the run step is that we may want to emulate background events. For example, the user modifying a file outside of lazygit.
+
+`input` is for driving the gui by pressing certain keys, selecting list items, etc.
+
+`assert` is for asserting on the state of the lazygit session. When you call a method on `assert`, the assert struct will wait for the assertion to hold true and then continue (failing the test after a timeout). For this reason, assertions have two purposes: one is to ensure the test fails as soon as something unexpected happens, but another is to allow lazygit to process a keypress before you follow up with more keypresses. If you input a bunch of keypresses too quickly lazygit might get confused.
+
+### Tips
+
+Try to do as much setup work as possible in your setup step. For example, if all you're testing is that the user is able to resolve merge conflicts, create the merge conflicts in the setup step. On the other hand, if you're testing to see that lazygit can warn the user about merge conflicts after an attempted merge, it's fine to wait until the run step to actually create the conflicts. If the run step is focused on the thing you're trying to test, the test will run faster and its intent will be clearer.
+
+Use assertions to ensure that lazygit has processed all your keybindings so far. For example, if you press 'n' on a branch to create a new branch, assert that the confirmation view is now focused.
+
+If you find yourself doing something frequently in a test, consider making it a method in one of the helper arguments. For example, instead of calling `input.PressKey(keys.Universal.Confirm)` in 100 places, it's better to have a method `input.Confirm()`. This is not to say that everything should be made into a method on the input struct: just things that are particularly common in tests.
+
+## Running tests
+
+There are three ways to invoke a test:
+
+1. go run cmd/integration_test/main.go cli [...]
+2. go run cmd/integration_test/main.go tui
+3. go test pkg/integration/clients/go_test.go
+
+The first, the test runner, is for directly running a test from the command line. If you pass no arguments, it runs all tests.
+The second, the TUI, is for running tests from a terminal UI where it's easier to find a test and run it without having to copy it's name and paste it into the terminal. This is the easiest approach by far.
+The third, the go-test command, intended only for use in CI, to be run along with the other `go test` tests. This runs the tests in headless mode so there's no visual output.
+
+The name of a test is based on its path, so the name of the test at `pkg/integration/tests/commit/new_branch.go` is commit/new_branch. So to run it with our test runner you would run `go run cmd/integration_test/main.go cli commit/new_branch`.
+
+You can pass the KEY_PRESS_DELAY env var to the test runner in order to set a delay in milliseconds between keypresses, which helps for watching a test at a realistic speed to understand what it's doing. Or in the tui you can press 't' to run the test with a pre-set delay.
+
+### Snapshots
+
+At the moment (this is subject to change) each test has a snapshot repo created after running for the first time. These snapshots live in `test/integration_new`, in folders named 'expected' (alongside the 'actual' folders which contain the resulting repo from the last test run). Whenever you run a test, the resultant repo will be compared against the snapshot repo and if they're different, you'll be asked whether you want to update the snapshot. If you want to update a snapshot without being prompted you can pass MODE=update to the test runner.
+
+### Sandbox mode
+
+Say you want to do a manual test of how lazygit handles merge-conflicts, but you can't be bothered actually finding a way to create merge conflicts in a repo. To make your life easier, you can simply run a merge-conflicts test in sandbox mode, meaning the setup step is run for you, and then instead of the test driving the lazygit session, you're allowed to drive it yourself.
+
+To run a test in sandbox mode you can press 's' on a test in the test TUI or pass the env var MODE=sandbox to the test runner.
+
+## Migration process
+
+At the time of writing, most tests are created under an old approach, where you would record yourself in a lazygit session and then the test would replay the keybindings with the same timestamps. This old approach is great for writing tests quickly, but is much harder to maintain. It has to rely entirely on snapshots to determining if a test passes or fails, and can't do assertions along the way. It's also harder to grok what's the intention behind certain actions that take place within the test (e.g. was the recorder intentionally switching to another panel or was that just a misclick?).
+
+At the moment, all the deprecated test code lives in pkg/integration/deprecated. Hopefully in the very near future we migrate everything across so that we don't need to maintain two systems.
+
+We should never write any new tests under the old method, and if a given test breaks because of new functionality, it's best to simply rewrite it under the new approach. If you want to run a test for the sake of watching what it does so that you can transcribe it into the new approach, you can run:
+
+```
+go run pkg/integration/deprecated/cmd/tui/main.go
+```
+
+The tests in the old format live in test/integration. In the old format, test definitions are co-located with the snapshots. The setup step is done in a `setup.sh` shell script and the `recording.json` file contains the recorded keypresses to be replayed during the test.
diff --git a/pkg/integration/clients/cli.go b/pkg/integration/clients/cli.go
new file mode 100644
index 000000000..76f0c9549
--- /dev/null
+++ b/pkg/integration/clients/cli.go
@@ -0,0 +1,96 @@
+package clients
+
+import (
+ "log"
+ "os"
+ "os/exec"
+ "strconv"
+
+ "github.com/jesseduffield/lazygit/pkg/integration/components"
+ "github.com/jesseduffield/lazygit/pkg/integration/tests"
+)
+
+// see pkg/integration/README.md
+
+// The purpose of this program is to run integration tests. It does this by
+// building our injector program (in the sibling injector directory) and then for
+// each test we're running, invoke the injector program with the test's name as
+// an environment variable. Then the injector finds the test and passes it to
+// the lazygit startup code.
+
+// If invoked directly, you can specify tests to run by passing their names as positional arguments
+
+func RunCLI(testNames []string) {
+ err := components.RunTests(
+ getTestsToRun(testNames),
+ log.Printf,
+ runCmdInTerminal,
+ runAndPrintError,
+ getModeFromEnv(),
+ tryConvert(os.Getenv("KEY_PRESS_DELAY"), 0),
+ )
+ if err != nil {
+ log.Print(err.Error())
+ }
+}
+
+func runAndPrintError(test *components.IntegrationTest, f func() error) {
+ if err := f(); err != nil {
+ log.Print(err.Error())
+ }
+}
+
+func getTestsToRun(testNames []string) []*components.IntegrationTest {
+ var testsToRun []*components.IntegrationTest
+
+ if len(testNames) == 0 {
+ return tests.Tests
+ }
+
+outer:
+ for _, testName := range testNames {
+ // check if our given test name actually exists
+ for _, test := range tests.Tests {
+ if test.Name() == testName {
+ testsToRun = append(testsToRun, test)
+ continue outer
+ }
+ }
+ log.Fatalf("test %s not found. Perhaps you forgot to add it to `pkg/integration/integration_tests/tests.go`?", testName)
+ }
+
+ return testsToRun
+}
+
+func runCmdInTerminal(cmd *exec.Cmd) error {
+ cmd.Stdout = os.Stdout
+ cmd.Stdin = os.Stdin
+ cmd.Stderr = os.Stderr
+
+ return cmd.Run()
+}
+
+func getModeFromEnv() components.Mode {
+ switch os.Getenv("MODE") {
+ case "", "ask":
+ return components.ASK_TO_UPDATE_SNAPSHOT
+ case "check":
+ return components.CHECK_SNAPSHOT
+ case "update":
+ return components.UPDATE_SNAPSHOT
+ case "sandbox":
+ return components.SANDBOX
+ default:
+ log.Fatalf("unknown test mode: %s, must be one of [ask, check, update, sandbox]", os.Getenv("MODE"))
+ panic("unreachable")
+ }
+}
+
+func tryConvert(numStr string, defaultVal int) int {
+ num, err := strconv.Atoi(numStr)
+ if err != nil {
+ return defaultVal
+ }
+
+ return num
+}
diff --git a/pkg/integration/clients/go_test.go b/pkg/integration/clients/go_test.go
new file mode 100644
index 000000000..d52cd409a
--- /dev/null
+++ b/pkg/integration/clients/go_test.go
@@ -0,0 +1,68 @@
+//go:build !windows
+// +build !windows
+
+package clients
+
+// this is the new way of running tests. See pkg/integration/integration_tests/commit.go
+// for an example
+
+import (
+ "io"
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "testing"
+
+ "github.com/creack/pty"
+ "github.com/jesseduffield/lazygit/pkg/integration/components"
+ "github.com/jesseduffield/lazygit/pkg/integration/tests"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIntegration(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration tests in short mode")
+ }
+
+ parallelTotal := tryConvert(os.Getenv("PARALLEL_TOTAL"), 1)
+ parallelIndex := tryConvert(os.Getenv("PARALLEL_INDEX"), 0)
+ testNumber := 0
+
+ err := components.RunTests(
+ tests.Tests,
+ t.Logf,
+ runCmdHeadless,
+ func(test *components.IntegrationTest, f func() error) {
+ defer func() { testNumber += 1 }()
+ if testNumber%parallelTotal != parallelIndex {
+ return
+ }
+
+ t.Run(test.Name(), func(t *testing.T) {
+ err := f()
+ assert.NoError(t, err)
+ })
+ },
+ components.CHECK_SNAPSHOT,
+ 0,
+ )
+
+ assert.NoError(t, err)
+}
+
+func runCmdHeadless(cmd *exec.Cmd) error {
+ cmd.Env = append(
+ cmd.Env,
+ "HEADLESS=true",
+ "TERM=xterm",
+ )
+
+ f, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 100, Cols: 100})
+ if err != nil {
+ return err
+ }
+
+ _, _ = io.Copy(ioutil.Discard, f)
+
+ return f.Close()
+}
diff --git a/pkg/integration/clients/injector/main.go b/pkg/integration/clients/injector/main.go
new file mode 100644
index 000000000..263dba5da
--- /dev/null
+++ b/pkg/integration/clients/injector/main.go
@@ -0,0 +1,62 @@
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/jesseduffield/lazygit/pkg/app"
+ "github.com/jesseduffield/lazygit/pkg/app/daemon"
+ "github.com/jesseduffield/lazygit/pkg/integration/components"
+ "github.com/jesseduffield/lazygit/pkg/integration/tests"
+ integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
+)
+
+// The purpose of this program is to run lazygit with an integration test passed in.
+// We could have done the check on TEST_NAME in the root main.go but
+// that would mean lazygit would be depending on integration test code which
+// would bloat the binary.
+
+// You should not invoke this program directly. Instead you should go through
+// go run cmd/integration_test/main.go
+
+func main() {
+ dummyBuildInfo := &app.BuildInfo{
+ Commit: "",
+ Date: "",
+ Version: "",
+ BuildSource: "integration test",
+ }
+
+ integrationTest := getIntegrationTest()
+
+ app.Start(dummyBuildInfo, integrationTest)
+}
+
+func getIntegrationTest() integrationTypes.IntegrationTest {
+ if daemon.InDaemonMode() {
+ // if we've invoked lazygit as a daemon from within lazygit,
+ // we don't want to pass a test to the rest of the code.
+ return nil
+ }
+
+ if os.Getenv(components.SANDBOX_ENV_VAR) == "true" {
+ // when in sandbox mode we don't want the test controlling the gui
+ return nil
+ }
+
+ integrationTestName := os.Getenv(components.TEST_NAME_ENV_VAR)
+ if integrationTestName == "" {
+ panic(fmt.Sprintf(
+ "expected %s environment variable to be set, given that we're running an integration test",
+ components.TEST_NAME_ENV_VAR,
+ ))
+ }
+
+ for _, candidateTest := range tests.Tests {
+ if candidateTest.Name() == integrationTestName {
+ return candidateTest
+ }
+ }
+
+ panic("Could not find integration test with name: " + integrationTestName)
+}
diff --git a/pkg/integration/clients/tui.go b/pkg/integration/clients/tui.go
new file mode 100644
index 000000000..707e482ca
--- /dev/null
+++ b/pkg/integration/clients/tui.go
@@ -0,0 +1,379 @@
+package clients
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui"
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/integration/components"
+ "github.com/jesseduffield/lazygit/pkg/integration/tests"
+ "github.com/jesseduffield/lazygit/pkg/secureexec"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+// This program lets you run integration tests from a TUI. See pkg/integration/README.md for more info.
+
+func RunTUI() {
+ rootDir := utils.GetLazygitRootDirectory()
+ testDir := filepath.Join(rootDir, "test", "integration")
+
+ app := newApp(testDir)
+ app.loadTests()
+
+ g, err := gocui.NewGui(gocui.OutputTrue, false, gocui.NORMAL, false, gui.RuneReplacements)
+ if err != nil {
+ log.Panicln(err)
+ }
+
+ g.Cursor = false
+
+ app.g = g
+
+ g.SetManagerFunc(app.layout)
+
+ if err := g.SetKeybinding("list", gocui.KeyArrowUp, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ if app.itemIdx > 0 {
+ app.itemIdx--
+ }
+ listView, err := g.View("list")
+ if err != nil {
+ return err
+ }
+ listView.FocusPoint(0, app.itemIdx)
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", gocui.KeyArrowDown, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ if app.itemIdx < len(app.filteredTests)-1 {
+ app.itemIdx++
+ }
+
+ listView, err := g.View("list")
+ if err != nil {
+ return err
+ }
+ listView.FocusPoint(0, app.itemIdx)
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 'q', gocui.ModNone, quit); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 's', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ suspendAndRunTest(currentTest, components.SANDBOX, 0)
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", gocui.KeyEnter, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ suspendAndRunTest(currentTest, components.ASK_TO_UPDATE_SNAPSHOT, 0)
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 't', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ suspendAndRunTest(currentTest, components.ASK_TO_UPDATE_SNAPSHOT, 200)
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 'o', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ cmd := secureexec.Command("sh", "-c", fmt.Sprintf("code -r pkg/integration/tests/%s", currentTest.Name()))
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 'O', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ cmd := secureexec.Command("sh", "-c", fmt.Sprintf("code test/integration_new/%s", currentTest.Name()))
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", '/', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ app.filtering = true
+ if _, err := g.SetCurrentView("editor"); err != nil {
+ return err
+ }
+ editorView, err := g.View("editor")
+ if err != nil {
+ return err
+ }
+ editorView.Clear()
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ // not using the editor yet, but will use it to help filter the list
+ if err := g.SetKeybinding("editor", gocui.KeyEsc, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ app.filtering = false
+ if _, err := g.SetCurrentView("list"); err != nil {
+ return err
+ }
+
+ app.filteredTests = tests.Tests
+ app.renderTests()
+ app.editorView.TextArea.Clear()
+ app.editorView.Clear()
+ app.editorView.Reset()
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("editor", gocui.KeyEnter, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ app.filtering = false
+
+ if _, err := g.SetCurrentView("list"); err != nil {
+ return err
+ }
+
+ app.renderTests()
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ err = g.MainLoop()
+ g.Close()
+ switch err {
+ case gocui.ErrQuit:
+ return
+ default:
+ log.Panicln(err)
+ }
+}
+
+type app struct {
+ filteredTests []*components.IntegrationTest
+ itemIdx int
+ testDir string
+ filtering bool
+ g *gocui.Gui
+ listView *gocui.View
+ editorView *gocui.View
+}
+
+func newApp(testDir string) *app {
+ return &app{testDir: testDir}
+}
+
+func (self *app) getCurrentTest() *components.IntegrationTest {
+ self.adjustCursor()
+ if len(self.filteredTests) > 0 {
+ return self.filteredTests[self.itemIdx]
+ }
+ return nil
+}
+
+func (self *app) loadTests() {
+ self.filteredTests = tests.Tests
+
+ self.adjustCursor()
+}
+
+func (self *app) adjustCursor() {
+ self.itemIdx = utils.Clamp(self.itemIdx, 0, len(self.filteredTests)-1)
+}
+
+func (self *app) filterWithString(needle string) {
+ if needle == "" {
+ self.filteredTests = tests.Tests
+ } else {
+ self.filteredTests = slices.Filter(tests.Tests, func(test *components.IntegrationTest) bool {
+ return strings.Contains(test.Name(), needle)
+ })
+ }
+
+ self.renderTests()
+ self.g.Update(func(g *gocui.Gui) error { return nil })
+}
+
+func (self *app) renderTests() {
+ self.listView.Clear()
+ for _, test := range self.filteredTests {
+ fmt.Fprintln(self.listView, test.Name())
+ }
+}
+
+func (self *app) wrapEditor(f func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool) func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
+ return func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
+ matched := f(v, key, ch, mod)
+ if matched {
+ self.filterWithString(v.TextArea.GetContent())
+ }
+ return matched
+ }
+}
+
+func suspendAndRunTest(test *components.IntegrationTest, mode components.Mode, keyPressDelay int) {
+ if err := gocui.Screen.Suspend(); err != nil {
+ panic(err)
+ }
+
+ runTuiTest(test, mode, keyPressDelay)
+
+ fmt.Fprintf(os.Stdout, "\n%s", style.FgGreen.Sprint("press enter to return"))
+ fmt.Scanln() // wait for enter press
+
+ if err := gocui.Screen.Resume(); err != nil {
+ panic(err)
+ }
+}
+
+func (self *app) layout(g *gocui.Gui) error {
+ maxX, maxY := g.Size()
+ descriptionViewHeight := 7
+ keybindingsViewHeight := 3
+ editorViewHeight := 3
+ if !self.filtering {
+ editorViewHeight = 0
+ } else {
+ descriptionViewHeight = 0
+ keybindingsViewHeight = 0
+ }
+ g.Cursor = self.filtering
+ g.FgColor = gocui.ColorGreen
+ listView, err := g.SetView("list", 0, 0, maxX-1, maxY-descriptionViewHeight-keybindingsViewHeight-editorViewHeight-1, 0)
+ if err != nil {
+ if err.Error() != "unknown view" {
+ return err
+ }
+
+ if self.listView == nil {
+ self.listView = listView
+ }
+
+ listView.Highlight = true
+ self.renderTests()
+ listView.Title = "Tests"
+ listView.FgColor = gocui.ColorDefault
+ if _, err := g.SetCurrentView("list"); err != nil {
+ return err
+ }
+ }
+
+ descriptionView, err := g.SetViewBeneath("description", "list", descriptionViewHeight)
+ if err != nil {
+ if err.Error() != "unknown view" {
+ return err
+ }
+ descriptionView.Title = "Test description"
+ descriptionView.Wrap = true
+ descriptionView.FgColor = gocui.ColorDefault
+ }
+
+ keybindingsView, err := g.SetViewBeneath("keybindings", "description", keybindingsViewHeight)
+ if err != nil {
+ if err.Error() != "unknown view" {
+ return err
+ }
+ keybindingsView.Title = "Keybindings"
+ keybindingsView.Wrap = true
+ keybindingsView.FgColor = gocui.ColorDefault
+ fmt.Fprintln(keybindingsView, "up/down: navigate, enter: run test, t: run test slow, s: sandbox, o: open test file, shift+o: open test snapshot directory, forward-slash: filter")
+ }
+
+ editorView, err := g.SetViewBeneath("editor", "keybindings", editorViewHeight)
+ if err != nil {
+ if err.Error() != "unknown view" {
+ return err
+ }
+
+ if self.editorView == nil {
+ self.editorView = editorView
+ }
+
+ editorView.Title = "Filter"
+ editorView.FgColor = gocui.ColorDefault
+ editorView.Editable = true
+ editorView.Editor = gocui.EditorFunc(self.wrapEditor(gocui.SimpleEditor))
+ }
+
+ currentTest := self.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ descriptionView.Clear()
+ fmt.Fprint(descriptionView, currentTest.Description())
+
+ return nil
+}
+
+func quit(g *gocui.Gui, v *gocui.View) error {
+ return gocui.ErrQuit
+}
+
+func runTuiTest(test *components.IntegrationTest, mode components.Mode, keyPressDelay int) {
+ err := components.RunTests(
+ []*components.IntegrationTest{test},
+ log.Printf,
+ runCmdInTerminal,
+ runAndPrintError,
+ mode,
+ keyPressDelay,
+ )
+ if err != nil {
+ log.Println(err.Error())
+ }
+}
diff --git a/pkg/integration/components/assert.go b/pkg/integration/components/assert.go
new file mode 100644
index 000000000..584ad438b
--- /dev/null
+++ b/pkg/integration/components/assert.go
@@ -0,0 +1,111 @@
+package components
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
+)
+
+// through this struct we assert on the state of the lazygit gui
+
+type Assert struct {
+ gui integrationTypes.GuiDriver
+}
+
+func NewAssert(gui integrationTypes.GuiDriver) *Assert {
+ return &Assert{gui: gui}
+}
+
+func (self *Assert) WorkingTreeFileCount(expectedCount int) {
+ self.assertWithRetries(func() (bool, string) {
+ actualCount := len(self.gui.Model().Files)
+
+ return actualCount == expectedCount, fmt.Sprintf(
+ "Expected %d changed working tree files, but got %d",
+ expectedCount, actualCount,
+ )
+ })
+}
+
+func (self *Assert) CommitCount(expectedCount int) {
+ self.assertWithRetries(func() (bool, string) {
+ actualCount := len(self.gui.Model().Commits)
+
+ return actualCount == expectedCount, fmt.Sprintf(
+ "Expected %d commits present, but got %d",
+ expectedCount, actualCount,
+ )
+ })
+}
+
+func (self *Assert) HeadCommitMessage(expectedMessage string) {
+ self.assertWithRetries(func() (bool, string) {
+ if len(self.gui.Model().Commits) == 0 {
+ return false, "Expected at least one commit to be present"
+ }
+
+ headCommit := self.gui.Model().Commits[0]
+ if headCommit.Name != expectedMessage {
+ return false, fmt.Sprintf(
+ "Expected commit message to be '%s', but got '%s'",
+ expectedMessage, headCommit.Name,
+ )
+ }
+
+ return true, ""
+ })
+}
+
+func (self *Assert) CurrentViewName(expectedViewName string) {
+ self.assertWithRetries(func() (bool, string) {
+ actual := self.gui.CurrentContext().GetView().Name()
+ return actual == expectedViewName, fmt.Sprintf("Expected current view name to be '%s', but got '%s'", expectedViewName, actual)
+ })
+}
+
+func (self *Assert) CurrentBranchName(expectedViewName string) {
+ self.assertWithRetries(func() (bool, string) {
+ actual := self.gui.CheckedOutRef().Name
+ return actual == expectedViewName, fmt.Sprintf("Expected current branch name to be '%s', but got '%s'", expectedViewName, actual)
+ })
+}
+
+func (self *Assert) InListContext() {
+ self.assertWithRetries(func() (bool, string) {
+ currentContext := self.gui.CurrentContext()
+ _, ok := currentContext.(types.IListContext)
+ return ok, fmt.Sprintf("Expected current context to be a list context, but got %s", currentContext.GetKey())
+ })
+}
+
+func (self *Assert) SelectedLineContains(text string) {
+ self.assertWithRetries(func() (bool, string) {
+ line := self.gui.CurrentContext().GetView().SelectedLine()
+ return strings.Contains(line, text), fmt.Sprintf("Expected selected line to contain '%s', but got '%s'", text, line)
+ })
+}
+
+func (self *Assert) assertWithRetries(test func() (bool, string)) {
+ waitTimes := []int{0, 1, 5, 10, 200, 500, 1000}
+
+ var message string
+ for _, waitTime := range waitTimes {
+ time.Sleep(time.Duration(waitTime) * time.Millisecond)
+
+ var ok bool
+ ok, message = test()
+ if ok {
+ return
+ }
+ }
+
+ self.Fail(message)
+}
+
+// for when you just want to fail the test yourself
+func (self *Assert) Fail(message string) {
+ self.gui.Fail(message)
+}
diff --git a/pkg/integration/components/input.go b/pkg/integration/components/input.go
new file mode 100644
index 000000000..d44b11830
--- /dev/null
+++ b/pkg/integration/components/input.go
@@ -0,0 +1,166 @@
+package components
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
+)
+
+type Input struct {
+ gui integrationTypes.GuiDriver
+ keys config.KeybindingConfig
+ assert *Assert
+ pushKeyDelay int
+}
+
+func NewInput(gui integrationTypes.GuiDriver, keys config.KeybindingConfig, assert *Assert, pushKeyDelay int) *Input {
+ return &Input{
+ gui: gui,
+ keys: keys,
+ assert: assert,
+ pushKeyDelay: pushKeyDelay,
+ }
+}
+
+// key is something like 'w' or ''. It's best not to pass a direct value,
+// but instead to go through the default user config to get a more meaningful key name
+func (self *Input) PressKeys(keyStrs ...string) {
+ for _, keyStr := range keyStrs {
+ self.pressKey(keyStr)
+ }
+}
+
+func (self *Input) pressKey(keyStr string) {
+ self.Wait(self.pushKeyDelay)
+
+ self.gui.PressKey(keyStr)
+}
+
+func (self *Input) SwitchToStatusWindow() {
+ self.pressKey(self.keys.Universal.JumpToBlock[0])
+}
+
+func (self *Input) SwitchToFilesWindow() {
+ self.pressKey(self.keys.Universal.JumpToBlock[1])
+}
+
+func (self *Input) SwitchToBranchesWindow() {
+ self.pressKey(self.keys.Universal.JumpToBlock[2])
+}
+
+func (self *Input) SwitchToCommitsWindow() {
+ self.pressKey(self.keys.Universal.JumpToBlock[3])
+}
+
+func (self *Input) SwitchToStashWindow() {
+ self.pressKey(self.keys.Universal.JumpToBlock[4])
+}
+
+func (self *Input) Type(content string) {
+ for _, char := range content {
+ self.pressKey(string(char))
+ }
+}
+
+// i.e. pressing enter
+func (self *Input) Confirm() {
+ self.pressKey(self.keys.Universal.Confirm)
+}
+
+// i.e. pressing escape
+func (self *Input) Cancel() {
+ self.pressKey(self.keys.Universal.Return)
+}
+
+// i.e. pressing space
+func (self *Input) Select() {
+ self.pressKey(self.keys.Universal.Select)
+}
+
+// i.e. pressing down arrow
+func (self *Input) NextItem() {
+ self.pressKey(self.keys.Universal.NextItem)
+}
+
+// i.e. pressing up arrow
+func (self *Input) PreviousItem() {
+ self.pressKey(self.keys.Universal.PrevItem)
+}
+
+func (self *Input) ContinueMerge() {
+ self.PressKeys(self.keys.Universal.CreateRebaseOptionsMenu)
+ self.assert.SelectedLineContains("continue")
+ self.Confirm()
+}
+
+func (self *Input) ContinueRebase() {
+ self.ContinueMerge()
+}
+
+// for when you want to allow lazygit to process something before continuing
+func (self *Input) Wait(milliseconds int) {
+ time.Sleep(time.Duration(milliseconds) * time.Millisecond)
+}
+
+func (self *Input) LogUI(message string) {
+ self.gui.LogUI(message)
+}
+
+func (self *Input) Log(message string) {
+ self.gui.LogUI(message)
+}
+
+// this will look for a list item in the current panel and if it finds it, it will
+// enter the keypresses required to navigate to it.
+// The test will fail if:
+// - the user is not in a list item
+// - no list item is found containing the given text
+// - multiple list items are found containing the given text in the initial page of items
+//
+// NOTE: this currently assumes that ViewBufferLines returns all the lines that can be accessed.
+// If this changes in future, we'll need to update this code to first attempt to find the item
+// in the current page and failing that, jump to the top of the view and iterate through all of it,
+// looking for the item.
+func (self *Input) NavigateToListItemContainingText(text string) {
+ self.assert.InListContext()
+
+ currentContext := self.gui.CurrentContext().(types.IListContext)
+
+ view := currentContext.GetView()
+
+ // first we look for a duplicate on the current screen. We won't bother looking beyond that though.
+ matchCount := 0
+ matchIndex := -1
+ for i, line := range view.ViewBufferLines() {
+ if strings.Contains(line, text) {
+ matchCount++
+ matchIndex = i
+ }
+ }
+ if matchCount > 1 {
+ self.assert.Fail(fmt.Sprintf("Found %d matches for %s, expected only a single match", matchCount, text))
+ }
+ if matchCount == 1 {
+ selectedLineIdx := view.SelectedLineIdx()
+ if selectedLineIdx == matchIndex {
+ return
+ }
+ if selectedLineIdx < matchIndex {
+ for i := selectedLineIdx; i < matchIndex; i++ {
+ self.NextItem()
+ }
+ return
+ } else {
+ for i := selectedLineIdx; i > matchIndex; i-- {
+ self.PreviousItem()
+ }
+ return
+ }
+ }
+
+ self.assert.Fail(fmt.Sprintf("Could not find item containing text: %s", text))
+}
diff --git a/pkg/integration/components/paths.go b/pkg/integration/components/paths.go
new file mode 100644
index 000000000..d01b58437
--- /dev/null
+++ b/pkg/integration/components/paths.go
@@ -0,0 +1,43 @@
+package components
+
+import "path/filepath"
+
+// convenience struct for easily getting directories within our test directory.
+// We have one test directory for each test, found in test/integration_new.
+type Paths struct {
+ // e.g. test/integration/test_name
+ root string
+}
+
+func NewPaths(root string) Paths {
+ return Paths{root: root}
+}
+
+// when a test first runs, it's situated in a repo called 'repo' within this
+// directory. In its setup step, the test is allowed to create other repos
+// alongside the 'repo' repo in this directory, for example, creating remotes
+// or repos to add as submodules.
+func (self Paths) Actual() string {
+ return filepath.Join(self.root, "actual")
+}
+
+// this is the 'repo' directory within the 'actual' directory,
+// where a lazygit test will start within.
+func (self Paths) ActualRepo() string {
+ return filepath.Join(self.Actual(), "repo")
+}
+
+// When an integration test first runs, we copy everything in the 'actual' directory,
+// and copy it into the 'expected' directory so that future runs can be compared
+// against what we expect.
+func (self Paths) Expected() string {
+ return filepath.Join(self.root, "expected")
+}
+
+func (self Paths) Config() string {
+ return filepath.Join(self.root, "used_config")
+}
+
+func (self Paths) Root() string {
+ return self.root
+}
diff --git a/pkg/integration/components/runner.go b/pkg/integration/components/runner.go
new file mode 100644
index 000000000..5a5022c53
--- /dev/null
+++ b/pkg/integration/components/runner.go
@@ -0,0 +1,216 @@
+package components
+
+import (
+ "fmt"
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "path/filepath"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+// this is the integration runner for the new and improved integration interface
+
+const (
+ TEST_NAME_ENV_VAR = "TEST_NAME"
+ SANDBOX_ENV_VAR = "SANDBOX"
+)
+
+type Mode int
+
+const (
+ // Default: if a snapshot test fails, the we'll be asked whether we want to update it
+ ASK_TO_UPDATE_SNAPSHOT Mode = iota
+ // fails the test if the snapshots don't match
+ CHECK_SNAPSHOT
+ // runs the test and updates the snapshot
+ UPDATE_SNAPSHOT
+ // This just makes use of the setup step of the test to get you into
+ // a lazygit session. Then you'll be able to do whatever you want. Useful
+ // when you want to test certain things without needing to manually set
+ // up the situation yourself.
+ // fails the test if the snapshots don't match
+ SANDBOX
+)
+
+func RunTests(
+ tests []*IntegrationTest,
+ logf func(format string, formatArgs ...interface{}),
+ runCmd func(cmd *exec.Cmd) error,
+ testWrapper func(test *IntegrationTest, f func() error),
+ mode Mode,
+ keyPressDelay int,
+) error {
+ projectRootDir := utils.GetLazygitRootDirectory()
+ err := os.Chdir(projectRootDir)
+ if err != nil {
+ return err
+ }
+
+ testDir := filepath.Join(projectRootDir, "test", "integration_new")
+
+ if err := buildLazygit(); err != nil {
+ return err
+ }
+
+ for _, test := range tests {
+ test := test
+
+ paths := NewPaths(
+ filepath.Join(testDir, test.Name()),
+ )
+
+ testWrapper(test, func() error { //nolint: thelper
+ return runTest(test, paths, projectRootDir, logf, runCmd, mode, keyPressDelay)
+ })
+ }
+
+ return nil
+}
+
+func runTest(
+ test *IntegrationTest,
+ paths Paths,
+ projectRootDir string,
+ logf func(format string, formatArgs ...interface{}),
+ runCmd func(cmd *exec.Cmd) error,
+ mode Mode,
+ keyPressDelay int,
+) error {
+ if test.Skip() {
+ logf("Skipping test %s", test.Name())
+ return nil
+ }
+
+ logf("path: %s", paths.Root())
+
+ if err := prepareTestDir(test, paths); err != nil {
+ return err
+ }
+
+ cmd, err := getLazygitCommand(test, paths, projectRootDir, mode, keyPressDelay)
+ if err != nil {
+ return err
+ }
+
+ err = runCmd(cmd)
+ if err != nil {
+ return err
+ }
+
+ return HandleSnapshots(paths, logf, test, mode)
+}
+
+func prepareTestDir(
+ test *IntegrationTest,
+ paths Paths,
+) error {
+ findOrCreateDir(paths.Root())
+ deleteAndRecreateEmptyDir(paths.Actual())
+
+ err := os.Mkdir(paths.ActualRepo(), 0o777)
+ if err != nil {
+ return err
+ }
+
+ return createFixture(test, paths)
+}
+
+func buildLazygit() error {
+ osCommand := oscommands.NewDummyOSCommand()
+ return osCommand.Cmd.New(fmt.Sprintf(
+ "go build -o %s pkg/integration/clients/injector/main.go", tempLazygitPath(),
+ )).Run()
+}
+
+func createFixture(test *IntegrationTest, paths Paths) error {
+ originalDir, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+
+ if err := os.Chdir(paths.ActualRepo()); err != nil {
+ panic(err)
+ }
+
+ shell := NewShell()
+ shell.RunCommand("git init")
+ shell.RunCommand(`git config user.email "CI@example.com"`)
+ shell.RunCommand(`git config user.name "CI"`)
+
+ test.SetupRepo(shell)
+
+ if err := os.Chdir(originalDir); err != nil {
+ panic(err)
+ }
+
+ return nil
+}
+
+func getLazygitCommand(test *IntegrationTest, paths Paths, rootDir string, mode Mode, keyPressDelay int) (*exec.Cmd, error) {
+ osCommand := oscommands.NewDummyOSCommand()
+
+ templateConfigDir := filepath.Join(rootDir, "test", "default_test_config")
+
+ err := os.RemoveAll(paths.Config())
+ if err != nil {
+ return nil, err
+ }
+ err = oscommands.CopyDir(templateConfigDir, paths.Config())
+ if err != nil {
+ return nil, err
+ }
+
+ cmdStr := fmt.Sprintf("%s -debug --use-config-dir=%s --path=%s %s", tempLazygitPath(), paths.Config(), paths.ActualRepo(), test.ExtraCmdArgs())
+
+ cmdObj := osCommand.Cmd.New(cmdStr)
+
+ cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", TEST_NAME_ENV_VAR, test.Name()))
+ if mode == SANDBOX {
+ cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", "SANDBOX", "true"))
+ }
+
+ if keyPressDelay > 0 {
+ cmdObj.AddEnvVars(fmt.Sprintf("KEY_PRESS_DELAY=%d", keyPressDelay))
+ }
+
+ return cmdObj.GetCmd(), nil
+}
+
+func tempLazygitPath() string {
+ return filepath.Join("/tmp", "lazygit", "test_lazygit")
+}
+
+func findOrCreateDir(path string) {
+ _, err := os.Stat(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ err = os.MkdirAll(path, 0o777)
+ if err != nil {
+ panic(err)
+ }
+ } else {
+ panic(err)
+ }
+ }
+}
+
+func deleteAndRecreateEmptyDir(path string) {
+ // remove contents of integration test directory
+ dir, err := ioutil.ReadDir(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ err = os.Mkdir(path, 0o777)
+ if err != nil {
+ panic(err)
+ }
+ } else {
+ panic(err)
+ }
+ }
+ for _, d := range dir {
+ os.RemoveAll(filepath.Join(path, d.Name()))
+ }
+}
diff --git a/pkg/integration/components/shell.go b/pkg/integration/components/shell.go
new file mode 100644
index 000000000..ee57cf401
--- /dev/null
+++ b/pkg/integration/components/shell.go
@@ -0,0 +1,83 @@
+package components
+
+import (
+ "fmt"
+ "io/ioutil"
+ "os"
+
+ "github.com/jesseduffield/lazygit/pkg/secureexec"
+ "github.com/mgutz/str"
+)
+
+// this is for running shell commands, mostly for the sake of setting up the repo
+// but you can also run the commands from within lazygit to emulate things happening
+// in the background.
+type Shell struct{}
+
+func NewShell() *Shell {
+ return &Shell{}
+}
+
+func (s *Shell) RunCommand(cmdStr string) *Shell {
+ args := str.ToArgv(cmdStr)
+ cmd := secureexec.Command(args[0], args[1:]...)
+ cmd.Env = os.Environ()
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ panic(fmt.Sprintf("error running command: %s\n%s", cmdStr, string(output)))
+ }
+
+ return s
+}
+
+func (s *Shell) CreateFile(path string, content string) *Shell {
+ err := ioutil.WriteFile(path, []byte(content), 0o644)
+ if err != nil {
+ panic(fmt.Sprintf("error creating file: %s\n%s", path, err))
+ }
+
+ return s
+}
+
+func (s *Shell) NewBranch(name string) *Shell {
+ return s.RunCommand("git checkout -b " + name)
+}
+
+func (s *Shell) GitAdd(path string) *Shell {
+ return s.RunCommand(fmt.Sprintf("git add \"%s\"", path))
+}
+
+func (s *Shell) GitAddAll() *Shell {
+ return s.RunCommand("git add -A")
+}
+
+func (s *Shell) Commit(message string) *Shell {
+ return s.RunCommand(fmt.Sprintf("git commit -m \"%s\"", message))
+}
+
+func (s *Shell) EmptyCommit(message string) *Shell {
+ return s.RunCommand(fmt.Sprintf("git commit --allow-empty -m \"%s\"", message))
+}
+
+// convenience method for creating a file and adding it
+func (s *Shell) CreateFileAndAdd(fileName string, fileContents string) *Shell {
+ return s.
+ CreateFile(fileName, fileContents).
+ GitAdd(fileName)
+}
+
+// creates commits 01, 02, 03, ..., n with a new file in each
+// The reason for padding with zeroes is so that it's easier to do string
+// matches on the commit messages when there are many of them
+func (s *Shell) CreateNCommits(n int) *Shell {
+ for i := 1; i <= n; i++ {
+ s.CreateFileAndAdd(
+ fmt.Sprintf("file%02d.txt", i),
+ fmt.Sprintf("file%02d content", i),
+ ).
+ Commit(fmt.Sprintf("commit %02d", i))
+ }
+
+ return s
+}
diff --git a/pkg/integration/components/snapshot.go b/pkg/integration/components/snapshot.go
new file mode 100644
index 000000000..b7efa0fb0
--- /dev/null
+++ b/pkg/integration/components/snapshot.go
@@ -0,0 +1,372 @@
+package components
+
+import (
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/stretchr/testify/assert"
+)
+
+// This creates and compares integration test snapshots.
+
+type (
+ logf func(format string, formatArgs ...interface{})
+)
+
+func HandleSnapshots(paths Paths, logf logf, test *IntegrationTest, mode Mode) error {
+ return NewSnapshotter(paths, logf, test, mode).
+ handleSnapshots()
+}
+
+type Snapshotter struct {
+ paths Paths
+ logf logf
+ test *IntegrationTest
+ mode Mode
+}
+
+func NewSnapshotter(
+ paths Paths,
+ logf logf,
+ test *IntegrationTest,
+ mode Mode,
+) *Snapshotter {
+ return &Snapshotter{
+ paths: paths,
+ logf: logf,
+ test: test,
+ mode: mode,
+ }
+}
+
+func (self *Snapshotter) handleSnapshots() error {
+ switch self.mode {
+ case UPDATE_SNAPSHOT:
+ return self.handleUpdate()
+ case CHECK_SNAPSHOT:
+ return self.handleCheck()
+ case ASK_TO_UPDATE_SNAPSHOT:
+ return self.handleAskToUpdate()
+ case SANDBOX:
+ self.logf("Sandbox session exited")
+ }
+ return nil
+}
+
+func (self *Snapshotter) handleUpdate() error {
+ if err := self.updateSnapshot(); err != nil {
+ return err
+ }
+ self.logf("Test passed: %s", self.test.Name())
+ return nil
+}
+
+func (self *Snapshotter) handleCheck() error {
+ self.logf("Comparing snapshots")
+ if err := self.compareSnapshots(); err != nil {
+ return err
+ }
+ self.logf("Test passed: %s", self.test.Name())
+ return nil
+}
+
+func (self *Snapshotter) handleAskToUpdate() error {
+ if _, err := os.Stat(self.paths.Expected()); os.IsNotExist(err) {
+ if err := self.updateSnapshot(); err != nil {
+ return err
+ }
+ self.logf("No existing snapshot found for %s. Created snapshot.", self.test.Name())
+
+ return nil
+ }
+
+ self.logf("Comparing snapshots...")
+ if err := self.compareSnapshots(); err != nil {
+ self.logf("%s", err)
+
+ // prompt user whether to update the snapshot (Y/N)
+ if promptUserToUpdateSnapshot() {
+ if err := self.updateSnapshot(); err != nil {
+ return err
+ }
+ self.logf("Snapshot updated: %s", self.test.Name())
+ } else {
+ return err
+ }
+ }
+
+ self.logf("Test passed: %s", self.test.Name())
+ return nil
+}
+
+func (self *Snapshotter) updateSnapshot() error {
+ // create/update snapshot
+ err := oscommands.CopyDir(self.paths.Actual(), self.paths.Expected())
+ if err != nil {
+ return err
+ }
+
+ if err := renameSpecialPaths(self.paths.Expected()); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (self *Snapshotter) compareSnapshots() error {
+ // there are a couple of reasons we're not generating the snapshot in expectedDir directly:
+ // Firstly we don't want to have to revert our .git file back to .git_keep.
+ // Secondly, the act of calling git commands like 'git status' actually changes the index
+ // for some reason, and we don't want to leave your lazygit working tree dirty as a result.
+ expectedDirCopy := filepath.Join(os.TempDir(), "expected_dir_test", self.test.Name())
+ err := oscommands.CopyDir(self.paths.Expected(), expectedDirCopy)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ err := os.RemoveAll(expectedDirCopy)
+ if err != nil {
+ panic(err)
+ }
+ }()
+
+ if err := restoreSpecialPaths(expectedDirCopy); err != nil {
+ return err
+ }
+
+ err = validateSameRepos(expectedDirCopy, self.paths.Actual())
+ if err != nil {
+ return err
+ }
+
+ // iterate through each repo in the expected dir and comparet to the corresponding repo in the actual dir
+ expectedFiles, err := ioutil.ReadDir(expectedDirCopy)
+ if err != nil {
+ return err
+ }
+
+ for _, f := range expectedFiles {
+ if !f.IsDir() {
+ return errors.New("unexpected file (as opposed to directory) in integration test 'expected' directory")
+ }
+
+ // get corresponding file name from actual dir
+ actualRepoPath := filepath.Join(self.paths.Actual(), f.Name())
+ expectedRepoPath := filepath.Join(expectedDirCopy, f.Name())
+
+ actualRepo, expectedRepo, err := generateSnapshots(actualRepoPath, expectedRepoPath)
+ if err != nil {
+ return err
+ }
+
+ if expectedRepo != actualRepo {
+ // get the log file and print it
+ bytes, err := ioutil.ReadFile(filepath.Join(self.paths.Config(), "development.log"))
+ if err != nil {
+ return err
+ }
+ self.logf("%s", string(bytes))
+
+ return errors.New(getDiff(f.Name(), actualRepo, expectedRepo))
+ }
+ }
+
+ return nil
+}
+
+func promptUserToUpdateSnapshot() bool {
+ fmt.Println("Test failed. Update snapshot? (y/n)")
+ var input string
+ fmt.Scanln(&input)
+ return input == "y"
+}
+
+func generateSnapshots(actualDir string, expectedDir string) (string, string, error) {
+ actual, err := generateSnapshot(actualDir)
+ if err != nil {
+ return "", "", err
+ }
+
+ expected, err := generateSnapshot(expectedDir)
+ if err != nil {
+ return "", "", err
+ }
+
+ return actual, expected, nil
+}
+
+// note that we don't actually store this snapshot in the lazygit repo.
+// Instead we store the whole expected git repo of our test, so that
+// we can easily change what we want to compare without needing to regenerate
+// snapshots for each test.
+func generateSnapshot(dir string) (string, error) {
+ osCommand := oscommands.NewDummyOSCommand()
+
+ _, err := os.Stat(filepath.Join(dir, ".git"))
+ if err != nil {
+ return "git directory not found", nil
+ }
+
+ snapshot := ""
+
+ cmdStrs := []string{
+ `remote show -n origin`, // remote branches
+ // TODO: find a way to bring this back without breaking tests
+ // `ls-remote origin`,
+ `status`, // file tree
+ `log --pretty=%B|%an|%ae -p -1`, // log
+ `tag -n`, // tags
+ `stash list`, // stash
+ `submodule foreach 'git status'`, // submodule status
+ `submodule foreach 'git log --pretty=%B -p -1'`, // submodule log
+ `submodule foreach 'git tag -n'`, // submodule tags
+ `submodule foreach 'git stash list'`, // submodule stash
+ }
+
+ for _, cmdStr := range cmdStrs {
+ // ignoring error for now. If there's an error it could be that there are no results
+ output, _ := osCommand.Cmd.New(fmt.Sprintf("git -C %s %s", dir, cmdStr)).RunWithOutput()
+
+ snapshot += fmt.Sprintf("git %s:\n%s\n", cmdStr, output)
+ }
+
+ snapshot += "files in repo:\n"
+ err = filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if f.IsDir() {
+ if f.Name() == ".git" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ bytes, err := ioutil.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ relativePath, err := filepath.Rel(dir, path)
+ if err != nil {
+ return err
+ }
+ snapshot += fmt.Sprintf("path: %s\ncontent:\n%s\n", relativePath, string(bytes))
+
+ return nil
+ })
+
+ if err != nil {
+ return "", err
+ }
+
+ return snapshot, nil
+}
+
+func getPathsToRename(dir string, needle string, contains string) []string {
+ pathsToRename := []string{}
+
+ err := filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if f.Name() == needle && (contains == "" || strings.Contains(path, contains)) {
+ pathsToRename = append(pathsToRename, path)
+ }
+
+ return nil
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ return pathsToRename
+}
+
+var specialPathMappings = []struct{ original, new, contains string }{
+ // git refuses to track .git or .gitmodules in subdirectories so we need to rename them
+ {".git", ".git_keep", ""},
+ {".gitmodules", ".gitmodules_keep", ""},
+ // we also need git to ignore the contents of our test gitignore files so that
+ // we actually commit files that are ignored within the test.
+ {".gitignore", "lg_ignore_file", ""},
+ // this is the .git/info/exclude file. We're being a little more specific here
+ // so that we don't accidentally mess with some other file named 'exclude' in the test.
+ {"exclude", "lg_exclude_file", ".git/info/exclude"},
+}
+
+func renameSpecialPaths(dir string) error {
+ for _, specialPath := range specialPathMappings {
+ for _, path := range getPathsToRename(dir, specialPath.original, specialPath.contains) {
+ err := os.Rename(path, filepath.Join(filepath.Dir(path), specialPath.new))
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func restoreSpecialPaths(dir string) error {
+ for _, specialPath := range specialPathMappings {
+ for _, path := range getPathsToRename(dir, specialPath.new, specialPath.contains) {
+ err := os.Rename(path, filepath.Join(filepath.Dir(path), specialPath.original))
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+// validates that the actual and expected dirs have the same repo names (doesn't actually check the contents of the repos)
+func validateSameRepos(expectedDir string, actualDir string) error {
+ // iterate through each repo in the expected dir and compare to the corresponding repo in the actual dir
+ expectedFiles, err := ioutil.ReadDir(expectedDir)
+ if err != nil {
+ return err
+ }
+
+ var actualFiles []os.FileInfo
+ actualFiles, err = ioutil.ReadDir(actualDir)
+ if err != nil {
+ return err
+ }
+
+ expectedFileNames := slices.Map(expectedFiles, getFileName)
+ actualFileNames := slices.Map(actualFiles, getFileName)
+ if !slices.Equal(expectedFileNames, actualFileNames) {
+ return fmt.Errorf("expected and actual repo dirs do not match: expected: %s, actual: %s", expectedFileNames, actualFileNames)
+ }
+
+ return nil
+}
+
+func getFileName(f os.FileInfo) string {
+ return f.Name()
+}
+
+func getDiff(prefix string, expected string, actual string) string {
+ mockT := &MockTestingT{}
+ assert.Equal(mockT, expected, actual, fmt.Sprintf("Unexpected %s. Expected:\n%s\nActual:\n%s\n", prefix, expected, actual))
+ return mockT.err
+}
+
+type MockTestingT struct {
+ err string
+}
+
+func (self *MockTestingT) Errorf(format string, args ...interface{}) {
+ self.err += fmt.Sprintf(format, args...)
+}
diff --git a/pkg/integration/components/test.go b/pkg/integration/components/test.go
new file mode 100644
index 000000000..a5973c07a
--- /dev/null
+++ b/pkg/integration/components/test.go
@@ -0,0 +1,129 @@
+package components
+
+import (
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/jesseduffield/lazygit/pkg/config"
+ integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+// Test describes an integration tests that will be run against the lazygit gui.
+
+// our unit tests will use this description to avoid a panic caused by attempting
+// to get the test's name via it's file's path.
+const unitTestDescription = "test test"
+
+type IntegrationTest struct {
+ name string
+ description string
+ extraCmdArgs string
+ skip bool
+ setupRepo func(shell *Shell)
+ setupConfig func(config *config.AppConfig)
+ run func(
+ shell *Shell,
+ input *Input,
+ assert *Assert,
+ keys config.KeybindingConfig,
+ )
+}
+
+var _ integrationTypes.IntegrationTest = &IntegrationTest{}
+
+type NewIntegrationTestArgs struct {
+ // Briefly describes what happens in the test and what it's testing for
+ Description string
+ // prepares a repo for testing
+ SetupRepo func(shell *Shell)
+ // takes a config and mutates. The mutated context will end up being passed to the gui
+ SetupConfig func(config *config.AppConfig)
+ // runs the test
+ Run func(shell *Shell, input *Input, assert *Assert, keys config.KeybindingConfig)
+ // additional args passed to lazygit
+ ExtraCmdArgs string
+ // for when a test is flakey
+ Skip bool
+}
+
+func NewIntegrationTest(args NewIntegrationTestArgs) *IntegrationTest {
+ name := ""
+ if args.Description != unitTestDescription {
+ // this panics if we're in a unit test for our integration tests,
+ // so we're using "test test" as a sentinel value
+ name = testNameFromFilePath()
+ }
+
+ return &IntegrationTest{
+ name: name,
+ description: args.Description,
+ extraCmdArgs: args.ExtraCmdArgs,
+ skip: args.Skip,
+ setupRepo: args.SetupRepo,
+ setupConfig: args.SetupConfig,
+ run: args.Run,
+ }
+}
+
+func (self *IntegrationTest) Name() string {
+ return self.name
+}
+
+func (self *IntegrationTest) Description() string {
+ return self.description
+}
+
+func (self *IntegrationTest) ExtraCmdArgs() string {
+ return self.extraCmdArgs
+}
+
+func (self *IntegrationTest) Skip() bool {
+ return self.skip
+}
+
+func (self *IntegrationTest) SetupConfig(config *config.AppConfig) {
+ self.setupConfig(config)
+}
+
+func (self *IntegrationTest) SetupRepo(shell *Shell) {
+ self.setupRepo(shell)
+}
+
+// I want access to all contexts, the model, the ability to press a key, the ability to log,
+func (self *IntegrationTest) Run(gui integrationTypes.GuiDriver) {
+ shell := NewShell()
+ assert := NewAssert(gui)
+ keys := gui.Keys()
+ input := NewInput(gui, keys, assert, KeyPressDelay())
+
+ self.run(shell, input, assert, keys)
+
+ if KeyPressDelay() > 0 {
+ // the dev would want to see the final state if they're running in slow mode
+ input.Wait(2000)
+ }
+}
+
+func testNameFromFilePath() string {
+ path := utils.FilePath(3)
+ name := strings.Split(path, "integration/tests/")[1]
+
+ return name[:len(name)-len(".go")]
+}
+
+// this is the delay in milliseconds between keypresses
+// defaults to zero
+func KeyPressDelay() int {
+ delayStr := os.Getenv("KEY_PRESS_DELAY")
+ if delayStr == "" {
+ return 0
+ }
+
+ delay, err := strconv.Atoi(delayStr)
+ if err != nil {
+ panic(err)
+ }
+ return delay
+}
diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go
new file mode 100644
index 000000000..de8dac8e4
--- /dev/null
+++ b/pkg/integration/components/test_test.go
@@ -0,0 +1,90 @@
+package components
+
+import (
+ "testing"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+ integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
+ "github.com/stretchr/testify/assert"
+)
+
+type fakeGuiDriver struct {
+ failureMessage string
+ pressedKeys []string
+}
+
+var _ integrationTypes.GuiDriver = &fakeGuiDriver{}
+
+func (self *fakeGuiDriver) PressKey(key string) {
+ self.pressedKeys = append(self.pressedKeys, key)
+}
+
+func (self *fakeGuiDriver) Keys() config.KeybindingConfig {
+ return config.KeybindingConfig{}
+}
+
+func (self *fakeGuiDriver) CurrentContext() types.Context {
+ return nil
+}
+
+func (self *fakeGuiDriver) Model() *types.Model {
+ return &types.Model{Commits: []*models.Commit{}}
+}
+
+func (self *fakeGuiDriver) Fail(message string) {
+ self.failureMessage = message
+}
+
+func (self *fakeGuiDriver) Log(message string) {
+}
+
+func (self *fakeGuiDriver) LogUI(message string) {
+}
+
+func (self *fakeGuiDriver) CheckedOutRef() *models.Branch {
+ return nil
+}
+
+func TestAssertionFailure(t *testing.T) {
+ test := NewIntegrationTest(NewIntegrationTestArgs{
+ Description: unitTestDescription,
+ Run: func(shell *Shell, input *Input, assert *Assert, keys config.KeybindingConfig) {
+ input.PressKeys("a")
+ input.PressKeys("b")
+ assert.CommitCount(2)
+ },
+ })
+ driver := &fakeGuiDriver{}
+ test.Run(driver)
+ assert.EqualValues(t, []string{"a", "b"}, driver.pressedKeys)
+ assert.Equal(t, "Expected 2 commits present, but got 0", driver.failureMessage)
+}
+
+func TestManualFailure(t *testing.T) {
+ test := NewIntegrationTest(NewIntegrationTestArgs{
+ Description: unitTestDescription,
+ Run: func(shell *Shell, input *Input, assert *Assert, keys config.KeybindingConfig) {
+ assert.Fail("blah")
+ },
+ })
+ driver := &fakeGuiDriver{}
+ test.Run(driver)
+ assert.Equal(t, "blah", driver.failureMessage)
+}
+
+func TestSuccess(t *testing.T) {
+ test := NewIntegrationTest(NewIntegrationTestArgs{
+ Description: unitTestDescription,
+ Run: func(shell *Shell, input *Input, assert *Assert, keys config.KeybindingConfig) {
+ input.PressKeys("a")
+ input.PressKeys("b")
+ assert.CommitCount(0)
+ },
+ })
+ driver := &fakeGuiDriver{}
+ test.Run(driver)
+ assert.EqualValues(t, []string{"a", "b"}, driver.pressedKeys)
+ assert.Equal(t, "", driver.failureMessage)
+}
diff --git a/pkg/integration/deprecated/cmd/runner/main.go b/pkg/integration/deprecated/cmd/runner/main.go
new file mode 100644
index 000000000..86f3c1f14
--- /dev/null
+++ b/pkg/integration/deprecated/cmd/runner/main.go
@@ -0,0 +1,65 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "testing"
+
+ "github.com/jesseduffield/lazygit/pkg/integration/deprecated"
+ "github.com/stretchr/testify/assert"
+)
+
+// Deprecated: This file is part of the old way of doing things.
+
+// see https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md
+// This file can be invoked directly, but you might find it easier to go through
+// test/lazyintegration/main.go, which provides a convenient gui wrapper to integration tests.
+//
+// If invoked directly, you can specify a test by passing it as the first argument.
+// You can also specify that you want to record a test by passing MODE=record
+// as an env var.
+
+func main() {
+ mode := deprecated.GetModeFromEnv()
+ speedEnv := os.Getenv("SPEED")
+ includeSkipped := os.Getenv("INCLUDE_SKIPPED") == "true"
+ selectedTestName := os.Args[1]
+
+ err := deprecated.RunTests(
+ log.Printf,
+ runCmdInTerminal,
+ func(test *deprecated.IntegrationTest, f func(*testing.T) error) {
+ if selectedTestName != "" && test.Name != selectedTestName {
+ return
+ }
+ if err := f(nil); err != nil {
+ log.Print(err.Error())
+ }
+ },
+ mode,
+ speedEnv,
+ func(_t *testing.T, expected string, actual string, prefix string) { //nolint:thelper
+ assert.Equal(MockTestingT{}, expected, actual, fmt.Sprintf("Unexpected %s. Expected:\n%s\nActual:\n%s\n", prefix, expected, actual))
+ },
+ includeSkipped,
+ )
+ if err != nil {
+ log.Print(err.Error())
+ }
+}
+
+type MockTestingT struct{}
+
+func (t MockTestingT) Errorf(format string, args ...interface{}) {
+ fmt.Printf(format, args...)
+}
+
+func runCmdInTerminal(cmd *exec.Cmd) error {
+ cmd.Stdout = os.Stdout
+ cmd.Stdin = os.Stdin
+ cmd.Stderr = os.Stderr
+
+ return cmd.Run()
+}
diff --git a/pkg/integration/deprecated/cmd/tui/main.go b/pkg/integration/deprecated/cmd/tui/main.go
new file mode 100644
index 000000000..136ed29fe
--- /dev/null
+++ b/pkg/integration/deprecated/cmd/tui/main.go
@@ -0,0 +1,421 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazygit/pkg/gui"
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/integration/deprecated"
+ "github.com/jesseduffield/lazygit/pkg/secureexec"
+)
+
+// Deprecated. See lazy_integration for the new approach.
+
+// this program lets you manage integration tests in a TUI. See https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md for more info.
+
+type App struct {
+ tests []*deprecated.IntegrationTest
+ itemIdx int
+ testDir string
+ editing bool
+ g *gocui.Gui
+}
+
+func (app *App) getCurrentTest() *deprecated.IntegrationTest {
+ if len(app.tests) > 0 {
+ return app.tests[app.itemIdx]
+ }
+ return nil
+}
+
+func (app *App) refreshTests() {
+ app.loadTests()
+ app.g.Update(func(*gocui.Gui) error {
+ listView, err := app.g.View("list")
+ if err != nil {
+ return err
+ }
+
+ listView.Clear()
+ for _, test := range app.tests {
+ fmt.Fprintln(listView, test.Name)
+ }
+
+ return nil
+ })
+}
+
+func (app *App) loadTests() {
+ tests, err := deprecated.LoadTests(app.testDir)
+ if err != nil {
+ log.Panicln(err)
+ }
+
+ app.tests = tests
+ if app.itemIdx > len(app.tests)-1 {
+ app.itemIdx = len(app.tests) - 1
+ }
+}
+
+func main() {
+ rootDir := deprecated.GetRootDirectory()
+ testDir := filepath.Join(rootDir, "test", "integration")
+
+ app := &App{testDir: testDir}
+ app.loadTests()
+
+ g, err := gocui.NewGui(gocui.OutputTrue, false, gocui.NORMAL, false, gui.RuneReplacements)
+ if err != nil {
+ log.Panicln(err)
+ }
+
+ g.Cursor = false
+
+ app.g = g
+
+ g.SetManagerFunc(app.layout)
+
+ if err := g.SetKeybinding("list", gocui.KeyArrowUp, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ if app.itemIdx > 0 {
+ app.itemIdx--
+ }
+ listView, err := g.View("list")
+ if err != nil {
+ return err
+ }
+ listView.FocusPoint(0, app.itemIdx)
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 'q', gocui.ModNone, quit); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 'r', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ cmd := secureexec.Command("sh", "-c", fmt.Sprintf("INCLUDE_SKIPPED=true MODE=record go run pkg/integration/deprecated/cmd/runner/main.go %s", currentTest.Name))
+ app.runSubprocess(cmd)
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 's', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ cmd := secureexec.Command("sh", "-c", fmt.Sprintf("INCLUDE_SKIPPED=true MODE=sandbox go run pkg/integration/deprecated/cmd/runner/main.go %s", currentTest.Name))
+ app.runSubprocess(cmd)
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", gocui.KeyEnter, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ cmd := secureexec.Command("sh", "-c", fmt.Sprintf("INCLUDE_SKIPPED=true go run pkg/integration/deprecated/cmd/runner/main.go %s", currentTest.Name))
+ app.runSubprocess(cmd)
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 'u', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ cmd := secureexec.Command("sh", "-c", fmt.Sprintf("INCLUDE_SKIPPED=true MODE=updateSnapshot go run pkg/integration/deprecated/cmd/runner/main.go %s", currentTest.Name))
+ app.runSubprocess(cmd)
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 't', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ cmd := secureexec.Command("sh", "-c", fmt.Sprintf("INCLUDE_SKIPPED=true SPEED=1 go run pkg/integration/deprecated/cmd/runner/main.go %s", currentTest.Name))
+ app.runSubprocess(cmd)
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 'o', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ cmd := secureexec.Command("sh", "-c", fmt.Sprintf("code -r %s/%s/test.json", app.testDir, currentTest.Name))
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 'n', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ // need to duplicate that folder and then re-fetch our tests.
+ dir := app.testDir + "/" + app.getCurrentTest().Name
+ newDir := dir + "_Copy"
+
+ cmd := secureexec.Command("sh", "-c", fmt.Sprintf("cp -r %s %s", dir, newDir))
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+
+ app.loadTests()
+
+ app.refreshTests()
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 'm', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ app.editing = true
+ if _, err := g.SetCurrentView("editor"); err != nil {
+ return err
+ }
+ editorView, err := g.View("editor")
+ if err != nil {
+ return err
+ }
+ editorView.Clear()
+ fmt.Fprint(editorView, currentTest.Name)
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("list", 'd', gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ dir := app.testDir + "/" + app.getCurrentTest().Name
+
+ cmd := secureexec.Command("sh", "-c", fmt.Sprintf("rm -rf %s", dir))
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+
+ app.refreshTests()
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("editor", gocui.KeyEnter, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ app.editing = false
+ if _, err := g.SetCurrentView("list"); err != nil {
+ return err
+ }
+
+ editorView, err := g.View("editor")
+ if err != nil {
+ return err
+ }
+
+ dir := app.testDir + "/" + app.getCurrentTest().Name
+ newDir := app.testDir + "/" + editorView.Buffer()
+
+ cmd := secureexec.Command("sh", "-c", fmt.Sprintf("mv %s %s", dir, newDir))
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+
+ editorView.Clear()
+
+ app.refreshTests()
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding("editor", gocui.KeyEsc, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ app.editing = false
+ if _, err := g.SetCurrentView("list"); err != nil {
+ return err
+ }
+
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ err = g.MainLoop()
+ g.Close()
+ switch err {
+ case gocui.ErrQuit:
+ return
+ default:
+ log.Panicln(err)
+ }
+}
+
+func (app *App) runSubprocess(cmd *exec.Cmd) {
+ if err := gocui.Screen.Suspend(); err != nil {
+ panic(err)
+ }
+
+ cmd.Stdin = os.Stdin
+ cmd.Stderr = os.Stderr
+ cmd.Stdout = os.Stdout
+ if err := cmd.Run(); err != nil {
+ log.Println(err.Error())
+ }
+ cmd.Stdin = nil
+ cmd.Stderr = nil
+ cmd.Stdout = nil
+
+ fmt.Fprintf(os.Stdout, "\n%s", style.FgGreen.Sprint("press enter to return"))
+ fmt.Scanln() // wait for enter press
+
+ if err := gocui.Screen.Resume(); err != nil {
+ panic(err)
+ }
+}
+
+func (app *App) layout(g *gocui.Gui) error {
+ maxX, maxY := g.Size()
+ descriptionViewHeight := 7
+ keybindingsViewHeight := 3
+ editorViewHeight := 3
+ if !app.editing {
+ editorViewHeight = 0
+ } else {
+ descriptionViewHeight = 0
+ keybindingsViewHeight = 0
+ }
+ g.Cursor = app.editing
+ g.FgColor = gocui.ColorGreen
+ listView, err := g.SetView("list", 0, 0, maxX-1, maxY-descriptionViewHeight-keybindingsViewHeight-editorViewHeight-1, 0)
+ if err != nil {
+ if err.Error() != "unknown view" {
+ return err
+ }
+ listView.Highlight = true
+ listView.Clear()
+ for _, test := range app.tests {
+ fmt.Fprintln(listView, test.Name)
+ }
+ listView.Title = "Tests"
+ listView.FgColor = gocui.ColorDefault
+ if _, err := g.SetCurrentView("list"); err != nil {
+ return err
+ }
+ }
+
+ descriptionView, err := g.SetViewBeneath("description", "list", descriptionViewHeight)
+ if err != nil {
+ if err.Error() != "unknown view" {
+ return err
+ }
+ descriptionView.Title = "Test description"
+ descriptionView.Wrap = true
+ descriptionView.FgColor = gocui.ColorDefault
+ }
+
+ keybindingsView, err := g.SetViewBeneath("keybindings", "description", keybindingsViewHeight)
+ if err != nil {
+ if err.Error() != "unknown view" {
+ return err
+ }
+ keybindingsView.Title = "Keybindings"
+ keybindingsView.Wrap = true
+ keybindingsView.FgColor = gocui.ColorDefault
+ fmt.Fprintln(keybindingsView, "up/down: navigate, enter: run test, u: run test and update snapshots, r: record test, s: sandbox, o: open test config, n: duplicate test, m: rename test, d: delete test, t: run test at original speed")
+ }
+
+ editorView, err := g.SetViewBeneath("editor", "keybindings", editorViewHeight)
+ if err != nil {
+ if err.Error() != "unknown view" {
+ return err
+ }
+ editorView.Title = "Enter Name"
+ editorView.FgColor = gocui.ColorDefault
+ editorView.Editable = true
+ }
+
+ currentTest := app.getCurrentTest()
+ if currentTest == nil {
+ return nil
+ }
+
+ descriptionView.Clear()
+ fmt.Fprintf(descriptionView, "Speed: %f. %s", currentTest.Speed, currentTest.Description)
+
+ if err := g.SetKeybinding("list", gocui.KeyArrowDown, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
+ if app.itemIdx < len(app.tests)-1 {
+ app.itemIdx++
+ }
+
+ listView, err := g.View("list")
+ if err != nil {
+ return err
+ }
+ listView.FocusPoint(0, app.itemIdx)
+ return nil
+ }); err != nil {
+ log.Panicln(err)
+ }
+
+ return nil
+}
+
+func quit(g *gocui.Gui, v *gocui.View) error {
+ return gocui.ErrQuit
+}
diff --git a/pkg/integration/deprecated/go_test.go b/pkg/integration/deprecated/go_test.go
new file mode 100644
index 000000000..fbec34bd9
--- /dev/null
+++ b/pkg/integration/deprecated/go_test.go
@@ -0,0 +1,106 @@
+//go:build !windows
+// +build !windows
+
+package deprecated
+
+import (
+ "fmt"
+ "io"
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "strconv"
+ "testing"
+
+ "github.com/creack/pty"
+ "github.com/stretchr/testify/assert"
+)
+
+// Deprecated.
+
+// This file is quite similar to integration/main.go. The main difference is that this file is
+// run via `go test` whereas the other is run via `test/lazyintegration/main.go` which provides
+// a convenient gui wrapper around our integration tests. The `go test` approach is better
+// for CI and for running locally in the background to ensure you haven't broken
+// anything while making changes. If you want to visually see what's happening when a test is run,
+// you'll need to take the other approach
+//
+// As for this file, to run an integration test, e.g. for test 'commit', go:
+// go test pkg/gui/old_gui_test.go -run /commit
+//
+// To update a snapshot for an integration test, pass UPDATE_SNAPSHOTS=true
+// UPDATE_SNAPSHOTS=true go test pkg/gui/old_gui_test.go -run /commit
+//
+// integration tests are run in test/integration//actual and the final test does
+// not clean up that directory so you can cd into it to see for yourself what
+// happened when a test fails.
+//
+// To override speed, pass e.g. `SPEED=1` as an env var. Otherwise we start each test
+// at a high speed and then drop down to lower speeds upon each failure until finally
+// trying at the original playback speed (speed 1). A speed of 2 represents twice the
+// original playback speed. Speed may be a decimal.
+
+func Test(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration tests in short mode")
+ }
+
+ mode := GetModeFromEnv()
+ speedEnv := os.Getenv("SPEED")
+ includeSkipped := os.Getenv("INCLUDE_SKIPPED") != ""
+
+ parallelTotal := tryConvert(os.Getenv("PARALLEL_TOTAL"), 1)
+ parallelIndex := tryConvert(os.Getenv("PARALLEL_INDEX"), 0)
+ testNumber := 0
+
+ err := RunTests(
+ t.Logf,
+ runCmdHeadless,
+ func(test *IntegrationTest, f func(*testing.T) error) {
+ defer func() { testNumber += 1 }()
+ if testNumber%parallelTotal != parallelIndex {
+ return
+ }
+
+ t.Run(test.Name, func(t *testing.T) {
+ err := f(t)
+ assert.NoError(t, err)
+ })
+ },
+ mode,
+ speedEnv,
+ func(t *testing.T, expected string, actual string, prefix string) {
+ t.Helper()
+ assert.Equal(t, expected, actual, fmt.Sprintf("Unexpected %s. Expected:\n%s\nActual:\n%s\n", prefix, expected, actual))
+ },
+ includeSkipped,
+ )
+
+ assert.NoError(t, err)
+}
+
+func tryConvert(numStr string, defaultVal int) int {
+ num, err := strconv.Atoi(numStr)
+ if err != nil {
+ return defaultVal
+ }
+
+ return num
+}
+
+func runCmdHeadless(cmd *exec.Cmd) error {
+ cmd.Env = append(
+ cmd.Env,
+ "HEADLESS=true",
+ "TERM=xterm",
+ )
+
+ f, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 100, Cols: 100})
+ if err != nil {
+ return err
+ }
+
+ _, _ = io.Copy(ioutil.Discard, f)
+
+ return f.Close()
+}
diff --git a/pkg/integration/deprecated/integration.go b/pkg/integration/deprecated/integration.go
new file mode 100644
index 000000000..b44fdb1b2
--- /dev/null
+++ b/pkg/integration/deprecated/integration.go
@@ -0,0 +1,564 @@
+package deprecated
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/secureexec"
+)
+
+// Deprecated: This file is part of the old way of doing things. See pkg/integration/integration.go for the new way
+
+// This package is for running our integration test suite. See https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md for more info.
+
+type IntegrationTest struct {
+ Name string `json:"name"`
+ Speed float64 `json:"speed"`
+ Description string `json:"description"`
+ ExtraCmdArgs string `json:"extraCmdArgs"`
+ Skip bool `json:"skip"`
+}
+
+type Mode int
+
+const (
+ // default: for when we're just running a test and comparing to the snapshot
+ TEST = iota
+ // for when we want to record a test and set the snapshot based on the result
+ RECORD
+ // when we just want to use the setup of the test for our own sandboxing purposes.
+ // This does not record the session and does not create/update snapshots
+ SANDBOX
+ // running a test but updating the snapshot
+ UPDATE_SNAPSHOT
+)
+
+func GetModeFromEnv() Mode {
+ switch os.Getenv("MODE") {
+ case "record":
+ return RECORD
+ case "", "test":
+ return TEST
+ case "updateSnapshot":
+ return UPDATE_SNAPSHOT
+ case "sandbox":
+ return SANDBOX
+ default:
+ log.Fatalf("unknown test mode: %s, must be one of [test, record, updateSnapshot, sandbox]", os.Getenv("MODE"))
+ panic("unreachable")
+ }
+}
+
+// this function is used by both `go test` and from our lazyintegration gui, but
+// errors need to be handled differently in each (for example go test is always
+// working with *testing.T) so we pass in any differences as args here.
+func RunTests(
+ logf func(format string, formatArgs ...interface{}),
+ runCmd func(cmd *exec.Cmd) error,
+ fnWrapper func(test *IntegrationTest, f func(*testing.T) error),
+ mode Mode,
+ speedEnv string,
+ onFail func(t *testing.T, expected string, actual string, prefix string),
+ includeSkipped bool,
+) error {
+ rootDir := GetRootDirectory()
+ err := os.Chdir(rootDir)
+ if err != nil {
+ return err
+ }
+
+ testDir := filepath.Join(rootDir, "test", "integration")
+
+ osCommand := oscommands.NewDummyOSCommand()
+ err = osCommand.Cmd.New("go build -o " + tempLazygitPath()).Run()
+ if err != nil {
+ return err
+ }
+
+ tests, err := LoadTests(testDir)
+ if err != nil {
+ return err
+ }
+
+ for _, test := range tests {
+ test := test
+
+ fnWrapper(test, func(t *testing.T) error { //nolint: thelper
+ if test.Skip && !includeSkipped {
+ logf("skipping test: %s", test.Name)
+ return nil
+ }
+
+ speeds := getTestSpeeds(test.Speed, mode, speedEnv)
+ testPath := filepath.Join(testDir, test.Name)
+ actualDir := filepath.Join(testPath, "actual")
+ expectedDir := filepath.Join(testPath, "expected")
+ actualRepoDir := filepath.Join(actualDir, "repo")
+ logf("path: %s", testPath)
+
+ for i, speed := range speeds {
+ if mode != SANDBOX && mode != RECORD {
+ logf("%s: attempting test at speed %f\n", test.Name, speed)
+ }
+
+ findOrCreateDir(testPath)
+ prepareIntegrationTestDir(actualDir)
+ findOrCreateDir(actualRepoDir)
+ err := createFixture(testPath, actualRepoDir)
+ if err != nil {
+ return err
+ }
+
+ configDir := filepath.Join(testPath, "used_config")
+
+ cmd, err := getLazygitCommand(testPath, rootDir, mode, speed, test.ExtraCmdArgs)
+ if err != nil {
+ return err
+ }
+
+ err = runCmd(cmd)
+ if err != nil {
+ return err
+ }
+
+ if mode == UPDATE_SNAPSHOT || mode == RECORD {
+ // create/update snapshot
+ err = oscommands.CopyDir(actualDir, expectedDir)
+ if err != nil {
+ return err
+ }
+
+ if err := renameSpecialPaths(expectedDir); err != nil {
+ return err
+ }
+
+ logf("%s", "updated snapshot")
+ } else {
+ if err := validateSameRepos(expectedDir, actualDir); err != nil {
+ return err
+ }
+
+ // iterate through each repo in the expected dir and comparet to the corresponding repo in the actual dir
+ expectedFiles, err := ioutil.ReadDir(expectedDir)
+ if err != nil {
+ return err
+ }
+
+ success := true
+ for _, f := range expectedFiles {
+ if !f.IsDir() {
+ return errors.New("unexpected file (as opposed to directory) in integration test 'expected' directory")
+ }
+
+ // get corresponding file name from actual dir
+ actualRepoPath := filepath.Join(actualDir, f.Name())
+ expectedRepoPath := filepath.Join(expectedDir, f.Name())
+
+ actualRepo, expectedRepo, err := generateSnapshots(actualRepoPath, expectedRepoPath)
+ if err != nil {
+ return err
+ }
+
+ if expectedRepo != actualRepo {
+ success = false
+ // if the snapshot doesn't match and we haven't tried all playback speeds different we'll retry at a slower speed
+ if i < len(speeds)-1 {
+ break
+ }
+
+ // get the log file and print it
+ bytes, err := ioutil.ReadFile(filepath.Join(configDir, "development.log"))
+ if err != nil {
+ return err
+ }
+ logf("%s", string(bytes))
+
+ onFail(t, expectedRepo, actualRepo, f.Name())
+ }
+ }
+
+ if success {
+ logf("%s: success at speed %f\n", test.Name, speed)
+ break
+ }
+ }
+ }
+
+ return nil
+ })
+ }
+
+ return nil
+}
+
+// validates that the actual and expected dirs have the same repo names (doesn't actually check the contents of the repos)
+func validateSameRepos(expectedDir string, actualDir string) error {
+ // iterate through each repo in the expected dir and compare to the corresponding repo in the actual dir
+ expectedFiles, err := ioutil.ReadDir(expectedDir)
+ if err != nil {
+ return err
+ }
+
+ var actualFiles []os.FileInfo
+ actualFiles, err = ioutil.ReadDir(actualDir)
+ if err != nil {
+ return err
+ }
+
+ expectedFileNames := slices.Map(expectedFiles, getFileName)
+ actualFileNames := slices.Map(actualFiles, getFileName)
+ if !slices.Equal(expectedFileNames, actualFileNames) {
+ return fmt.Errorf("expected and actual repo dirs do not match: expected: %s, actual: %s", expectedFileNames, actualFileNames)
+ }
+
+ return nil
+}
+
+func getFileName(f os.FileInfo) string {
+ return f.Name()
+}
+
+func prepareIntegrationTestDir(actualDir string) {
+ // remove contents of integration test directory
+ dir, err := ioutil.ReadDir(actualDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ err = os.Mkdir(actualDir, 0o777)
+ if err != nil {
+ panic(err)
+ }
+ } else {
+ panic(err)
+ }
+ }
+ for _, d := range dir {
+ os.RemoveAll(filepath.Join(actualDir, d.Name()))
+ }
+}
+
+func GetRootDirectory() string {
+ path, err := os.Getwd()
+ if err != nil {
+ panic(err)
+ }
+
+ for {
+ _, err := os.Stat(filepath.Join(path, ".git"))
+
+ if err == nil {
+ return path
+ }
+
+ if !os.IsNotExist(err) {
+ panic(err)
+ }
+
+ path = filepath.Dir(path)
+
+ if path == "/" {
+ log.Fatal("must run in lazygit folder or child folder")
+ }
+ }
+}
+
+func createFixture(testPath, actualDir string) error {
+ bashScriptPath := filepath.Join(testPath, "setup.sh")
+ cmd := secureexec.Command("bash", bashScriptPath, actualDir)
+
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return errors.New(string(output))
+ }
+
+ return nil
+}
+
+func tempLazygitPath() string {
+ return filepath.Join("/tmp", "lazygit", "test_lazygit")
+}
+
+func getTestSpeeds(testStartSpeed float64, mode Mode, speedStr string) []float64 {
+ if mode != TEST {
+ // have to go at original speed if updating snapshots in case we go to fast and create a junk snapshot
+ return []float64{1.0}
+ }
+
+ if speedStr != "" {
+ speed, err := strconv.ParseFloat(speedStr, 64)
+ if err != nil {
+ panic(err)
+ }
+ return []float64{speed}
+ }
+
+ // default is 10, 5, 1
+ startSpeed := 10.0
+ if testStartSpeed != 0 {
+ startSpeed = testStartSpeed
+ }
+ speeds := []float64{startSpeed}
+ if startSpeed > 5 {
+ speeds = append(speeds, 5)
+ }
+ speeds = append(speeds, 1, 1)
+
+ return speeds
+}
+
+func LoadTests(testDir string) ([]*IntegrationTest, error) {
+ paths, err := filepath.Glob(filepath.Join(testDir, "/*/test.json"))
+ if err != nil {
+ return nil, err
+ }
+
+ tests := make([]*IntegrationTest, len(paths))
+
+ for i, path := range paths {
+ data, err := ioutil.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ test := &IntegrationTest{}
+
+ err = json.Unmarshal(data, test)
+ if err != nil {
+ return nil, err
+ }
+
+ test.Name = strings.TrimPrefix(filepath.Dir(path), testDir+"/")
+
+ tests[i] = test
+ }
+
+ return tests, nil
+}
+
+func findOrCreateDir(path string) {
+ _, err := os.Stat(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ err = os.MkdirAll(path, 0o777)
+ if err != nil {
+ panic(err)
+ }
+ } else {
+ panic(err)
+ }
+ }
+}
+
+// note that we don't actually store this snapshot in the lazygit repo.
+// Instead we store the whole expected git repo of our test, so that
+// we can easily change what we want to compare without needing to regenerate
+// snapshots for each test.
+func generateSnapshot(dir string) (string, error) {
+ osCommand := oscommands.NewDummyOSCommand()
+
+ _, err := os.Stat(filepath.Join(dir, ".git"))
+ if err != nil {
+ return "git directory not found", nil
+ }
+
+ snapshot := ""
+
+ cmdStrs := []string{
+ `remote show -n origin`, // remote branches
+ // TODO: find a way to bring this back without breaking tests
+ // `ls-remote origin`,
+ `status`, // file tree
+ `log --pretty=%B|%an|%ae -p -1`, // log
+ `tag -n`, // tags
+ `stash list`, // stash
+ `submodule foreach 'git status'`, // submodule status
+ `submodule foreach 'git log --pretty=%B -p -1'`, // submodule log
+ `submodule foreach 'git tag -n'`, // submodule tags
+ `submodule foreach 'git stash list'`, // submodule stash
+ }
+
+ for _, cmdStr := range cmdStrs {
+ // ignoring error for now. If there's an error it could be that there are no results
+ output, _ := osCommand.Cmd.New(fmt.Sprintf("git -C %s %s", dir, cmdStr)).RunWithOutput()
+
+ snapshot += fmt.Sprintf("git %s:\n%s\n", cmdStr, output)
+ }
+
+ snapshot += "files in repo:\n"
+ err = filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if f.IsDir() {
+ if f.Name() == ".git" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ bytes, err := ioutil.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ relativePath, err := filepath.Rel(dir, path)
+ if err != nil {
+ return err
+ }
+ snapshot += fmt.Sprintf("path: %s\ncontent:\n%s\n", relativePath, string(bytes))
+
+ return nil
+ })
+
+ if err != nil {
+ return "", err
+ }
+
+ return snapshot, nil
+}
+
+func generateSnapshots(actualDir string, expectedDir string) (string, string, error) {
+ actual, err := generateSnapshot(actualDir)
+ if err != nil {
+ return "", "", err
+ }
+
+ // there are a couple of reasons we're not generating the snapshot in expectedDir directly:
+ // Firstly we don't want to have to revert our .git file back to .git_keep.
+ // Secondly, the act of calling git commands like 'git status' actually changes the index
+ // for some reason, and we don't want to leave your lazygit working tree dirty as a result.
+ expectedDirCopyDir := filepath.Join(filepath.Dir(expectedDir), "expected_dir_test")
+ err = oscommands.CopyDir(expectedDir, expectedDirCopyDir)
+ if err != nil {
+ return "", "", err
+ }
+
+ defer func() {
+ err := os.RemoveAll(expectedDirCopyDir)
+ if err != nil {
+ panic(err)
+ }
+ }()
+
+ if err := restoreSpecialPaths(expectedDirCopyDir); err != nil {
+ return "", "", err
+ }
+
+ expected, err := generateSnapshot(expectedDirCopyDir)
+ if err != nil {
+ return "", "", err
+ }
+
+ return actual, expected, nil
+}
+
+func getPathsToRename(dir string, needle string, contains string) []string {
+ pathsToRename := []string{}
+
+ err := filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if f.Name() == needle && (contains == "" || strings.Contains(path, contains)) {
+ pathsToRename = append(pathsToRename, path)
+ }
+
+ return nil
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ return pathsToRename
+}
+
+var specialPathMappings = []struct{ original, new, contains string }{
+ // git refuses to track .git or .gitmodules in subdirectories so we need to rename them
+ {".git", ".git_keep", ""},
+ {".gitmodules", ".gitmodules_keep", ""},
+ // we also need git to ignore the contents of our test gitignore files so that
+ // we actually commit files that are ignored within the test.
+ {".gitignore", "lg_ignore_file", ""},
+ // this is the .git/info/exclude file. We're being a little more specific here
+ // so that we don't accidentally mess with some other file named 'exclude' in the test.
+ {"exclude", "lg_exclude_file", ".git/info/exclude"},
+}
+
+func renameSpecialPaths(dir string) error {
+ for _, specialPath := range specialPathMappings {
+ for _, path := range getPathsToRename(dir, specialPath.original, specialPath.contains) {
+ err := os.Rename(path, filepath.Join(filepath.Dir(path), specialPath.new))
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func restoreSpecialPaths(dir string) error {
+ for _, specialPath := range specialPathMappings {
+ for _, path := range getPathsToRename(dir, specialPath.new, specialPath.contains) {
+ err := os.Rename(path, filepath.Join(filepath.Dir(path), specialPath.original))
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func getLazygitCommand(testPath string, rootDir string, mode Mode, speed float64, extraCmdArgs string) (*exec.Cmd, error) {
+ osCommand := oscommands.NewDummyOSCommand()
+
+ replayPath := filepath.Join(testPath, "recording.json")
+ templateConfigDir := filepath.Join(rootDir, "test", "default_test_config")
+ actualRepoDir := filepath.Join(testPath, "actual", "repo")
+
+ exists, err := osCommand.FileExists(filepath.Join(testPath, "config"))
+ if err != nil {
+ return nil, err
+ }
+
+ if exists {
+ templateConfigDir = filepath.Join(testPath, "config")
+ }
+
+ configDir := filepath.Join(testPath, "used_config")
+
+ err = os.RemoveAll(configDir)
+ if err != nil {
+ return nil, err
+ }
+ err = oscommands.CopyDir(templateConfigDir, configDir)
+ if err != nil {
+ return nil, err
+ }
+
+ cmdStr := fmt.Sprintf("%s -debug --use-config-dir=%s --path=%s %s", tempLazygitPath(), configDir, actualRepoDir, extraCmdArgs)
+
+ cmdObj := osCommand.Cmd.New(cmdStr)
+ cmdObj.AddEnvVars(fmt.Sprintf("SPEED=%f", speed))
+
+ switch mode {
+ case RECORD:
+ cmdObj.AddEnvVars(fmt.Sprintf("RECORD_EVENTS_TO=%s", replayPath))
+ case TEST, UPDATE_SNAPSHOT:
+ cmdObj.AddEnvVars(fmt.Sprintf("REPLAY_EVENTS_FROM=%s", replayPath))
+ }
+
+ return cmdObj.GetCmd(), nil
+}
diff --git a/pkg/integration/integration.go b/pkg/integration/integration.go
deleted file mode 100644
index a55d460fe..000000000
--- a/pkg/integration/integration.go
+++ /dev/null
@@ -1,554 +0,0 @@
-package integration
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "io/ioutil"
- "log"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
- "testing"
-
- "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
- "github.com/jesseduffield/lazygit/pkg/secureexec"
-)
-
-type Test struct {
- Name string `json:"name"`
- Speed float64 `json:"speed"`
- Description string `json:"description"`
- ExtraCmdArgs string `json:"extraCmdArgs"`
- Skip bool `json:"skip"`
-}
-
-type Mode int
-
-const (
- // default: for when we're just running a test and comparing to the snapshot
- TEST = iota
- // for when we want to record a test and set the snapshot based on the result
- RECORD
- // when we just want to use the setup of the test for our own sandboxing purposes.
- // This does not record the session and does not create/update snapshots
- SANDBOX
- // running a test but updating the snapshot
- UPDATE_SNAPSHOT
-)
-
-func GetModeFromEnv() Mode {
- switch os.Getenv("MODE") {
- case "record":
- return RECORD
- case "", "test":
- return TEST
- case "updateSnapshot":
- return UPDATE_SNAPSHOT
- case "sandbox":
- return SANDBOX
- default:
- log.Fatalf("unknown test mode: %s, must be one of [test, record, update, sandbox]", os.Getenv("MODE"))
- panic("unreachable")
- }
-}
-
-// this function is used by both `go test` and from our lazyintegration gui, but
-// errors need to be handled differently in each (for example go test is always
-// working with *testing.T) so we pass in any differences as args here.
-func RunTests(
- logf func(format string, formatArgs ...interface{}),
- runCmd func(cmd *exec.Cmd) error,
- fnWrapper func(test *Test, f func(*testing.T) error),
- mode Mode,
- speedEnv string,
- onFail func(t *testing.T, expected string, actual string, prefix string),
- includeSkipped bool,
-) error {
- rootDir := GetRootDirectory()
- err := os.Chdir(rootDir)
- if err != nil {
- return err
- }
-
- testDir := filepath.Join(rootDir, "test", "integration")
-
- osCommand := oscommands.NewDummyOSCommand()
- err = osCommand.Cmd.New("go build -o " + tempLazygitPath()).Run()
- if err != nil {
- return err
- }
-
- tests, err := LoadTests(testDir)
- if err != nil {
- return err
- }
-
- for _, test := range tests {
- test := test
-
- if test.Skip && !includeSkipped {
- logf("skipping test: %s", test.Name)
- continue
- }
-
- fnWrapper(test, func(t *testing.T) error {
- speeds := getTestSpeeds(test.Speed, mode, speedEnv)
- testPath := filepath.Join(testDir, test.Name)
- actualRepoDir := filepath.Join(testPath, "actual")
- expectedRepoDir := filepath.Join(testPath, "expected")
- actualRemoteDir := filepath.Join(testPath, "actual_remote")
- expectedRemoteDir := filepath.Join(testPath, "expected_remote")
- otherRepoDir := filepath.Join(testPath, "other_repo")
- logf("path: %s", testPath)
-
- for i, speed := range speeds {
- if mode != SANDBOX && mode != RECORD {
- logf("%s: attempting test at speed %f\n", test.Name, speed)
- }
-
- findOrCreateDir(testPath)
- prepareIntegrationTestDir(actualRepoDir)
- removeDir(otherRepoDir)
- removeDir(actualRemoteDir)
- err := createFixture(testPath, actualRepoDir)
- if err != nil {
- return err
- }
-
- configDir := filepath.Join(testPath, "used_config")
-
- cmd, err := getLazygitCommand(testPath, rootDir, mode, speed, test.ExtraCmdArgs)
- if err != nil {
- return err
- }
-
- err = runCmd(cmd)
- if err != nil {
- return err
- }
-
- // submodule tests currently make use of a repo called 'other_repo' but we don't want that
- // to stick around. Long-term we should have an 'actual' folder which itself contains
- // repos, and there we can put the 'repo' repo which is the main one, alongside
- // any others that we use as part of the test (including remotes). Then we'll do snapshots for
- // each of them.
- removeDir(otherRepoDir)
-
- if mode == UPDATE_SNAPSHOT || mode == RECORD {
- // create/update snapshot
- err = oscommands.CopyDir(actualRepoDir, expectedRepoDir)
- if err != nil {
- return err
- }
-
- if err := renameGitDirs(expectedRepoDir); err != nil {
- return err
- }
-
- // see if we have a remote dir and if so, copy it over. Otherwise, delete the expected dir because we have no remote folder.
- if folderExists(actualRemoteDir) {
- err = oscommands.CopyDir(actualRemoteDir, expectedRemoteDir)
- if err != nil {
- return err
- }
- } else {
- removeDir(expectedRemoteDir)
- }
-
- logf("%s", "updated snapshot")
- } else {
- // compare result to snapshot
- actualRepo, expectedRepo, err := generateSnapshots(actualRepoDir, expectedRepoDir)
- if err != nil {
- return err
- }
-
- actualRemote := "remote folder does not exist"
- expectedRemote := "remote folder does not exist"
- if folderExists(expectedRemoteDir) {
- actualRemote, expectedRemote, err = generateSnapshotsForRemote(actualRemoteDir, expectedRemoteDir)
- if err != nil {
- return err
- }
- } else if folderExists(actualRemoteDir) {
- actualRemote = "remote folder exists"
- }
-
- if expectedRepo == actualRepo && expectedRemote == actualRemote {
- logf("%s: success at speed %f\n", test.Name, speed)
- break
- }
-
- // if the snapshot doesn't match and we haven't tried all playback speeds different we'll retry at a slower speed
- if i == len(speeds)-1 {
- // get the log file and print that
- bytes, err := ioutil.ReadFile(filepath.Join(configDir, "development.log"))
- if err != nil {
- return err
- }
- logf("%s", string(bytes))
- if expectedRepo != actualRepo {
- onFail(t, expectedRepo, actualRepo, "repo")
- } else {
- onFail(t, expectedRemote, actualRemote, "remote")
- }
- }
- }
- }
-
- return nil
- })
- }
-
- return nil
-}
-
-func removeDir(dir string) {
- err := os.RemoveAll(dir)
- if err != nil {
- panic(err)
- }
-}
-
-func prepareIntegrationTestDir(actualDir string) {
- // remove contents of integration test directory
- dir, err := ioutil.ReadDir(actualDir)
- if err != nil {
- if os.IsNotExist(err) {
- err = os.Mkdir(actualDir, 0777)
- if err != nil {
- panic(err)
- }
- } else {
- panic(err)
- }
- }
- for _, d := range dir {
- os.RemoveAll(filepath.Join(actualDir, d.Name()))
- }
-}
-
-func GetRootDirectory() string {
- path, err := os.Getwd()
- if err != nil {
- panic(err)
- }
-
- for {
- _, err := os.Stat(filepath.Join(path, ".git"))
-
- if err == nil {
- return path
- }
-
- if !os.IsNotExist(err) {
- panic(err)
- }
-
- path = filepath.Dir(path)
-
- if path == "/" {
- log.Fatal("must run in lazygit folder or child folder")
- }
- }
-}
-
-func createFixture(testPath, actualDir string) error {
- bashScriptPath := filepath.Join(testPath, "setup.sh")
- cmd := secureexec.Command("bash", bashScriptPath, actualDir)
-
- if output, err := cmd.CombinedOutput(); err != nil {
- return errors.New(string(output))
- }
-
- return nil
-}
-
-func tempLazygitPath() string {
- return filepath.Join("/tmp", "lazygit", "test_lazygit")
-}
-
-func getTestSpeeds(testStartSpeed float64, mode Mode, speedStr string) []float64 {
- if mode != TEST {
- // have to go at original speed if updating snapshots in case we go to fast and create a junk snapshot
- return []float64{1.0}
- }
-
- if speedStr != "" {
- speed, err := strconv.ParseFloat(speedStr, 64)
- if err != nil {
- panic(err)
- }
- return []float64{speed}
- }
-
- // default is 10, 5, 1
- startSpeed := 10.0
- if testStartSpeed != 0 {
- startSpeed = testStartSpeed
- }
- speeds := []float64{startSpeed}
- if startSpeed > 5 {
- speeds = append(speeds, 5)
- }
- speeds = append(speeds, 1, 1)
-
- return speeds
-}
-
-func LoadTests(testDir string) ([]*Test, error) {
- paths, err := filepath.Glob(filepath.Join(testDir, "/*/test.json"))
- if err != nil {
- return nil, err
- }
-
- tests := make([]*Test, len(paths))
-
- for i, path := range paths {
- data, err := ioutil.ReadFile(path)
- if err != nil {
- return nil, err
- }
-
- test := &Test{}
-
- err = json.Unmarshal(data, test)
- if err != nil {
- return nil, err
- }
-
- test.Name = strings.TrimPrefix(filepath.Dir(path), testDir+"/")
-
- tests[i] = test
- }
-
- return tests, nil
-}
-
-func findOrCreateDir(path string) {
- _, err := os.Stat(path)
- if err != nil {
- if os.IsNotExist(err) {
- err = os.MkdirAll(path, 0777)
- if err != nil {
- panic(err)
- }
- } else {
- panic(err)
- }
- }
-}
-
-// note that we don't actually store this snapshot in the lazygit repo.
-// Instead we store the whole expected git repo of our test, so that
-// we can easily change what we want to compare without needing to regenerate
-// snapshots for each test.
-func generateSnapshot(dir string) (string, error) {
- osCommand := oscommands.NewDummyOSCommand()
-
- _, err := os.Stat(filepath.Join(dir, ".git"))
- if err != nil {
- return "git directory not found", nil
- }
-
- snapshot := ""
-
- cmdStrs := []string{
- `status`, // file tree
- `log --pretty=%B -p -1`, // log
- `tag -n`, // tags
- `stash list`, // stash
- `submodule foreach 'git status'`, // submodule status
- `submodule foreach 'git log --pretty=%B -p -1'`, // submodule log
- `submodule foreach 'git tag -n'`, // submodule tags
- `submodule foreach 'git stash list'`, // submodule stash
- }
-
- for _, cmdStr := range cmdStrs {
- // ignoring error for now. If there's an error it could be that there are no results
- output, _ := osCommand.Cmd.New(fmt.Sprintf("git -C %s %s", dir, cmdStr)).RunWithOutput()
-
- snapshot += fmt.Sprintf("git %s:\n%s\n", cmdStr, output)
- }
-
- snapshot += "files in repo:\n"
- err = filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
- if err != nil {
- return err
- }
-
- if f.IsDir() {
- if f.Name() == ".git" {
- return filepath.SkipDir
- }
- return nil
- }
-
- bytes, err := ioutil.ReadFile(path)
- if err != nil {
- return err
- }
-
- relativePath, err := filepath.Rel(dir, path)
- if err != nil {
- return err
- }
- snapshot += fmt.Sprintf("path: %s\ncontent:\n%s\n", relativePath, string(bytes))
-
- return nil
- })
-
- if err != nil {
- return "", err
- }
-
- return snapshot, nil
-}
-
-func generateSnapshots(actualDir string, expectedDir string) (string, string, error) {
- actual, err := generateSnapshot(actualDir)
- if err != nil {
- return "", "", err
- }
-
- // there are a couple of reasons we're not generating the snapshot in expectedDir directly:
- // Firstly we don't want to have to revert our .git file back to .git_keep.
- // Secondly, the act of calling git commands like 'git status' actually changes the index
- // for some reason, and we don't want to leave your lazygit working tree dirty as a result.
- expectedDirCopyDir := filepath.Join(filepath.Dir(expectedDir), "expected_dir_test")
- err = oscommands.CopyDir(expectedDir, expectedDirCopyDir)
- if err != nil {
- return "", "", err
- }
-
- if err := restoreGitDirs(expectedDirCopyDir); err != nil {
- return "", "", err
- }
-
- expected, err := generateSnapshot(expectedDirCopyDir)
- if err != nil {
- return "", "", err
- }
-
- err = os.RemoveAll(expectedDirCopyDir)
- if err != nil {
- return "", "", err
- }
-
- return actual, expected, nil
-}
-
-func getPathsToRename(dir string, needle string) []string {
- pathsToRename := []string{}
-
- err := filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
- if err != nil {
- return err
- }
-
- if f.Name() == needle {
- pathsToRename = append(pathsToRename, path)
- }
-
- return nil
- })
- if err != nil {
- panic(err)
- }
-
- return pathsToRename
-}
-
-// Git refuses to track .git and .gitmodules folders in subdirectories so we need to rename it
-// to git_keep after running a test, and then change it back again
-var untrackedGitDirs []string = []string{".git", ".gitmodules"}
-
-func renameGitDirs(dir string) error {
- for _, untrackedGitDir := range untrackedGitDirs {
- for _, path := range getPathsToRename(dir, untrackedGitDir) {
- err := os.Rename(path, path+"_keep")
- if err != nil {
- return err
- }
- }
- }
-
- return nil
-}
-
-func restoreGitDirs(dir string) error {
- for _, untrackedGitDir := range untrackedGitDirs {
- for _, path := range getPathsToRename(dir, untrackedGitDir+"_keep") {
- err := os.Rename(path, strings.TrimSuffix(path, "_keep"))
- if err != nil {
- return err
- }
- }
- }
-
- return nil
-}
-
-func generateSnapshotsForRemote(actualDir string, expectedDir string) (string, string, error) {
- actual, err := generateSnapshot(actualDir)
- if err != nil {
- return "", "", err
- }
-
- expected, err := generateSnapshot(expectedDir)
- if err != nil {
- return "", "", err
- }
-
- return actual, expected, nil
-}
-
-func getLazygitCommand(testPath string, rootDir string, mode Mode, speed float64, extraCmdArgs string) (*exec.Cmd, error) {
- osCommand := oscommands.NewDummyOSCommand()
-
- replayPath := filepath.Join(testPath, "recording.json")
- templateConfigDir := filepath.Join(rootDir, "test", "default_test_config")
- actualDir := filepath.Join(testPath, "actual")
-
- exists, err := osCommand.FileExists(filepath.Join(testPath, "config"))
- if err != nil {
- return nil, err
- }
-
- if exists {
- templateConfigDir = filepath.Join(testPath, "config")
- }
-
- configDir := filepath.Join(testPath, "used_config")
-
- err = os.RemoveAll(configDir)
- if err != nil {
- return nil, err
- }
- err = oscommands.CopyDir(templateConfigDir, configDir)
- if err != nil {
- return nil, err
- }
-
- cmdStr := fmt.Sprintf("%s -debug --use-config-dir=%s --path=%s %s", tempLazygitPath(), configDir, actualDir, extraCmdArgs)
-
- cmdObj := osCommand.Cmd.New(cmdStr)
- cmdObj.AddEnvVars(fmt.Sprintf("SPEED=%f", speed))
-
- switch mode {
- case RECORD:
- cmdObj.AddEnvVars(fmt.Sprintf("RECORD_EVENTS_TO=%s", replayPath))
- case TEST, UPDATE_SNAPSHOT:
- cmdObj.AddEnvVars(fmt.Sprintf("REPLAY_EVENTS_FROM=%s", replayPath))
- }
-
- return cmdObj.GetCmd(), nil
-}
-
-func folderExists(path string) bool {
- _, err := os.Stat(path)
- return err == nil
-}
diff --git a/pkg/integration/tests/branch/suggestions.go b/pkg/integration/tests/branch/suggestions.go
new file mode 100644
index 000000000..0d8269f1d
--- /dev/null
+++ b/pkg/integration/tests/branch/suggestions.go
@@ -0,0 +1,41 @@
+package branch
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var Suggestions = components.NewIntegrationTest(components.NewIntegrationTestArgs{
+ Description: "Checking out a branch with name suggestions",
+ ExtraCmdArgs: "",
+ Skip: false,
+ SetupConfig: func(config *config.AppConfig) {},
+ SetupRepo: func(shell *components.Shell) {
+ shell.
+ EmptyCommit("my commit message").
+ NewBranch("new-branch").
+ NewBranch("new-branch-2").
+ NewBranch("new-branch-3").
+ NewBranch("branch-to-checkout").
+ NewBranch("other-new-branch-2").
+ NewBranch("other-new-branch-3")
+ },
+ Run: func(shell *components.Shell, input *components.Input, assert *components.Assert, keys config.KeybindingConfig) {
+ input.SwitchToBranchesWindow()
+ assert.CurrentViewName("localBranches")
+
+ input.PressKeys(keys.Branches.CheckoutBranchByName)
+ assert.CurrentViewName("confirmation")
+
+ input.Type("branch-to")
+
+ input.PressKeys(keys.Universal.TogglePanel)
+ assert.CurrentViewName("suggestions")
+
+ // we expect the first suggestion to be the branch we want because it most
+ // closely matches what we typed in
+ input.Confirm()
+
+ assert.CurrentBranchName("branch-to-checkout")
+ },
+})
diff --git a/pkg/integration/tests/commit/commit.go b/pkg/integration/tests/commit/commit.go
new file mode 100644
index 000000000..12a68925d
--- /dev/null
+++ b/pkg/integration/tests/commit/commit.go
@@ -0,0 +1,32 @@
+package commit
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var Commit = components.NewIntegrationTest(components.NewIntegrationTestArgs{
+ Description: "Staging a couple files and committing",
+ ExtraCmdArgs: "",
+ Skip: false,
+ SetupConfig: func(config *config.AppConfig) {},
+ SetupRepo: func(shell *components.Shell) {
+ shell.CreateFile("myfile", "myfile content")
+ shell.CreateFile("myfile2", "myfile2 content")
+ },
+ Run: func(shell *components.Shell, input *components.Input, assert *components.Assert, keys config.KeybindingConfig) {
+ assert.CommitCount(0)
+
+ input.Select()
+ input.NextItem()
+ input.Select()
+ input.PressKeys(keys.Files.CommitChanges)
+
+ commitMessage := "my commit message"
+ input.Type(commitMessage)
+ input.Confirm()
+
+ assert.CommitCount(1)
+ assert.HeadCommitMessage(commitMessage)
+ },
+})
diff --git a/pkg/integration/tests/commit/new_branch.go b/pkg/integration/tests/commit/new_branch.go
new file mode 100644
index 000000000..ad96938f5
--- /dev/null
+++ b/pkg/integration/tests/commit/new_branch.go
@@ -0,0 +1,38 @@
+package commit
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var NewBranch = components.NewIntegrationTest(components.NewIntegrationTestArgs{
+ Description: "Creating a new branch from a commit",
+ ExtraCmdArgs: "",
+ Skip: false,
+ SetupConfig: func(config *config.AppConfig) {},
+ SetupRepo: func(shell *components.Shell) {
+ shell.
+ EmptyCommit("commit 1").
+ EmptyCommit("commit 2").
+ EmptyCommit("commit 3")
+ },
+ Run: func(shell *components.Shell, input *components.Input, assert *components.Assert, keys config.KeybindingConfig) {
+ assert.CommitCount(3)
+
+ input.SwitchToCommitsWindow()
+ assert.CurrentViewName("commits")
+ input.NextItem()
+
+ input.PressKeys(keys.Universal.New)
+
+ assert.CurrentViewName("confirmation")
+
+ branchName := "my-branch-name"
+ input.Type(branchName)
+ input.Confirm()
+
+ assert.CommitCount(2)
+ assert.HeadCommitMessage("commit 2")
+ assert.CurrentBranchName(branchName)
+ },
+})
diff --git a/pkg/integration/tests/interactive_rebase/one.go b/pkg/integration/tests/interactive_rebase/one.go
new file mode 100644
index 000000000..3c785a727
--- /dev/null
+++ b/pkg/integration/tests/interactive_rebase/one.go
@@ -0,0 +1,41 @@
+package interactive_rebase
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var One = components.NewIntegrationTest(components.NewIntegrationTestArgs{
+ Description: "Begins an interactive rebase, then fixups, drops, and squashes some commits",
+ ExtraCmdArgs: "",
+ Skip: false,
+ SetupConfig: func(config *config.AppConfig) {},
+ SetupRepo: func(shell *components.Shell) {
+ shell.
+ CreateNCommits(5) // these will appears at commit 05, 04, 04, down to 01
+ },
+ Run: func(shell *components.Shell, input *components.Input, assert *components.Assert, keys config.KeybindingConfig) {
+ input.SwitchToCommitsWindow()
+ assert.CurrentViewName("commits")
+
+ input.NavigateToListItemContainingText("commit 02")
+ input.PressKeys(keys.Universal.Edit)
+ assert.SelectedLineContains("YOU ARE HERE")
+
+ input.PreviousItem()
+ input.PressKeys(keys.Commits.MarkCommitAsFixup)
+ assert.SelectedLineContains("fixup")
+
+ input.PreviousItem()
+ input.PressKeys(keys.Universal.Remove)
+ assert.SelectedLineContains("drop")
+
+ input.PreviousItem()
+ input.PressKeys(keys.Commits.SquashDown)
+ assert.SelectedLineContains("squash")
+
+ input.ContinueRebase()
+
+ assert.CommitCount(2)
+ },
+})
diff --git a/pkg/integration/tests/tests.go b/pkg/integration/tests/tests.go
new file mode 100644
index 000000000..e9794169a
--- /dev/null
+++ b/pkg/integration/tests/tests.go
@@ -0,0 +1,18 @@
+package tests
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/integration/components"
+ "github.com/jesseduffield/lazygit/pkg/integration/tests/branch"
+ "github.com/jesseduffield/lazygit/pkg/integration/tests/commit"
+ "github.com/jesseduffield/lazygit/pkg/integration/tests/interactive_rebase"
+)
+
+// Here is where we lists the actual tests that will run. When you create a new test,
+// be sure to add it to this list.
+
+var Tests = []*components.IntegrationTest{
+ commit.Commit,
+ commit.NewBranch,
+ branch.Suggestions,
+ interactive_rebase.One,
+}
diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go
new file mode 100644
index 000000000..543212d59
--- /dev/null
+++ b/pkg/integration/types/types.go
@@ -0,0 +1,31 @@
+package types
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/config"
+ "github.com/jesseduffield/lazygit/pkg/gui/types"
+)
+
+// these interfaces are used by the gui package so that it knows what it needs
+// to provide to a test in order for the test to run.
+
+type IntegrationTest interface {
+ Run(GuiDriver)
+ SetupConfig(config *config.AppConfig)
+}
+
+// this is the interface through which our integration tests interact with the lazygit gui
+type GuiDriver interface {
+ PressKey(string)
+ Keys() config.KeybindingConfig
+ CurrentContext() types.Context
+ Model() *types.Model
+ Fail(message string)
+ // These two log methods are for the sake of debugging while testing. There's no need to actually
+ // commit any logging.
+ // logs to the normal place that you log to i.e. viewable with `lazygit --logs`
+ Log(message string)
+ // logs in the actual UI (in the commands panel)
+ LogUI(message string)
+ CheckedOutRef() *models.Branch
+}
diff --git a/pkg/logs/logs.go b/pkg/logs/logs.go
new file mode 100644
index 000000000..a4fe94031
--- /dev/null
+++ b/pkg/logs/logs.go
@@ -0,0 +1,34 @@
+package logs
+
+import (
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/aybabtme/humanlog"
+ "github.com/jesseduffield/lazygit/pkg/config"
+)
+
+// TailLogs lets us run `lazygit --logs` to print the logs produced by other lazygit processes.
+// This makes for easier debugging.
+func TailLogs() {
+ logFilePath, err := config.LogPath()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("Tailing log file %s\n\n", logFilePath)
+
+ opts := humanlog.DefaultOptions
+ opts.Truncates = false
+
+ _, err = os.Stat(logFilePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ log.Fatal("Log file does not exist. Run `lazygit --debug` first to create the log file")
+ }
+ log.Fatal(err)
+ }
+
+ TailLogsForPlatform(logFilePath, opts)
+}
diff --git a/pkg/logs/logs_default.go b/pkg/logs/logs_default.go
new file mode 100644
index 000000000..b4474720c
--- /dev/null
+++ b/pkg/logs/logs_default.go
@@ -0,0 +1,31 @@
+//go:build !windows
+// +build !windows
+
+package logs
+
+import (
+ "log"
+ "os"
+
+ "github.com/aybabtme/humanlog"
+ "github.com/jesseduffield/lazygit/pkg/secureexec"
+)
+
+func TailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) {
+ cmd := secureexec.Command("tail", "-f", logFilePath)
+
+ stdout, _ := cmd.StdoutPipe()
+ if err := cmd.Start(); err != nil {
+ log.Fatal(err)
+ }
+
+ if err := humanlog.Scanner(stdout, os.Stdout, opts); err != nil {
+ log.Fatal(err)
+ }
+
+ if err := cmd.Wait(); err != nil {
+ log.Fatal(err)
+ }
+
+ os.Exit(0)
+}
diff --git a/pkg/logs/logs_windows.go b/pkg/logs/logs_windows.go
new file mode 100644
index 000000000..7fa17db26
--- /dev/null
+++ b/pkg/logs/logs_windows.go
@@ -0,0 +1,73 @@
+//go:build windows
+// +build windows
+
+package logs
+
+import (
+ "bufio"
+ "log"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/aybabtme/humanlog"
+)
+
+func TailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) {
+ var lastModified int64 = 0
+ var lastOffset int64 = 0
+ for {
+ stat, err := os.Stat(logFilePath)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if stat.ModTime().Unix() > lastModified {
+ err = TailFrom(lastOffset, logFilePath, opts)
+ if err != nil {
+ log.Fatal(err)
+ }
+ }
+ lastOffset = stat.Size()
+ time.Sleep(1 * time.Second)
+ }
+}
+
+func OpenAndSeek(filepath string, offset int64) (*os.File, error) {
+ file, err := os.Open(filepath)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = file.Seek(offset, 0)
+ if err != nil {
+ _ = file.Close()
+ return nil, err
+ }
+ return file, nil
+}
+
+func TailFrom(lastOffset int64, logFilePath string, opts *humanlog.HandlerOptions) error {
+ file, err := OpenAndSeek(logFilePath, lastOffset)
+ if err != nil {
+ return err
+ }
+
+ fileScanner := bufio.NewScanner(file)
+ var lines []string
+ for fileScanner.Scan() {
+ lines = append(lines, fileScanner.Text())
+ }
+ file.Close()
+ lineCount := len(lines)
+ lastTen := lines
+ if lineCount > 10 {
+ lastTen = lines[lineCount-10:]
+ }
+ for _, line := range lastTen {
+ reader := strings.NewReader(line)
+ if err := humanlog.Scanner(reader, os.Stdout, opts); err != nil {
+ log.Fatal(err)
+ }
+ }
+ return nil
+}
diff --git a/pkg/tasks/async_handler.go b/pkg/tasks/async_handler.go
index 897efb0e2..c277a1184 100644
--- a/pkg/tasks/async_handler.go
+++ b/pkg/tasks/async_handler.go
@@ -1,9 +1,8 @@
package tasks
import (
- "sync"
-
"github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/sasha-s/go-deadlock"
)
// the purpose of an AsyncHandler is to ensure that if we have multiple long-running
@@ -17,13 +16,13 @@ import (
type AsyncHandler struct {
currentId int
lastId int
- mutex sync.Mutex
+ mutex deadlock.Mutex
onReject func()
}
func NewAsyncHandler() *AsyncHandler {
return &AsyncHandler{
- mutex: sync.Mutex{},
+ mutex: deadlock.Mutex{},
}
}
diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go
index 4a987039c..448857fca 100644
--- a/pkg/tasks/tasks.go
+++ b/pkg/tasks/tasks.go
@@ -11,9 +11,18 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
)
+// This file revolves around running commands that will be output to the main panel
+// in the gui. If we're flicking through the commits panel, we want to invoke a
+// `git show` command for each commit, but we don't want to read the entire output
+// at once (because that would slow things down); we just want to fill the panel
+// and then read more as the user scrolls down. We also want to ensure that we're only
+// ever running one `git show` command at time, and that we only have one command
+// writing its output to the main panel at a time.
+
const THROTTLE_TIME = time.Millisecond * 30
// we use this to check if the system is under stress right now. Hopefully this makes sense on other machines
@@ -26,11 +35,10 @@ type ViewBufferManager struct {
// this is what we write the output of the task to. It's typically a view
writer io.Writer
- // this is for when we wait to get
- waitingMutex sync.Mutex
- taskIDMutex sync.Mutex
+ waitingMutex deadlock.Mutex
+ taskIDMutex deadlock.Mutex
Log *logrus.Entry
- newTaskId int
+ newTaskID int
readLines chan int
taskKey string
onNewKey func()
@@ -70,14 +78,14 @@ func NewViewBufferManager(
}
}
-func (m *ViewBufferManager) ReadLines(n int) {
+func (self *ViewBufferManager) ReadLines(n int) {
go utils.Safe(func() {
- m.readLines <- n
+ self.readLines <- n
})
}
// note: onDone may be called twice
-func (m *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), prefix string, linesToRead int, onDone func()) func(chan struct{}) error {
+func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), prefix string, linesToRead int, onDone func()) func(chan struct{}) error {
return func(stop chan struct{}) error {
var once sync.Once
var onDoneWrapper func()
@@ -85,8 +93,8 @@ func (m *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), pref
onDoneWrapper = func() { once.Do(onDone) }
}
- if m.throttle {
- m.Log.Info("throttling task")
+ if self.throttle {
+ self.Log.Info("throttling task")
time.Sleep(THROTTLE_TIME)
}
@@ -106,10 +114,10 @@ func (m *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), pref
// are running slow at the moment. This is admittedly a crude estimate, but
// the point is that we only want to throttle when things are running slow
// and the user is flicking through a bunch of items.
- m.throttle = time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD
+ self.throttle = time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD
if err := oscommands.Kill(cmd); err != nil {
if !strings.Contains(err.Error(), "process already finished") {
- m.Log.Errorf("error when running cmd task: %v", err)
+ self.Log.Errorf("error when running cmd task: %v", err)
}
}
@@ -119,10 +127,10 @@ func (m *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), pref
}
})
- loadingMutex := sync.Mutex{}
+ loadingMutex := deadlock.Mutex{}
// not sure if it's the right move to redefine this or not
- m.readLines = make(chan int, 1024)
+ self.readLines = make(chan int, 1024)
done := make(chan struct{})
@@ -140,9 +148,9 @@ func (m *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), pref
case <-ticker.C:
loadingMutex.Lock()
if !loaded {
- m.beforeStart()
- _, _ = m.writer.Write([]byte("loading..."))
- m.refreshView()
+ self.beforeStart()
+ _, _ = self.writer.Write([]byte("loading..."))
+ self.refreshView()
}
loadingMutex.Unlock()
}
@@ -154,7 +162,7 @@ func (m *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), pref
select {
case <-stop:
break outer
- case linesToRead := <-m.readLines:
+ case linesToRead := <-self.readLines:
for i := 0; i < linesToRead; i++ {
select {
case <-stop:
@@ -165,9 +173,9 @@ func (m *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), pref
ok := scanner.Scan()
loadingMutex.Lock()
if !loaded {
- m.beforeStart()
+ self.beforeStart()
if prefix != "" {
- _, _ = m.writer.Write([]byte(prefix))
+ _, _ = self.writer.Write([]byte(prefix))
}
loaded = true
}
@@ -176,21 +184,21 @@ func (m *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), pref
if !ok {
// if we're here then there's nothing left to scan from the source
// so we're at the EOF and can flush the stale content
- m.onEndOfInput()
+ self.onEndOfInput()
break outer
}
- _, _ = m.writer.Write(append(scanner.Bytes(), '\n'))
+ _, _ = self.writer.Write(append(scanner.Bytes(), '\n'))
}
- m.refreshView()
+ self.refreshView()
}
}
- m.refreshView()
+ self.refreshView()
if err := cmd.Wait(); err != nil {
// it's fine if we've killed this program ourselves
if !strings.Contains(err.Error(), "signal: killed") {
- m.Log.Error(err)
+ self.Log.Errorf("Unexpected error when running cmd task: %v", err)
}
}
@@ -202,7 +210,7 @@ func (m *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), pref
close(done)
})
- m.readLines <- linesToRead
+ self.readLines <- linesToRead
<-done
@@ -211,15 +219,15 @@ func (m *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), pref
}
// Close closes the task manager, killing whatever task may currently be running
-func (t *ViewBufferManager) Close() {
- if t.stopCurrentTask == nil {
+func (self *ViewBufferManager) Close() {
+ if self.stopCurrentTask == nil {
return
}
c := make(chan struct{})
go utils.Safe(func() {
- t.stopCurrentTask()
+ self.stopCurrentTask()
c <- struct{}{}
})
@@ -235,28 +243,28 @@ func (t *ViewBufferManager) Close() {
// 1) command based, where the manager can be asked to read more lines, but the command can be killed
// 2) string based, where the manager can also be asked to read more lines
-func (m *ViewBufferManager) NewTask(f func(stop chan struct{}) error, key string) error {
+func (self *ViewBufferManager) NewTask(f func(stop chan struct{}) error, key string) error {
go utils.Safe(func() {
- m.taskIDMutex.Lock()
- m.newTaskId++
- taskID := m.newTaskId
+ self.taskIDMutex.Lock()
+ self.newTaskID++
+ taskID := self.newTaskID
- if m.GetTaskKey() != key && m.onNewKey != nil {
- m.onNewKey()
+ if self.GetTaskKey() != key && self.onNewKey != nil {
+ self.onNewKey()
}
- m.taskKey = key
+ self.taskKey = key
- m.taskIDMutex.Unlock()
+ self.taskIDMutex.Unlock()
- m.waitingMutex.Lock()
- defer m.waitingMutex.Unlock()
+ self.waitingMutex.Lock()
+ defer self.waitingMutex.Unlock()
- if taskID < m.newTaskId {
+ if taskID < self.newTaskID {
return
}
- if m.stopCurrentTask != nil {
- m.stopCurrentTask()
+ if self.stopCurrentTask != nil {
+ self.stopCurrentTask()
}
stop := make(chan struct{})
@@ -268,11 +276,11 @@ func (m *ViewBufferManager) NewTask(f func(stop chan struct{}) error, key string
<-notifyStopped
}
- m.stopCurrentTask = func() { once.Do(onStop) }
+ self.stopCurrentTask = func() { once.Do(onStop) }
go utils.Safe(func() {
if err := f(stop); err != nil {
- m.Log.Error(err) // might need an onError callback
+ self.Log.Error(err) // might need an onError callback
}
close(notifyStopped)
diff --git a/pkg/tasks/tasks_test.go b/pkg/tasks/tasks_test.go
index d580c95f5..9bd552162 100644
--- a/pkg/tasks/tasks_test.go
+++ b/pkg/tasks/tasks_test.go
@@ -69,7 +69,7 @@ func TestNewCmdTaskInstantStop(t *testing.T) {
expectedContent := ""
actualContent := writer.String()
if actualContent != expectedContent {
- t.Errorf("expected writer to receive the following content: \n%s\n. But instead it recevied: %s", expectedContent, actualContent)
+ t.Errorf("expected writer to receive the following content: \n%s\n. But instead it received: %s", expectedContent, actualContent)
}
}
@@ -131,6 +131,6 @@ func TestNewCmdTask(t *testing.T) {
expectedContent := "prefix\ntest\n"
actualContent := writer.String()
if actualContent != expectedContent {
- t.Errorf("expected writer to receive the following content: \n%s\n. But instead it recevied: %s", expectedContent, actualContent)
+ t.Errorf("expected writer to receive the following content: \n%s\n. But instead it received: %s", expectedContent, actualContent)
}
}
diff --git a/pkg/test/log.go b/pkg/test/log.go
index 3b166bb5d..32d79b987 100644
--- a/pkg/test/log.go
+++ b/pkg/test/log.go
@@ -8,9 +8,7 @@ import (
"github.com/stretchr/testify/assert"
)
-var (
- _ logrus.FieldLogger = &FakeFieldLogger{}
-)
+var _ logrus.FieldLogger = &FakeFieldLogger{}
// for now we're just tracking calls to the Error and Errorf methods
type FakeFieldLogger struct {
@@ -37,5 +35,6 @@ func (self *FakeFieldLogger) Errorf(format string, args ...interface{}) {
}
func (self *FakeFieldLogger) AssertErrors(t *testing.T, expectedErrors []string) {
+ t.Helper()
assert.EqualValues(t, expectedErrors, self.loggedErrors)
}
diff --git a/pkg/test/test.go b/pkg/test/test.go
deleted file mode 100644
index da476b95c..000000000
--- a/pkg/test/test.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package test
-
-import (
- "os"
- "path/filepath"
-
- "github.com/go-errors/errors"
-
- "github.com/jesseduffield/lazygit/pkg/secureexec"
- "github.com/jesseduffield/lazygit/pkg/utils"
-)
-
-// GenerateRepo generates a repo from test/repos and changes the directory to be
-// inside the newly made repo
-func GenerateRepo(filename string) error {
- reposDir := "/test/repos/"
- testPath := utils.GetProjectRoot() + reposDir
-
- // workaround for debian packaging
- if _, err := os.Stat(testPath); os.IsNotExist(err) {
- cwd, _ := os.Getwd()
- testPath = filepath.Dir(filepath.Dir(cwd)) + reposDir
- }
- if err := os.Chdir(testPath); err != nil {
- return err
- }
- if output, err := secureexec.Command("bash", filename).CombinedOutput(); err != nil {
- return errors.New(string(output))
- }
-
- return os.Chdir(testPath + "repo")
-}
diff --git a/pkg/test/utils.go b/pkg/test/utils.go
deleted file mode 100644
index 47f2c1146..000000000
--- a/pkg/test/utils.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package test
-
-import (
- "fmt"
- "os/exec"
- "regexp"
- "strings"
- "testing"
-
- "github.com/jesseduffield/lazygit/pkg/secureexec"
- "github.com/mgutz/str"
- "github.com/stretchr/testify/assert"
-)
-
-// CommandSwapper takes a command, verifies that it is what it's expected to be
-// and then returns a replacement command that will actually be called by the os
-type CommandSwapper struct {
- Expect string
- Replace string
-}
-
-// SwapCommand verifies the command is what we expected, and swaps it out for a different command
-func (i *CommandSwapper) SwapCommand(t *testing.T, cmd string, args []string) *exec.Cmd {
- splitCmd := str.ToArgv(i.Expect)
- assert.EqualValues(t, splitCmd[0], cmd, fmt.Sprintf("received command: %s %s", cmd, strings.Join(args, " ")))
- if len(splitCmd) > 1 {
- assert.EqualValues(t, splitCmd[1:], args, fmt.Sprintf("received command: %s %s", cmd, strings.Join(args, " ")))
- }
-
- splitCmd = str.ToArgv(i.Replace)
- return secureexec.Command(splitCmd[0], splitCmd[1:]...)
-}
-
-// CreateMockCommand creates a command function that will verify its receiving the right sequence of commands from lazygit
-func CreateMockCommand(t *testing.T, swappers []*CommandSwapper) func(cmd string, args ...string) *exec.Cmd {
- commandIndex := 0
-
- return func(cmd string, args ...string) *exec.Cmd {
- var command *exec.Cmd
- if commandIndex > len(swappers)-1 {
- assert.Fail(t, fmt.Sprintf("too many commands run. This command was (%s %s)", cmd, strings.Join(args, " ")))
- }
- command = swappers[commandIndex].SwapCommand(t, cmd, args)
- commandIndex++
- return command
- }
-}
-
-func AssertContainsMatch(t *testing.T, strs []string, pattern *regexp.Regexp, message string) {
- t.Helper()
-
- for _, str := range strs {
- if pattern.Match([]byte(str)) {
- return
- }
- }
-
- assert.Fail(t, message)
-}
diff --git a/pkg/theme/theme.go b/pkg/theme/theme.go
index c3e12fdcc..39347702a 100644
--- a/pkg/theme/theme.go
+++ b/pkg/theme/theme.go
@@ -39,6 +39,8 @@ var (
OptionsFgColor = style.New()
DiffTerminalColor = style.FgMagenta
+
+ UnstagedChangesColor = style.New()
)
// UpdateTheme updates all theme variables
@@ -52,6 +54,9 @@ func UpdateTheme(themeConfig config.ThemeConfig) {
cherryPickedCommitFgTextStyle := GetTextStyle(themeConfig.CherryPickedCommitFgColor, false)
CherryPickedCommitTextStyle = cherryPickedCommitBgTextStyle.MergeStyle(cherryPickedCommitFgTextStyle)
+ unstagedChangesTextStyle := GetTextStyle(themeConfig.UnstagedChangesColor, false)
+ UnstagedChangesColor = unstagedChangesTextStyle
+
GocuiSelectedLineBgColor = GetGocuiStyle(themeConfig.SelectedLineBgColor)
OptionsColor = GetGocuiStyle(themeConfig.OptionsTextColor)
OptionsFgColor = GetTextStyle(themeConfig.OptionsTextColor, false)
diff --git a/pkg/updates/updates.go b/pkg/updates/updates.go
index 1c52d0419..58c93fa7d 100644
--- a/pkg/updates/updates.go
+++ b/pkg/updates/updates.go
@@ -144,12 +144,10 @@ func (u *Updater) CheckForNewUpdate(onFinish func(string, error) error, userRequ
return
}
- go utils.Safe(func() {
- newVersion, err := u.checkForNewUpdate()
- if err = onFinish(newVersion, err); err != nil {
- u.Log.Error(err)
- }
- })
+ newVersion, err := u.checkForNewUpdate()
+ if err = onFinish(newVersion, err); err != nil {
+ u.Log.Error(err)
+ }
}
func (u *Updater) skipUpdateCheck() bool {
@@ -331,7 +329,6 @@ func (u *Updater) verifyResourceFound(rawUrl string) bool {
}
defer resp.Body.Close()
u.Log.Info("Received status code ", resp.StatusCode)
- // 403 means the resource is there (not going to bother adding extra request headers)
- // 404 means its not
- return resp.StatusCode == 403
+ // OK (200) indicates that the resource is present.
+ return resp.StatusCode == http.StatusOK
}
diff --git a/pkg/utils/color.go b/pkg/utils/color.go
index 37c60179a..a4ad578e0 100644
--- a/pkg/utils/color.go
+++ b/pkg/utils/color.go
@@ -6,10 +6,13 @@ import (
"github.com/gookit/color"
"github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/samber/lo"
)
-var decoloriseCache = make(map[string]string)
-var decoloriseMutex sync.RWMutex
+var (
+ decoloriseCache = make(map[string]string)
+ decoloriseMutex sync.RWMutex
+)
// Decolorise strips a string of color
func Decolorise(str string) string {
@@ -53,10 +56,10 @@ func IsValidHexValue(v string) bool {
}
func SetCustomColors(customColors map[string]string) map[string]style.TextStyle {
- colors := make(map[string]style.TextStyle)
- for key, colorSequence := range customColors {
- style := style.New().SetFg(style.NewRGBColor(color.HEX(colorSequence, false)))
- colors[key] = style
- }
- return colors
+ return lo.MapValues(customColors, func(c string, key string) style.TextStyle {
+ if s, ok := style.ColorMap[c]; ok {
+ return s.Foreground
+ }
+ return style.New().SetFg(style.NewRGBColor(color.HEX(c, false)))
+ })
}
diff --git a/pkg/utils/color_test.go b/pkg/utils/color_test.go
index 37144e955..1440f946c 100644
--- a/pkg/utils/color_test.go
+++ b/pkg/utils/color_test.go
@@ -5,7 +5,7 @@ import (
)
func TestDecolorise(t *testing.T) {
- var tests = []struct {
+ tests := []struct {
input string
output string
}{
diff --git a/pkg/utils/date.go b/pkg/utils/date.go
index 40165ecaa..2f9812b81 100644
--- a/pkg/utils/date.go
+++ b/pkg/utils/date.go
@@ -20,6 +20,6 @@ func UnixToTimeAgo(timestamp int64) string {
return fmt.Sprintf("%dy", int(delta))
}
-func UnixToDate(timestamp int64) string {
- return time.Unix(timestamp, 0).Format(time.RFC822)
+func UnixToDate(timestamp int64, timeFormat string) string {
+ return time.Unix(timestamp, 0).Format(timeFormat)
}
diff --git a/pkg/utils/formatting.go b/pkg/utils/formatting.go
index d33028063..657d1d2eb 100644
--- a/pkg/utils/formatting.go
+++ b/pkg/utils/formatting.go
@@ -3,7 +3,9 @@ package utils
import (
"strings"
+ "github.com/jesseduffield/generics/slices"
"github.com/mattn/go-runewidth"
+ "github.com/samber/lo"
)
// WithPadding pads a string as much as you want
@@ -83,27 +85,20 @@ func getPaddedDisplayStrings(stringArrays [][]string, padWidths []int) string {
}
func getPadWidths(stringArrays [][]string) []int {
- maxWidth := 0
- for _, stringArray := range stringArrays {
- if len(stringArray) > maxWidth {
- maxWidth = len(stringArray)
- }
- }
+ maxWidth := slices.MaxBy(stringArrays, func(stringArray []string) int {
+ return len(stringArray)
+ })
+
if maxWidth-1 < 0 {
return []int{}
}
- padWidths := make([]int, maxWidth-1)
- for i := range padWidths {
- for _, strings := range stringArrays {
- uncoloredStr := Decolorise(strings[i])
+ return slices.Map(lo.Range(maxWidth-1), func(i int) int {
+ return slices.MaxBy(stringArrays, func(stringArray []string) int {
+ uncoloredStr := Decolorise(stringArray[i])
- width := runewidth.StringWidth(uncoloredStr)
- if width > padWidths[i] {
- padWidths[i] = width
- }
- }
- }
- return padWidths
+ return runewidth.StringWidth(uncoloredStr)
+ })
+ })
}
// TruncateWithEllipsis returns a string, truncated to a certain length, with an ellipsis
diff --git a/pkg/utils/fuzzy_search.go b/pkg/utils/fuzzy_search.go
index 4199d6c8b..5fce3dde9 100644
--- a/pkg/utils/fuzzy_search.go
+++ b/pkg/utils/fuzzy_search.go
@@ -3,6 +3,7 @@ package utils
import (
"sort"
+ "github.com/jesseduffield/generics/slices"
"github.com/sahilm/fuzzy"
)
@@ -14,10 +15,7 @@ func FuzzySearch(needle string, haystack []string) []string {
matches := fuzzy.Find(needle, haystack)
sort.Sort(matches)
- result := make([]string, len(matches))
- for i, match := range matches {
- result[i] = match.Str
- }
-
- return result
+ return slices.Map(matches, func(match fuzzy.Match) string {
+ return match.Str
+ })
}
diff --git a/pkg/utils/lines.go b/pkg/utils/lines.go
index 9aea84bff..47d33e939 100644
--- a/pkg/utils/lines.go
+++ b/pkg/utils/lines.go
@@ -17,15 +17,6 @@ func SplitLines(multilineString string) []string {
return lines
}
-// TrimTrailingNewline - Trims the trailing newline
-// TODO: replace with `chomp` after refactor
-func TrimTrailingNewline(str string) string {
- if strings.HasSuffix(str, "\n") {
- return str[:len(str)-1]
- }
- return str
-}
-
// NormalizeLinefeeds - Removes all Windows and Mac style line feeds
func NormalizeLinefeeds(str string) string {
str = strings.Replace(str, "\r\n", "\n", -1)
diff --git a/pkg/utils/lines_test.go b/pkg/utils/lines_test.go
index 6069b8f93..361f0a510 100644
--- a/pkg/utils/lines_test.go
+++ b/pkg/utils/lines_test.go
@@ -36,36 +36,13 @@ func TestSplitLines(t *testing.T) {
}
}
-// TestTrimTrailingNewline is a function.
-func TestTrimTrailingNewline(t *testing.T) {
- type scenario struct {
- str string
- expected string
- }
-
- scenarios := []scenario{
- {
- "hello world !\n",
- "hello world !",
- },
- {
- "hello world !",
- "hello world !",
- },
- }
-
- for _, s := range scenarios {
- assert.EqualValues(t, s.expected, TrimTrailingNewline(s.str))
- }
-}
-
// TestNormalizeLinefeeds is a function.
func TestNormalizeLinefeeds(t *testing.T) {
type scenario struct {
byteArray []byte
expected []byte
}
- var scenarios = []scenario{
+ scenarios := []scenario{
{
// \r\n
[]byte{97, 115, 100, 102, 13, 10},
diff --git a/pkg/utils/once_writer.go b/pkg/utils/once_writer.go
new file mode 100644
index 000000000..aecf20369
--- /dev/null
+++ b/pkg/utils/once_writer.go
@@ -0,0 +1,31 @@
+package utils
+
+import (
+ "io"
+ "sync"
+)
+
+// This wraps a writer and ensures that before we actually write anything we call a given function first
+
+type OnceWriter struct {
+ writer io.Writer
+ once sync.Once
+ f func()
+}
+
+var _ io.Writer = &OnceWriter{}
+
+func NewOnceWriter(writer io.Writer, f func()) *OnceWriter {
+ return &OnceWriter{
+ writer: writer,
+ f: f,
+ }
+}
+
+func (self *OnceWriter) Write(p []byte) (n int, err error) {
+ self.once.Do(func() {
+ self.f()
+ })
+
+ return self.writer.Write(p)
+}
diff --git a/pkg/utils/once_writer_test.go b/pkg/utils/once_writer_test.go
new file mode 100644
index 000000000..47f64bb61
--- /dev/null
+++ b/pkg/utils/once_writer_test.go
@@ -0,0 +1,19 @@
+package utils
+
+import (
+ "bytes"
+ "testing"
+)
+
+func TestOnceWriter(t *testing.T) {
+ innerWriter := bytes.NewBuffer(nil)
+ counter := 0
+ onceWriter := NewOnceWriter(innerWriter, func() {
+ counter += 1
+ })
+ _, _ = onceWriter.Write([]byte("hello"))
+ _, _ = onceWriter.Write([]byte("hello"))
+ if counter != 1 {
+ t.Errorf("expected counter to be 1, got %d", counter)
+ }
+}
diff --git a/pkg/utils/slice.go b/pkg/utils/slice.go
index 123fc7df9..2281d8a73 100644
--- a/pkg/utils/slice.go
+++ b/pkg/utils/slice.go
@@ -1,29 +1,5 @@
package utils
-// IncludesString if the list contains the string
-func IncludesString(list []string, a string) bool {
- return IncludesStringFunc(list, func(b string) bool { return b == a })
-}
-
-func IncludesStringFunc(list []string, fn func(string) bool) bool {
- for _, b := range list {
- if fn(b) {
- return true
- }
- }
- return false
-}
-
-// IncludesInt if the list contains the Int
-func IncludesInt(list []int, a int) bool {
- for _, b := range list {
- if b == a {
- return true
- }
- }
- return false
-}
-
// NextIndex returns the index of the element that comes after the given number
func NextIndex(numbers []int, currentNumber int) int {
for index, number := range numbers {
@@ -45,44 +21,6 @@ func PrevIndex(numbers []int, currentNumber int) int {
return 0
}
-// UnionInt returns the union of two int arrays
-func UnionInt(a, b []int) []int {
- m := make(map[int]bool)
-
- for _, item := range a {
- m[item] = true
- }
-
- for _, item := range b {
- if _, ok := m[item]; !ok {
- // this does not mutate the original a slice
- // though it does mutate the backing array I believe
- // but that doesn't matter because if you later want to append to the
- // original a it must see that the backing array has been changed
- // and create a new one
- a = append(a, item)
- }
- }
- return a
-}
-
-// DifferenceInt returns the difference of two int arrays
-func DifferenceInt(a, b []int) []int {
- result := []int{}
- m := make(map[int]bool)
-
- for _, item := range b {
- m[item] = true
- }
-
- for _, item := range a {
- if _, ok := m[item]; !ok {
- result = append(result, item)
- }
- }
- return result
-}
-
// NextIntInCycle returns the next int in a slice, returning to the first index if we've reached the end
func NextIntInCycle(sl []int, current int) int {
for i, val := range sl {
@@ -121,19 +59,6 @@ func StringArraysOverlap(strArrA []string, strArrB []string) bool {
return false
}
-func Uniq(values []string) []string {
- added := make(map[string]bool)
- result := make([]string, 0, len(values))
- for _, value := range values {
- if added[value] {
- continue
- }
- added[value] = true
- result = append(result, value)
- }
- return result
-}
-
func Limit(values []string, limit int) []string {
if len(values) > limit {
return values[:limit]
@@ -141,14 +66,6 @@ func Limit(values []string, limit int) []string {
return values
}
-func Reverse(values []string) []string {
- result := make([]string, len(values))
- for i, val := range values {
- result[len(values)-i-1] = val
- }
- return result
-}
-
func LimitStr(value string, limit int) string {
n := 0
for i := range value {
@@ -159,3 +76,19 @@ func LimitStr(value string, limit int) string {
}
return value
}
+
+// Similar to a regular GroupBy, except that each item can be grouped under multiple keys,
+// so the callback returns a slice of keys instead of just one key.
+func MuiltiGroupBy[T any, K comparable](slice []T, f func(T) []K) map[K][]T {
+ result := map[K][]T{}
+ for _, item := range slice {
+ for _, key := range f(item) {
+ if _, ok := result[key]; !ok {
+ result[key] = []T{item}
+ } else {
+ result[key] = append(result[key], item)
+ }
+ }
+ }
+ return result
+}
diff --git a/pkg/utils/slice_test.go b/pkg/utils/slice_test.go
index fc80a46d7..e66edcd61 100644
--- a/pkg/utils/slice_test.go
+++ b/pkg/utils/slice_test.go
@@ -6,42 +6,6 @@ import (
"github.com/stretchr/testify/assert"
)
-// TestIncludesString is a function.
-func TestIncludesString(t *testing.T) {
- type scenario struct {
- list []string
- element string
- expected bool
- }
-
- scenarios := []scenario{
- {
- []string{"a", "b"},
- "a",
- true,
- },
- {
- []string{"a", "b"},
- "c",
- false,
- },
- {
- []string{"a", "b"},
- "",
- false,
- },
- {
- []string{""},
- "",
- true,
- },
- }
-
- for _, s := range scenarios {
- assert.EqualValues(t, s.expected, IncludesString(s.list, s.element))
- }
-}
-
func TestNextIndex(t *testing.T) {
type scenario struct {
testName string
@@ -169,26 +133,6 @@ func TestEscapeSpecialChars(t *testing.T) {
}
}
-func TestUniq(t *testing.T) {
- for _, test := range []struct {
- values []string
- want []string
- }{
- {
- values: []string{"a", "b", "c"},
- want: []string{"a", "b", "c"},
- },
- {
- values: []string{"a", "b", "a", "b", "c"},
- want: []string{"a", "b", "c"},
- },
- } {
- if got := Uniq(test.values); !assert.EqualValues(t, got, test.want) {
- t.Errorf("Uniq(%v) = %v; want %v", test.values, got, test.want)
- }
- }
-}
-
func TestLimit(t *testing.T) {
for _, test := range []struct {
values []string
@@ -232,26 +176,6 @@ func TestLimit(t *testing.T) {
}
}
-func TestReverse(t *testing.T) {
- for _, test := range []struct {
- values []string
- want []string
- }{
- {
- values: []string{"a", "b", "c"},
- want: []string{"c", "b", "a"},
- },
- {
- values: []string{},
- want: []string{},
- },
- } {
- if got := Reverse(test.values); !assert.EqualValues(t, got, test.want) {
- t.Errorf("Reverse(%v) = %v; want %v", test.values, got, test.want)
- }
- }
-}
-
func TestLimitStr(t *testing.T) {
for _, test := range []struct {
values string
diff --git a/pkg/utils/string_stack.go b/pkg/utils/string_stack.go
new file mode 100644
index 000000000..c2d18c70c
--- /dev/null
+++ b/pkg/utils/string_stack.go
@@ -0,0 +1,27 @@
+package utils
+
+type StringStack struct {
+ stack []string
+}
+
+func (self *StringStack) Push(s string) {
+ self.stack = append(self.stack, s)
+}
+
+func (self *StringStack) Pop() string {
+ if len(self.stack) == 0 {
+ return ""
+ }
+ n := len(self.stack) - 1
+ last := self.stack[n]
+ self.stack = self.stack[:n]
+ return last
+}
+
+func (self *StringStack) IsEmpty() bool {
+ return len(self.stack) == 0
+}
+
+func (self *StringStack) Clear() {
+ self.stack = []string{}
+}
diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go
index c9d64c30e..9d6213c1d 100644
--- a/pkg/utils/utils.go
+++ b/pkg/utils/utils.go
@@ -59,6 +59,15 @@ func Max(x, y int) int {
return y
}
+func Clamp(x int, min int, max int) int {
+ if x < min {
+ return min
+ } else if x > max {
+ return max
+ }
+ return x
+}
+
func AsJson(i interface{}) string {
bytes, _ := json.MarshalIndent(i, "", " ")
return string(bytes)
@@ -66,6 +75,10 @@ func AsJson(i interface{}) string {
// used to keep a number n between 0 and max, allowing for wraparounds
func ModuloWithWrap(n, max int) int {
+ if max == 0 {
+ return 0
+ }
+
if n >= max {
return n % max
} else if n < 0 {
@@ -115,3 +128,37 @@ func StackTrace() string {
n := runtime.Stack(buf, false)
return fmt.Sprintf("%s\n", buf[:n])
}
+
+// returns the path of the file that calls the function.
+// 'skip' is the number of stack frames to skip.
+func FilePath(skip int) string {
+ _, path, _, _ := runtime.Caller(skip)
+ return path
+}
+
+// for our cheatsheet script and integration tests. Not to be confused with finding the
+// root directory of _any_ random repo.
+func GetLazygitRootDirectory() string {
+ path, err := os.Getwd()
+ if err != nil {
+ panic(err)
+ }
+
+ for {
+ _, err := os.Stat(filepath.Join(path, ".git"))
+
+ if err == nil {
+ return path
+ }
+
+ if !os.IsNotExist(err) {
+ panic(err)
+ }
+
+ path = filepath.Dir(path)
+
+ if path == "/" {
+ log.Fatal("must run in lazygit folder or child folder")
+ }
+ }
+}
diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go
index 02aded559..4933cf073 100644
--- a/pkg/utils/utils_test.go
+++ b/pkg/utils/utils_test.go
@@ -87,3 +87,45 @@ func TestSafeTruncate(t *testing.T) {
assert.EqualValues(t, s.expected, SafeTruncate(s.str, s.limit))
}
}
+
+func TestModuloWithWrap(t *testing.T) {
+ type scenario struct {
+ n int
+ max int
+ expected int
+ }
+
+ scenarios := []scenario{
+ {
+ n: 0,
+ max: 0,
+ expected: 0,
+ },
+ {
+ n: 0,
+ max: 1,
+ expected: 0,
+ },
+ {
+ n: 1,
+ max: 0,
+ expected: 0,
+ },
+ {
+ n: 3,
+ max: 2,
+ expected: 1,
+ },
+ {
+ n: -1,
+ max: 2,
+ expected: 1,
+ },
+ }
+
+ for _, s := range scenarios {
+ if s.expected != ModuloWithWrap(s.n, s.max) {
+ t.Errorf("expected %d, got %d, for n: %d, max: %d", s.expected, ModuloWithWrap(s.n, s.max), s.n, s.max)
+ }
+ }
+}
diff --git a/scripts/bisect.sh b/scripts/bisect.sh
index 0e5f404cb..a3bc5f19e 100755
--- a/scripts/bisect.sh
+++ b/scripts/bisect.sh
@@ -2,7 +2,7 @@
# How to use:
# 1) find a commit that is working fine.
-# 2) Create an integration test capturing the fact that it works (Don't commit it). See https://github.com/jesseduffield/lazygit/blob/master/docs/Integration_Tests.md
+# 2) Create an integration test capturing the fact that it works (Don't commit it). See https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md
# 3) checkout the commit that's known to be failing
# 4) run this script supplying the commit sha / tag name that works and the name of the newly created test
diff --git a/test.sh b/test.sh
deleted file mode 100755
index 0a8d91e85..000000000
--- a/test.sh
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/usr/bin/env bash
-
-set -e
-echo "" > coverage.txt
-
-export GOFLAGS=-mod=vendor
-
-use_go_test=false
-if command -v gotest; then
- use_go_test=true
-fi
-
-for d in $( find ./* -maxdepth 10 ! -path "./vendor*" ! -path "./.git*" ! -path "./scripts*" -type d); do
- if ls $d/*.go &> /dev/null; then
- args="-race -coverprofile=profile.out -covermode=atomic $d"
- if [ "$use_go_test" == true ]; then
- gotest $args
- else
- go test $args
- fi
- if [ -f profile.out ]; then
- cat profile.out >> coverage.txt
- rm profile.out
- fi
- fi
-done
diff --git a/test/hooks/pre-push b/test/hooks/pre-push
index b7cb2e87b..3b758c1b1 100644
--- a/test/hooks/pre-push
+++ b/test/hooks/pre-push
@@ -14,6 +14,8 @@ echo -n "Username for 'github': "
read username
echo -n "Password for 'github': "
+# this will print the password to the log view but real git won't do that.
+# We could use read -s but that's not POSIX compliant.
read password
if [ "$username" = "username" -a "$password" = "password" ]; then
diff --git a/test/integration/bisect/expected/.git_keep/BISECT_ANCESTORS_OK b/test/integration/bisect/expected/repo/.git_keep/BISECT_ANCESTORS_OK
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/BISECT_ANCESTORS_OK
rename to test/integration/bisect/expected/repo/.git_keep/BISECT_ANCESTORS_OK
diff --git a/test/integration/bisect/expected/.git_keep/BISECT_EXPECTED_REV b/test/integration/bisect/expected/repo/.git_keep/BISECT_EXPECTED_REV
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/BISECT_EXPECTED_REV
rename to test/integration/bisect/expected/repo/.git_keep/BISECT_EXPECTED_REV
diff --git a/test/integration/bisect/expected/.git_keep/BISECT_LOG b/test/integration/bisect/expected/repo/.git_keep/BISECT_LOG
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/BISECT_LOG
rename to test/integration/bisect/expected/repo/.git_keep/BISECT_LOG
diff --git a/test/integration/bisect/expected/.git_keep/BISECT_NAMES b/test/integration/bisect/expected/repo/.git_keep/BISECT_NAMES
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/BISECT_NAMES
rename to test/integration/bisect/expected/repo/.git_keep/BISECT_NAMES
diff --git a/test/integration/bisect/expected/.git_keep/BISECT_START b/test/integration/bisect/expected/repo/.git_keep/BISECT_START
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/BISECT_START
rename to test/integration/bisect/expected/repo/.git_keep/BISECT_START
diff --git a/test/integration/bisect/expected/.git_keep/BISECT_TERMS b/test/integration/bisect/expected/repo/.git_keep/BISECT_TERMS
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/BISECT_TERMS
rename to test/integration/bisect/expected/repo/.git_keep/BISECT_TERMS
diff --git a/test/integration/bisect/expected/.git_keep/COMMIT_EDITMSG b/test/integration/bisect/expected/repo/.git_keep/COMMIT_EDITMSG
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/COMMIT_EDITMSG
rename to test/integration/bisect/expected/repo/.git_keep/COMMIT_EDITMSG
diff --git a/test/integration/bisect/expected/.git_keep/FETCH_HEAD b/test/integration/bisect/expected/repo/.git_keep/FETCH_HEAD
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/FETCH_HEAD
rename to test/integration/bisect/expected/repo/.git_keep/FETCH_HEAD
diff --git a/test/integration/bisect/expected/.git_keep/HEAD b/test/integration/bisect/expected/repo/.git_keep/HEAD
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/HEAD
rename to test/integration/bisect/expected/repo/.git_keep/HEAD
diff --git a/test/integration/bisect/expected/.git_keep/config b/test/integration/bisect/expected/repo/.git_keep/config
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/config
rename to test/integration/bisect/expected/repo/.git_keep/config
diff --git a/test/integration/bisect/expected/.git_keep/description b/test/integration/bisect/expected/repo/.git_keep/description
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/description
rename to test/integration/bisect/expected/repo/.git_keep/description
diff --git a/test/integration/bisect/expected/.git_keep/index b/test/integration/bisect/expected/repo/.git_keep/index
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/index
rename to test/integration/bisect/expected/repo/.git_keep/index
diff --git a/test/integration/bisect/expected/.git_keep/info/exclude b/test/integration/bisect/expected/repo/.git_keep/info/exclude
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/info/exclude
rename to test/integration/bisect/expected/repo/.git_keep/info/exclude
diff --git a/test/integration/bisect/expected/.git_keep/logs/HEAD b/test/integration/bisect/expected/repo/.git_keep/logs/HEAD
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/logs/HEAD
rename to test/integration/bisect/expected/repo/.git_keep/logs/HEAD
diff --git a/test/integration/bisect/expected/.git_keep/logs/refs/heads/master b/test/integration/bisect/expected/repo/.git_keep/logs/refs/heads/master
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/logs/refs/heads/master
rename to test/integration/bisect/expected/repo/.git_keep/logs/refs/heads/master
diff --git a/test/integration/bisect/expected/.git_keep/logs/refs/heads/test b/test/integration/bisect/expected/repo/.git_keep/logs/refs/heads/test
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/logs/refs/heads/test
rename to test/integration/bisect/expected/repo/.git_keep/logs/refs/heads/test
diff --git a/test/integration/bisect/expected/.git_keep/objects/00/5ca78c7fb8157683fa61158235b250d2316004 b/test/integration/bisect/expected/repo/.git_keep/objects/00/5ca78c7fb8157683fa61158235b250d2316004
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/00/5ca78c7fb8157683fa61158235b250d2316004
rename to test/integration/bisect/expected/repo/.git_keep/objects/00/5ca78c7fb8157683fa61158235b250d2316004
diff --git a/test/integration/bisect/expected/.git_keep/objects/00/750edc07d6415dcc07ae0351e9397b0222b7ba b/test/integration/bisect/expected/repo/.git_keep/objects/00/750edc07d6415dcc07ae0351e9397b0222b7ba
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/00/750edc07d6415dcc07ae0351e9397b0222b7ba
rename to test/integration/bisect/expected/repo/.git_keep/objects/00/750edc07d6415dcc07ae0351e9397b0222b7ba
diff --git a/test/integration/bisect/expected/.git_keep/objects/05/4bdf969fdcf1f90f1998666f628d40f72fde4f b/test/integration/bisect/expected/repo/.git_keep/objects/05/4bdf969fdcf1f90f1998666f628d40f72fde4f
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/05/4bdf969fdcf1f90f1998666f628d40f72fde4f
rename to test/integration/bisect/expected/repo/.git_keep/objects/05/4bdf969fdcf1f90f1998666f628d40f72fde4f
diff --git a/test/integration/bisect/expected/.git_keep/objects/07/552205114379b7c1abd7cb39575cb7a30a2e8c b/test/integration/bisect/expected/repo/.git_keep/objects/07/552205114379b7c1abd7cb39575cb7a30a2e8c
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/07/552205114379b7c1abd7cb39575cb7a30a2e8c
rename to test/integration/bisect/expected/repo/.git_keep/objects/07/552205114379b7c1abd7cb39575cb7a30a2e8c
diff --git a/test/integration/bisect/expected/.git_keep/objects/0c/fbf08886fca9a91cb753ec8734c84fcbe52c9f b/test/integration/bisect/expected/repo/.git_keep/objects/0c/fbf08886fca9a91cb753ec8734c84fcbe52c9f
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/0c/fbf08886fca9a91cb753ec8734c84fcbe52c9f
rename to test/integration/bisect/expected/repo/.git_keep/objects/0c/fbf08886fca9a91cb753ec8734c84fcbe52c9f
diff --git a/test/integration/bisect/expected/.git_keep/objects/11/0046b8d92b877def6cda61639cf8f37bc2829c b/test/integration/bisect/expected/repo/.git_keep/objects/11/0046b8d92b877def6cda61639cf8f37bc2829c
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/11/0046b8d92b877def6cda61639cf8f37bc2829c
rename to test/integration/bisect/expected/repo/.git_keep/objects/11/0046b8d92b877def6cda61639cf8f37bc2829c
diff --git a/test/integration/bisect/expected/.git_keep/objects/12/e46e3c37d1a43a26b909a346ecd2d97677c641 b/test/integration/bisect/expected/repo/.git_keep/objects/12/e46e3c37d1a43a26b909a346ecd2d97677c641
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/12/e46e3c37d1a43a26b909a346ecd2d97677c641
rename to test/integration/bisect/expected/repo/.git_keep/objects/12/e46e3c37d1a43a26b909a346ecd2d97677c641
diff --git a/test/integration/bisect/expected/.git_keep/objects/1b/01733c2b372c7b5544c7f2293c3b7341824112 b/test/integration/bisect/expected/repo/.git_keep/objects/1b/01733c2b372c7b5544c7f2293c3b7341824112
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/1b/01733c2b372c7b5544c7f2293c3b7341824112
rename to test/integration/bisect/expected/repo/.git_keep/objects/1b/01733c2b372c7b5544c7f2293c3b7341824112
diff --git a/test/integration/bisect/expected/.git_keep/objects/1e/8b314962144c26d5e0e50fd29d2ca327864913 b/test/integration/bisect/expected/repo/.git_keep/objects/1e/8b314962144c26d5e0e50fd29d2ca327864913
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/1e/8b314962144c26d5e0e50fd29d2ca327864913
rename to test/integration/bisect/expected/repo/.git_keep/objects/1e/8b314962144c26d5e0e50fd29d2ca327864913
diff --git a/test/integration/bisect/expected/.git_keep/objects/20/9e3ef4b6247ce746048d5711befda46206d235 b/test/integration/bisect/expected/repo/.git_keep/objects/20/9e3ef4b6247ce746048d5711befda46206d235
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/20/9e3ef4b6247ce746048d5711befda46206d235
rename to test/integration/bisect/expected/repo/.git_keep/objects/20/9e3ef4b6247ce746048d5711befda46206d235
diff --git a/test/integration/bisect/expected/.git_keep/objects/26/7465454f74736bbe5b493c7f69dd3d024e26e5 b/test/integration/bisect/expected/repo/.git_keep/objects/26/7465454f74736bbe5b493c7f69dd3d024e26e5
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/26/7465454f74736bbe5b493c7f69dd3d024e26e5
rename to test/integration/bisect/expected/repo/.git_keep/objects/26/7465454f74736bbe5b493c7f69dd3d024e26e5
diff --git a/test/integration/bisect/expected/.git_keep/objects/32/e7b0308424a817ed5aa5bba94b06b72a1b8ce5 b/test/integration/bisect/expected/repo/.git_keep/objects/32/e7b0308424a817ed5aa5bba94b06b72a1b8ce5
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/32/e7b0308424a817ed5aa5bba94b06b72a1b8ce5
rename to test/integration/bisect/expected/repo/.git_keep/objects/32/e7b0308424a817ed5aa5bba94b06b72a1b8ce5
diff --git a/test/integration/bisect/expected/.git_keep/objects/39/983ea412adebe6c5a3d4451a7673cf0962c472 b/test/integration/bisect/expected/repo/.git_keep/objects/39/983ea412adebe6c5a3d4451a7673cf0962c472
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/39/983ea412adebe6c5a3d4451a7673cf0962c472
rename to test/integration/bisect/expected/repo/.git_keep/objects/39/983ea412adebe6c5a3d4451a7673cf0962c472
diff --git a/test/integration/bisect/expected/.git_keep/objects/3c/032078a4a21c5c51d3c93d91717c1dabbb8cd0 b/test/integration/bisect/expected/repo/.git_keep/objects/3c/032078a4a21c5c51d3c93d91717c1dabbb8cd0
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/3c/032078a4a21c5c51d3c93d91717c1dabbb8cd0
rename to test/integration/bisect/expected/repo/.git_keep/objects/3c/032078a4a21c5c51d3c93d91717c1dabbb8cd0
diff --git a/test/integration/bisect/expected/.git_keep/objects/3e/02ce90348f3386128ebb2972515fb1a3788818 b/test/integration/bisect/expected/repo/.git_keep/objects/3e/02ce90348f3386128ebb2972515fb1a3788818
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/3e/02ce90348f3386128ebb2972515fb1a3788818
rename to test/integration/bisect/expected/repo/.git_keep/objects/3e/02ce90348f3386128ebb2972515fb1a3788818
diff --git a/test/integration/bisect/expected/.git_keep/objects/3f/f8b0f3820fd2eb3da53a5b803f94caf30dc2ab b/test/integration/bisect/expected/repo/.git_keep/objects/3f/f8b0f3820fd2eb3da53a5b803f94caf30dc2ab
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/3f/f8b0f3820fd2eb3da53a5b803f94caf30dc2ab
rename to test/integration/bisect/expected/repo/.git_keep/objects/3f/f8b0f3820fd2eb3da53a5b803f94caf30dc2ab
diff --git a/test/integration/bisect/expected/.git_keep/objects/43/78c740dfa0de7a973216b54b99c45a3c03f83c b/test/integration/bisect/expected/repo/.git_keep/objects/43/78c740dfa0de7a973216b54b99c45a3c03f83c
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/43/78c740dfa0de7a973216b54b99c45a3c03f83c
rename to test/integration/bisect/expected/repo/.git_keep/objects/43/78c740dfa0de7a973216b54b99c45a3c03f83c
diff --git a/test/integration/bisect/expected/.git_keep/objects/45/a4fb75db864000d01701c0f7a51864bd4daabf b/test/integration/bisect/expected/repo/.git_keep/objects/45/a4fb75db864000d01701c0f7a51864bd4daabf
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/45/a4fb75db864000d01701c0f7a51864bd4daabf
rename to test/integration/bisect/expected/repo/.git_keep/objects/45/a4fb75db864000d01701c0f7a51864bd4daabf
diff --git a/test/integration/bisect/expected/.git_keep/objects/47/8a007451b33c7a234c60f0d13b164561b29094 b/test/integration/bisect/expected/repo/.git_keep/objects/47/8a007451b33c7a234c60f0d13b164561b29094
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/47/8a007451b33c7a234c60f0d13b164561b29094
rename to test/integration/bisect/expected/repo/.git_keep/objects/47/8a007451b33c7a234c60f0d13b164561b29094
diff --git a/test/integration/bisect/expected/.git_keep/objects/48/082f72f087ce7e6fa75b9c41d7387daecd447b b/test/integration/bisect/expected/repo/.git_keep/objects/48/082f72f087ce7e6fa75b9c41d7387daecd447b
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/48/082f72f087ce7e6fa75b9c41d7387daecd447b
rename to test/integration/bisect/expected/repo/.git_keep/objects/48/082f72f087ce7e6fa75b9c41d7387daecd447b
diff --git a/test/integration/bisect/expected/.git_keep/objects/4b/65d66c089cd4f6bfa69dff2d7ba4c27337cd23 b/test/integration/bisect/expected/repo/.git_keep/objects/4b/65d66c089cd4f6bfa69dff2d7ba4c27337cd23
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/4b/65d66c089cd4f6bfa69dff2d7ba4c27337cd23
rename to test/integration/bisect/expected/repo/.git_keep/objects/4b/65d66c089cd4f6bfa69dff2d7ba4c27337cd23
diff --git a/test/integration/bisect/expected/.git_keep/objects/54/3c0ef66d928051f16f8b9d7d33d6c4ea1f4e4f b/test/integration/bisect/expected/repo/.git_keep/objects/54/3c0ef66d928051f16f8b9d7d33d6c4ea1f4e4f
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/54/3c0ef66d928051f16f8b9d7d33d6c4ea1f4e4f
rename to test/integration/bisect/expected/repo/.git_keep/objects/54/3c0ef66d928051f16f8b9d7d33d6c4ea1f4e4f
diff --git a/test/integration/bisect/expected/.git_keep/objects/5f/9397e5bcee1ac2a3fe6d834d42e36b74ef4ca8 b/test/integration/bisect/expected/repo/.git_keep/objects/5f/9397e5bcee1ac2a3fe6d834d42e36b74ef4ca8
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/5f/9397e5bcee1ac2a3fe6d834d42e36b74ef4ca8
rename to test/integration/bisect/expected/repo/.git_keep/objects/5f/9397e5bcee1ac2a3fe6d834d42e36b74ef4ca8
diff --git a/test/integration/bisect/expected/.git_keep/objects/60/d3b2f4a4cd5f1637eba020358bfe5ecb5edcf2 b/test/integration/bisect/expected/repo/.git_keep/objects/60/d3b2f4a4cd5f1637eba020358bfe5ecb5edcf2
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/60/d3b2f4a4cd5f1637eba020358bfe5ecb5edcf2
rename to test/integration/bisect/expected/repo/.git_keep/objects/60/d3b2f4a4cd5f1637eba020358bfe5ecb5edcf2
diff --git a/test/integration/bisect/expected/.git_keep/objects/66/19c0a1a3eb6449eb15ce6cd0916fec0e410c10 b/test/integration/bisect/expected/repo/.git_keep/objects/66/19c0a1a3eb6449eb15ce6cd0916fec0e410c10
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/66/19c0a1a3eb6449eb15ce6cd0916fec0e410c10
rename to test/integration/bisect/expected/repo/.git_keep/objects/66/19c0a1a3eb6449eb15ce6cd0916fec0e410c10
diff --git a/test/integration/bisect/expected/.git_keep/objects/67/fbfb3b74c2381ad1e058949231f2b4f0c8921f b/test/integration/bisect/expected/repo/.git_keep/objects/67/fbfb3b74c2381ad1e058949231f2b4f0c8921f
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/67/fbfb3b74c2381ad1e058949231f2b4f0c8921f
rename to test/integration/bisect/expected/repo/.git_keep/objects/67/fbfb3b74c2381ad1e058949231f2b4f0c8921f
diff --git a/test/integration/bisect/expected/.git_keep/objects/78/d41b2abbd2f52c1ebf2f496268a915d59eb27b b/test/integration/bisect/expected/repo/.git_keep/objects/78/d41b2abbd2f52c1ebf2f496268a915d59eb27b
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/78/d41b2abbd2f52c1ebf2f496268a915d59eb27b
rename to test/integration/bisect/expected/repo/.git_keep/objects/78/d41b2abbd2f52c1ebf2f496268a915d59eb27b
diff --git a/test/integration/bisect/expected/.git_keep/objects/7e/d6ff82de6bcc2a78243fc9c54d3ef5ac14da69 b/test/integration/bisect/expected/repo/.git_keep/objects/7e/d6ff82de6bcc2a78243fc9c54d3ef5ac14da69
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/7e/d6ff82de6bcc2a78243fc9c54d3ef5ac14da69
rename to test/integration/bisect/expected/repo/.git_keep/objects/7e/d6ff82de6bcc2a78243fc9c54d3ef5ac14da69
diff --git a/test/integration/bisect/expected/.git_keep/objects/7f/8f011eb73d6043d2e6db9d2c101195ae2801f2 b/test/integration/bisect/expected/repo/.git_keep/objects/7f/8f011eb73d6043d2e6db9d2c101195ae2801f2
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/7f/8f011eb73d6043d2e6db9d2c101195ae2801f2
rename to test/integration/bisect/expected/repo/.git_keep/objects/7f/8f011eb73d6043d2e6db9d2c101195ae2801f2
diff --git a/test/integration/bisect/expected/.git_keep/objects/80/eeef1a7c49b376f3373ea26c6ba44d69d90d9c b/test/integration/bisect/expected/repo/.git_keep/objects/80/eeef1a7c49b376f3373ea26c6ba44d69d90d9c
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/80/eeef1a7c49b376f3373ea26c6ba44d69d90d9c
rename to test/integration/bisect/expected/repo/.git_keep/objects/80/eeef1a7c49b376f3373ea26c6ba44d69d90d9c
diff --git a/test/integration/bisect/expected/.git_keep/objects/82/d721eb037f7045056023d0904989781ce1f526 b/test/integration/bisect/expected/repo/.git_keep/objects/82/d721eb037f7045056023d0904989781ce1f526
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/82/d721eb037f7045056023d0904989781ce1f526
rename to test/integration/bisect/expected/repo/.git_keep/objects/82/d721eb037f7045056023d0904989781ce1f526
diff --git a/test/integration/bisect/expected/.git_keep/objects/83/51c19397f4fcd5238d10034fa7fa384f14d580 b/test/integration/bisect/expected/repo/.git_keep/objects/83/51c19397f4fcd5238d10034fa7fa384f14d580
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/83/51c19397f4fcd5238d10034fa7fa384f14d580
rename to test/integration/bisect/expected/repo/.git_keep/objects/83/51c19397f4fcd5238d10034fa7fa384f14d580
diff --git a/test/integration/bisect/expected/.git_keep/objects/91/36f315e5952043f1e7ecdc0d28c208eaeaed71 b/test/integration/bisect/expected/repo/.git_keep/objects/91/36f315e5952043f1e7ecdc0d28c208eaeaed71
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/91/36f315e5952043f1e7ecdc0d28c208eaeaed71
rename to test/integration/bisect/expected/repo/.git_keep/objects/91/36f315e5952043f1e7ecdc0d28c208eaeaed71
diff --git a/test/integration/bisect/expected/.git_keep/objects/96/202a92c1d3bde1b20d6f3dec8e742d09732b4d b/test/integration/bisect/expected/repo/.git_keep/objects/96/202a92c1d3bde1b20d6f3dec8e742d09732b4d
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/96/202a92c1d3bde1b20d6f3dec8e742d09732b4d
rename to test/integration/bisect/expected/repo/.git_keep/objects/96/202a92c1d3bde1b20d6f3dec8e742d09732b4d
diff --git a/test/integration/bisect/expected/.git_keep/objects/98/d9bcb75a685dfbfd60f611c309410152935b3d b/test/integration/bisect/expected/repo/.git_keep/objects/98/d9bcb75a685dfbfd60f611c309410152935b3d
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/98/d9bcb75a685dfbfd60f611c309410152935b3d
rename to test/integration/bisect/expected/repo/.git_keep/objects/98/d9bcb75a685dfbfd60f611c309410152935b3d
diff --git a/test/integration/bisect/expected/.git_keep/objects/ae/95e9aa3b8881aedb7a526c86ec5d60f371ca6c b/test/integration/bisect/expected/repo/.git_keep/objects/ae/95e9aa3b8881aedb7a526c86ec5d60f371ca6c
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/ae/95e9aa3b8881aedb7a526c86ec5d60f371ca6c
rename to test/integration/bisect/expected/repo/.git_keep/objects/ae/95e9aa3b8881aedb7a526c86ec5d60f371ca6c
diff --git a/test/integration/bisect/expected/.git_keep/objects/af/f6316148f1524977997c486bcfe624c9094c4e b/test/integration/bisect/expected/repo/.git_keep/objects/af/f6316148f1524977997c486bcfe624c9094c4e
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/af/f6316148f1524977997c486bcfe624c9094c4e
rename to test/integration/bisect/expected/repo/.git_keep/objects/af/f6316148f1524977997c486bcfe624c9094c4e
diff --git a/test/integration/bisect/expected/.git_keep/objects/b1/bd38b62a0800a4f6a80c34e21c5acffae52c7e b/test/integration/bisect/expected/repo/.git_keep/objects/b1/bd38b62a0800a4f6a80c34e21c5acffae52c7e
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/b1/bd38b62a0800a4f6a80c34e21c5acffae52c7e
rename to test/integration/bisect/expected/repo/.git_keep/objects/b1/bd38b62a0800a4f6a80c34e21c5acffae52c7e
diff --git a/test/integration/bisect/expected/.git_keep/objects/b4/de3947675361a7770d29b8982c407b0ec6b2a0 b/test/integration/bisect/expected/repo/.git_keep/objects/b4/de3947675361a7770d29b8982c407b0ec6b2a0
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/b4/de3947675361a7770d29b8982c407b0ec6b2a0
rename to test/integration/bisect/expected/repo/.git_keep/objects/b4/de3947675361a7770d29b8982c407b0ec6b2a0
diff --git a/test/integration/bisect/expected/.git_keep/objects/b5/31696093a6482eca9ad4bcab63407172225b93 b/test/integration/bisect/expected/repo/.git_keep/objects/b5/31696093a6482eca9ad4bcab63407172225b93
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/b5/31696093a6482eca9ad4bcab63407172225b93
rename to test/integration/bisect/expected/repo/.git_keep/objects/b5/31696093a6482eca9ad4bcab63407172225b93
diff --git a/test/integration/bisect/expected/.git_keep/objects/b6/a7d89c68e0ca66e96a9a51892cc33db66fb8a3 b/test/integration/bisect/expected/repo/.git_keep/objects/b6/a7d89c68e0ca66e96a9a51892cc33db66fb8a3
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/b6/a7d89c68e0ca66e96a9a51892cc33db66fb8a3
rename to test/integration/bisect/expected/repo/.git_keep/objects/b6/a7d89c68e0ca66e96a9a51892cc33db66fb8a3
diff --git a/test/integration/bisect/expected/.git_keep/objects/b8/626c4cff2849624fb67f87cd0ad72b163671ad b/test/integration/bisect/expected/repo/.git_keep/objects/b8/626c4cff2849624fb67f87cd0ad72b163671ad
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/b8/626c4cff2849624fb67f87cd0ad72b163671ad
rename to test/integration/bisect/expected/repo/.git_keep/objects/b8/626c4cff2849624fb67f87cd0ad72b163671ad
diff --git a/test/integration/bisect/expected/.git_keep/objects/b9/7844c9437a4ab69c8165cadd97bc597b43135b b/test/integration/bisect/expected/repo/.git_keep/objects/b9/7844c9437a4ab69c8165cadd97bc597b43135b
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/b9/7844c9437a4ab69c8165cadd97bc597b43135b
rename to test/integration/bisect/expected/repo/.git_keep/objects/b9/7844c9437a4ab69c8165cadd97bc597b43135b
diff --git a/test/integration/bisect/expected/.git_keep/objects/ba/8e7277a0ee7cdf84cd5c6138057adb85947a90 b/test/integration/bisect/expected/repo/.git_keep/objects/ba/8e7277a0ee7cdf84cd5c6138057adb85947a90
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/ba/8e7277a0ee7cdf84cd5c6138057adb85947a90
rename to test/integration/bisect/expected/repo/.git_keep/objects/ba/8e7277a0ee7cdf84cd5c6138057adb85947a90
diff --git a/test/integration/bisect/expected/.git_keep/objects/bc/21c8fabc28201fab6c60503168ecda25ad8626 b/test/integration/bisect/expected/repo/.git_keep/objects/bc/21c8fabc28201fab6c60503168ecda25ad8626
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/bc/21c8fabc28201fab6c60503168ecda25ad8626
rename to test/integration/bisect/expected/repo/.git_keep/objects/bc/21c8fabc28201fab6c60503168ecda25ad8626
diff --git a/test/integration/bisect/expected/.git_keep/objects/d0/0491fd7e5bb6fa28c517a0bb32b8b506539d4d b/test/integration/bisect/expected/repo/.git_keep/objects/d0/0491fd7e5bb6fa28c517a0bb32b8b506539d4d
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/d0/0491fd7e5bb6fa28c517a0bb32b8b506539d4d
rename to test/integration/bisect/expected/repo/.git_keep/objects/d0/0491fd7e5bb6fa28c517a0bb32b8b506539d4d
diff --git a/test/integration/bisect/expected/.git_keep/objects/d1/f7a85555fe6f10dd44754d35459ae741cb107c b/test/integration/bisect/expected/repo/.git_keep/objects/d1/f7a85555fe6f10dd44754d35459ae741cb107c
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/d1/f7a85555fe6f10dd44754d35459ae741cb107c
rename to test/integration/bisect/expected/repo/.git_keep/objects/d1/f7a85555fe6f10dd44754d35459ae741cb107c
diff --git a/test/integration/bisect/expected/.git_keep/objects/d5/42aa84743f8ba1380358d4009408f03dbfb247 b/test/integration/bisect/expected/repo/.git_keep/objects/d5/42aa84743f8ba1380358d4009408f03dbfb247
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/d5/42aa84743f8ba1380358d4009408f03dbfb247
rename to test/integration/bisect/expected/repo/.git_keep/objects/d5/42aa84743f8ba1380358d4009408f03dbfb247
diff --git a/test/integration/bisect/expected/.git_keep/objects/d6/b24041cf04154f8f902651969675021f4d93a5 b/test/integration/bisect/expected/repo/.git_keep/objects/d6/b24041cf04154f8f902651969675021f4d93a5
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/d6/b24041cf04154f8f902651969675021f4d93a5
rename to test/integration/bisect/expected/repo/.git_keep/objects/d6/b24041cf04154f8f902651969675021f4d93a5
diff --git a/test/integration/bisect/expected/.git_keep/objects/d9/328d9b2c9536fdf01641dd03f4a254d2c86601 b/test/integration/bisect/expected/repo/.git_keep/objects/d9/328d9b2c9536fdf01641dd03f4a254d2c86601
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/d9/328d9b2c9536fdf01641dd03f4a254d2c86601
rename to test/integration/bisect/expected/repo/.git_keep/objects/d9/328d9b2c9536fdf01641dd03f4a254d2c86601
diff --git a/test/integration/bisect/expected/.git_keep/objects/d9/cc608eedd5d2cc63c262272b7a0f6ab6aed5dd b/test/integration/bisect/expected/repo/.git_keep/objects/d9/cc608eedd5d2cc63c262272b7a0f6ab6aed5dd
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/d9/cc608eedd5d2cc63c262272b7a0f6ab6aed5dd
rename to test/integration/bisect/expected/repo/.git_keep/objects/d9/cc608eedd5d2cc63c262272b7a0f6ab6aed5dd
diff --git a/test/integration/bisect/expected/.git_keep/objects/db/b21289ee21b2ff0f3de2bc7d00038b30c4e353 b/test/integration/bisect/expected/repo/.git_keep/objects/db/b21289ee21b2ff0f3de2bc7d00038b30c4e353
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/db/b21289ee21b2ff0f3de2bc7d00038b30c4e353
rename to test/integration/bisect/expected/repo/.git_keep/objects/db/b21289ee21b2ff0f3de2bc7d00038b30c4e353
diff --git a/test/integration/bisect/expected/.git_keep/objects/e5/9bbaffe94b06acaadab4245f30ff3e11c66e5b b/test/integration/bisect/expected/repo/.git_keep/objects/e5/9bbaffe94b06acaadab4245f30ff3e11c66e5b
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/e5/9bbaffe94b06acaadab4245f30ff3e11c66e5b
rename to test/integration/bisect/expected/repo/.git_keep/objects/e5/9bbaffe94b06acaadab4245f30ff3e11c66e5b
diff --git a/test/integration/bisect/expected/.git_keep/objects/e9/27f0f9467e772eea36f24053c9b534303b106a b/test/integration/bisect/expected/repo/.git_keep/objects/e9/27f0f9467e772eea36f24053c9b534303b106a
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/e9/27f0f9467e772eea36f24053c9b534303b106a
rename to test/integration/bisect/expected/repo/.git_keep/objects/e9/27f0f9467e772eea36f24053c9b534303b106a
diff --git a/test/integration/bisect/expected/.git_keep/objects/e9/d2f825e793bc9ac2be698348dbe669bad34cad b/test/integration/bisect/expected/repo/.git_keep/objects/e9/d2f825e793bc9ac2be698348dbe669bad34cad
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/e9/d2f825e793bc9ac2be698348dbe669bad34cad
rename to test/integration/bisect/expected/repo/.git_keep/objects/e9/d2f825e793bc9ac2be698348dbe669bad34cad
diff --git a/test/integration/bisect/expected/.git_keep/objects/ea/684d3f868c358400465f2ec16a640c319ea6a3 b/test/integration/bisect/expected/repo/.git_keep/objects/ea/684d3f868c358400465f2ec16a640c319ea6a3
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/ea/684d3f868c358400465f2ec16a640c319ea6a3
rename to test/integration/bisect/expected/repo/.git_keep/objects/ea/684d3f868c358400465f2ec16a640c319ea6a3
diff --git a/test/integration/bisect/expected/.git_keep/objects/eb/e59a71e9750e75fb983f241687cdf7f0c8ce94 b/test/integration/bisect/expected/repo/.git_keep/objects/eb/e59a71e9750e75fb983f241687cdf7f0c8ce94
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/eb/e59a71e9750e75fb983f241687cdf7f0c8ce94
rename to test/integration/bisect/expected/repo/.git_keep/objects/eb/e59a71e9750e75fb983f241687cdf7f0c8ce94
diff --git a/test/integration/bisect/expected/.git_keep/objects/ec/635144f60048986bc560c5576355344005e6e7 b/test/integration/bisect/expected/repo/.git_keep/objects/ec/635144f60048986bc560c5576355344005e6e7
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/ec/635144f60048986bc560c5576355344005e6e7
rename to test/integration/bisect/expected/repo/.git_keep/objects/ec/635144f60048986bc560c5576355344005e6e7
diff --git a/test/integration/bisect/expected/.git_keep/objects/f2/7c6ae26adb8396d3861976ba268f87ad8afa0b b/test/integration/bisect/expected/repo/.git_keep/objects/f2/7c6ae26adb8396d3861976ba268f87ad8afa0b
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/f2/7c6ae26adb8396d3861976ba268f87ad8afa0b
rename to test/integration/bisect/expected/repo/.git_keep/objects/f2/7c6ae26adb8396d3861976ba268f87ad8afa0b
diff --git a/test/integration/bisect/expected/.git_keep/objects/f5/99e28b8ab0d8c9c57a486c89c4a5132dcbd3b2 b/test/integration/bisect/expected/repo/.git_keep/objects/f5/99e28b8ab0d8c9c57a486c89c4a5132dcbd3b2
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/objects/f5/99e28b8ab0d8c9c57a486c89c4a5132dcbd3b2
rename to test/integration/bisect/expected/repo/.git_keep/objects/f5/99e28b8ab0d8c9c57a486c89c4a5132dcbd3b2
diff --git a/test/integration/bisect/expected/.git_keep/packed-refs b/test/integration/bisect/expected/repo/.git_keep/packed-refs
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/packed-refs
rename to test/integration/bisect/expected/repo/.git_keep/packed-refs
diff --git a/test/integration/bisect/expected/.git_keep/refs/bisect/bad b/test/integration/bisect/expected/repo/.git_keep/refs/bisect/bad
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/refs/bisect/bad
rename to test/integration/bisect/expected/repo/.git_keep/refs/bisect/bad
diff --git a/test/integration/bisect/expected/.git_keep/refs/bisect/good-39983ea412adebe6c5a3d4451a7673cf0962c472 b/test/integration/bisect/expected/repo/.git_keep/refs/bisect/good-39983ea412adebe6c5a3d4451a7673cf0962c472
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/refs/bisect/good-39983ea412adebe6c5a3d4451a7673cf0962c472
rename to test/integration/bisect/expected/repo/.git_keep/refs/bisect/good-39983ea412adebe6c5a3d4451a7673cf0962c472
diff --git a/test/integration/bisect/expected/.git_keep/refs/bisect/good-67fbfb3b74c2381ad1e058949231f2b4f0c8921f b/test/integration/bisect/expected/repo/.git_keep/refs/bisect/good-67fbfb3b74c2381ad1e058949231f2b4f0c8921f
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/refs/bisect/good-67fbfb3b74c2381ad1e058949231f2b4f0c8921f
rename to test/integration/bisect/expected/repo/.git_keep/refs/bisect/good-67fbfb3b74c2381ad1e058949231f2b4f0c8921f
diff --git a/test/integration/bisect/expected/.git_keep/refs/bisect/good-e927f0f9467e772eea36f24053c9b534303b106a b/test/integration/bisect/expected/repo/.git_keep/refs/bisect/good-e927f0f9467e772eea36f24053c9b534303b106a
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/refs/bisect/good-e927f0f9467e772eea36f24053c9b534303b106a
rename to test/integration/bisect/expected/repo/.git_keep/refs/bisect/good-e927f0f9467e772eea36f24053c9b534303b106a
diff --git a/test/integration/bisect/expected/.git_keep/refs/bisect/good-e9d2f825e793bc9ac2be698348dbe669bad34cad b/test/integration/bisect/expected/repo/.git_keep/refs/bisect/good-e9d2f825e793bc9ac2be698348dbe669bad34cad
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/refs/bisect/good-e9d2f825e793bc9ac2be698348dbe669bad34cad
rename to test/integration/bisect/expected/repo/.git_keep/refs/bisect/good-e9d2f825e793bc9ac2be698348dbe669bad34cad
diff --git a/test/integration/bisect/expected/.git_keep/refs/bisect/skip-bc21c8fabc28201fab6c60503168ecda25ad8626 b/test/integration/bisect/expected/repo/.git_keep/refs/bisect/skip-bc21c8fabc28201fab6c60503168ecda25ad8626
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/refs/bisect/skip-bc21c8fabc28201fab6c60503168ecda25ad8626
rename to test/integration/bisect/expected/repo/.git_keep/refs/bisect/skip-bc21c8fabc28201fab6c60503168ecda25ad8626
diff --git a/test/integration/bisect/expected/.git_keep/refs/bisect/skip-d1f7a85555fe6f10dd44754d35459ae741cb107c b/test/integration/bisect/expected/repo/.git_keep/refs/bisect/skip-d1f7a85555fe6f10dd44754d35459ae741cb107c
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/refs/bisect/skip-d1f7a85555fe6f10dd44754d35459ae741cb107c
rename to test/integration/bisect/expected/repo/.git_keep/refs/bisect/skip-d1f7a85555fe6f10dd44754d35459ae741cb107c
diff --git a/test/integration/bisect/expected/.git_keep/refs/heads/master b/test/integration/bisect/expected/repo/.git_keep/refs/heads/master
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/refs/heads/master
rename to test/integration/bisect/expected/repo/.git_keep/refs/heads/master
diff --git a/test/integration/bisect/expected/.git_keep/refs/heads/test b/test/integration/bisect/expected/repo/.git_keep/refs/heads/test
similarity index 100%
rename from test/integration/bisect/expected/.git_keep/refs/heads/test
rename to test/integration/bisect/expected/repo/.git_keep/refs/heads/test
diff --git a/test/integration/bisect/expected/file b/test/integration/bisect/expected/repo/file
similarity index 100%
rename from test/integration/bisect/expected/file
rename to test/integration/bisect/expected/repo/file
diff --git a/test/integration/bisect/test.json b/test/integration/bisect/test.json
index 2bf2b418b..58263936d 100644
--- a/test/integration/bisect/test.json
+++ b/test/integration/bisect/test.json
@@ -1,4 +1,5 @@
{
"description": "Basic git bisect usage",
- "speed": 5
+ "speed": 5,
+ "skip": true
}
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/BISECT_ANCESTORS_OK b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/BISECT_ANCESTORS_OK
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/BISECT_ANCESTORS_OK
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/BISECT_ANCESTORS_OK
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/BISECT_EXPECTED_REV b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/BISECT_EXPECTED_REV
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/BISECT_EXPECTED_REV
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/BISECT_EXPECTED_REV
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/BISECT_LOG b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/BISECT_LOG
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/BISECT_LOG
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/BISECT_LOG
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/BISECT_NAMES b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/BISECT_NAMES
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/BISECT_NAMES
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/BISECT_NAMES
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/BISECT_START b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/BISECT_START
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/BISECT_START
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/BISECT_START
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/BISECT_TERMS b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/BISECT_TERMS
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/BISECT_TERMS
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/BISECT_TERMS
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/COMMIT_EDITMSG b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/COMMIT_EDITMSG
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/COMMIT_EDITMSG
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/COMMIT_EDITMSG
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/FETCH_HEAD b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/FETCH_HEAD
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/FETCH_HEAD
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/FETCH_HEAD
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/HEAD b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/HEAD
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/HEAD
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/HEAD
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/config b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/config
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/config
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/config
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/description b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/description
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/description
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/description
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/index b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/index
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/index
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/index
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/info/exclude b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/info/exclude
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/info/exclude
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/info/exclude
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/logs/HEAD b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/logs/HEAD
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/logs/HEAD
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/logs/HEAD
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/logs/refs/heads/master b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/logs/refs/heads/master
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/logs/refs/heads/master
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/logs/refs/heads/master
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/logs/refs/heads/other b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/logs/refs/heads/other
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/logs/refs/heads/other
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/logs/refs/heads/other
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/logs/refs/heads/test b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/logs/refs/heads/test
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/logs/refs/heads/test
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/logs/refs/heads/test
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/00/750edc07d6415dcc07ae0351e9397b0222b7ba b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/00/750edc07d6415dcc07ae0351e9397b0222b7ba
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/00/750edc07d6415dcc07ae0351e9397b0222b7ba
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/00/750edc07d6415dcc07ae0351e9397b0222b7ba
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/03/ecdaa424af1fdaeab1bd1852319652b9518f11 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/03/ecdaa424af1fdaeab1bd1852319652b9518f11
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/03/ecdaa424af1fdaeab1bd1852319652b9518f11
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/03/ecdaa424af1fdaeab1bd1852319652b9518f11
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/04/a577be2858b8024716876aefe6b665a98e1e4f b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/04/a577be2858b8024716876aefe6b665a98e1e4f
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/04/a577be2858b8024716876aefe6b665a98e1e4f
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/04/a577be2858b8024716876aefe6b665a98e1e4f
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/05/57fc43da38567eae00831e9b385fd2cad22643 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/05/57fc43da38567eae00831e9b385fd2cad22643
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/05/57fc43da38567eae00831e9b385fd2cad22643
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/05/57fc43da38567eae00831e9b385fd2cad22643
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/0c/fbf08886fca9a91cb753ec8734c84fcbe52c9f b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/0c/fbf08886fca9a91cb753ec8734c84fcbe52c9f
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/0c/fbf08886fca9a91cb753ec8734c84fcbe52c9f
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/0c/fbf08886fca9a91cb753ec8734c84fcbe52c9f
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/0d/1bbe8d012c8c070167a006a4898b525cfbd930 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/0d/1bbe8d012c8c070167a006a4898b525cfbd930
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/0d/1bbe8d012c8c070167a006a4898b525cfbd930
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/0d/1bbe8d012c8c070167a006a4898b525cfbd930
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/0f/373801691c466240bd131d28b2168712b045ba b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/0f/373801691c466240bd131d28b2168712b045ba
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/0f/373801691c466240bd131d28b2168712b045ba
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/0f/373801691c466240bd131d28b2168712b045ba
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/10/e171beacb963e4f8a4dc1d80fd291c135902bb b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/10/e171beacb963e4f8a4dc1d80fd291c135902bb
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/10/e171beacb963e4f8a4dc1d80fd291c135902bb
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/10/e171beacb963e4f8a4dc1d80fd291c135902bb
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/11/e4fd4011c9ed3800bb33b85580dd1d09f6aefd b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/11/e4fd4011c9ed3800bb33b85580dd1d09f6aefd
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/11/e4fd4011c9ed3800bb33b85580dd1d09f6aefd
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/11/e4fd4011c9ed3800bb33b85580dd1d09f6aefd
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/15/d4c5b8608fe472fd224333e60d44ea826cc80e b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/15/d4c5b8608fe472fd224333e60d44ea826cc80e
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/15/d4c5b8608fe472fd224333e60d44ea826cc80e
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/15/d4c5b8608fe472fd224333e60d44ea826cc80e
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/1a/35564b85e24c96e647b477aaf8d35dcf0de84d b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/1a/35564b85e24c96e647b477aaf8d35dcf0de84d
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/1a/35564b85e24c96e647b477aaf8d35dcf0de84d
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/1a/35564b85e24c96e647b477aaf8d35dcf0de84d
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/1a/b342c03b4226cca1c751dba71fa2df0e7d82ee b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/1a/b342c03b4226cca1c751dba71fa2df0e7d82ee
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/1a/b342c03b4226cca1c751dba71fa2df0e7d82ee
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/1a/b342c03b4226cca1c751dba71fa2df0e7d82ee
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/1e/8b314962144c26d5e0e50fd29d2ca327864913 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/1e/8b314962144c26d5e0e50fd29d2ca327864913
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/1e/8b314962144c26d5e0e50fd29d2ca327864913
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/1e/8b314962144c26d5e0e50fd29d2ca327864913
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/20/9e3ef4b6247ce746048d5711befda46206d235 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/20/9e3ef4b6247ce746048d5711befda46206d235
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/20/9e3ef4b6247ce746048d5711befda46206d235
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/20/9e3ef4b6247ce746048d5711befda46206d235
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/26/661287266e53bda69d8daa3aac1f714650f13c b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/26/661287266e53bda69d8daa3aac1f714650f13c
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/26/661287266e53bda69d8daa3aac1f714650f13c
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/26/661287266e53bda69d8daa3aac1f714650f13c
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/30/68d0d730547aaa5b86dbfe638db83133ae1421 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/30/68d0d730547aaa5b86dbfe638db83133ae1421
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/30/68d0d730547aaa5b86dbfe638db83133ae1421
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/30/68d0d730547aaa5b86dbfe638db83133ae1421
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/31/df6c951dc82b75b11bc48836e780134a16e6ad b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/31/df6c951dc82b75b11bc48836e780134a16e6ad
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/31/df6c951dc82b75b11bc48836e780134a16e6ad
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/31/df6c951dc82b75b11bc48836e780134a16e6ad
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/33/3ff293caf2d3216edf22e3f1df64d43a7e1311 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/33/3ff293caf2d3216edf22e3f1df64d43a7e1311
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/33/3ff293caf2d3216edf22e3f1df64d43a7e1311
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/33/3ff293caf2d3216edf22e3f1df64d43a7e1311
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/36/903784186b1b8b4a150dc656eccd49f94e114e b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/36/903784186b1b8b4a150dc656eccd49f94e114e
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/36/903784186b1b8b4a150dc656eccd49f94e114e
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/36/903784186b1b8b4a150dc656eccd49f94e114e
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/38/242e5215bc35b3e418c1d6d63fd0291001e10b b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/38/242e5215bc35b3e418c1d6d63fd0291001e10b
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/38/242e5215bc35b3e418c1d6d63fd0291001e10b
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/38/242e5215bc35b3e418c1d6d63fd0291001e10b
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/3a/05ed1ca9671bc362c7197eb09bddff040c9110 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/3a/05ed1ca9671bc362c7197eb09bddff040c9110
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/3a/05ed1ca9671bc362c7197eb09bddff040c9110
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/3a/05ed1ca9671bc362c7197eb09bddff040c9110
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/3b/e94d6b1b1b3b63db9e412b2e788d08979bc176 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/3b/e94d6b1b1b3b63db9e412b2e788d08979bc176
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/3b/e94d6b1b1b3b63db9e412b2e788d08979bc176
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/3b/e94d6b1b1b3b63db9e412b2e788d08979bc176
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/3c/032078a4a21c5c51d3c93d91717c1dabbb8cd0 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/3c/032078a4a21c5c51d3c93d91717c1dabbb8cd0
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/3c/032078a4a21c5c51d3c93d91717c1dabbb8cd0
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/3c/032078a4a21c5c51d3c93d91717c1dabbb8cd0
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/3f/bb9e626d4b7d30e58346b3eefaa342b15ab776 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/3f/bb9e626d4b7d30e58346b3eefaa342b15ab776
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/3f/bb9e626d4b7d30e58346b3eefaa342b15ab776
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/3f/bb9e626d4b7d30e58346b3eefaa342b15ab776
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/3f/d9ad29c185eac6106cfa005a3aa594e21b9e06 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/3f/d9ad29c185eac6106cfa005a3aa594e21b9e06
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/3f/d9ad29c185eac6106cfa005a3aa594e21b9e06
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/3f/d9ad29c185eac6106cfa005a3aa594e21b9e06
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/43/99b07bd31d1f58a0ff10b5434d5ff4f36e38fb b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/43/99b07bd31d1f58a0ff10b5434d5ff4f36e38fb
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/43/99b07bd31d1f58a0ff10b5434d5ff4f36e38fb
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/43/99b07bd31d1f58a0ff10b5434d5ff4f36e38fb
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/45/a4fb75db864000d01701c0f7a51864bd4daabf b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/45/a4fb75db864000d01701c0f7a51864bd4daabf
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/45/a4fb75db864000d01701c0f7a51864bd4daabf
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/45/a4fb75db864000d01701c0f7a51864bd4daabf
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/48/082f72f087ce7e6fa75b9c41d7387daecd447b b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/48/082f72f087ce7e6fa75b9c41d7387daecd447b
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/48/082f72f087ce7e6fa75b9c41d7387daecd447b
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/48/082f72f087ce7e6fa75b9c41d7387daecd447b
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/4a/9e01012c736e5e0998b7184d9a54e8c610ed02 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/4a/9e01012c736e5e0998b7184d9a54e8c610ed02
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/4a/9e01012c736e5e0998b7184d9a54e8c610ed02
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/4a/9e01012c736e5e0998b7184d9a54e8c610ed02
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/54/93d27d38b9902cf28b1035c644bf470df76060 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/54/93d27d38b9902cf28b1035c644bf470df76060
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/54/93d27d38b9902cf28b1035c644bf470df76060
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/54/93d27d38b9902cf28b1035c644bf470df76060
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/60/d3b2f4a4cd5f1637eba020358bfe5ecb5edcf2 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/60/d3b2f4a4cd5f1637eba020358bfe5ecb5edcf2
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/60/d3b2f4a4cd5f1637eba020358bfe5ecb5edcf2
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/60/d3b2f4a4cd5f1637eba020358bfe5ecb5edcf2
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/6f/a769bf11bd9dc4ff4e85f4951950b0bc34326f b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/6f/a769bf11bd9dc4ff4e85f4951950b0bc34326f
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/6f/a769bf11bd9dc4ff4e85f4951950b0bc34326f
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/6f/a769bf11bd9dc4ff4e85f4951950b0bc34326f
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/74/cd0e856d938eb6b665284c5485c00f87e20dc5 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/74/cd0e856d938eb6b665284c5485c00f87e20dc5
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/74/cd0e856d938eb6b665284c5485c00f87e20dc5
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/74/cd0e856d938eb6b665284c5485c00f87e20dc5
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/7c/fd51ebd06287effcfdab241235305cb6439d40 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/7c/fd51ebd06287effcfdab241235305cb6439d40
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/7c/fd51ebd06287effcfdab241235305cb6439d40
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/7c/fd51ebd06287effcfdab241235305cb6439d40
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/7e/d6ff82de6bcc2a78243fc9c54d3ef5ac14da69 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/7e/d6ff82de6bcc2a78243fc9c54d3ef5ac14da69
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/7e/d6ff82de6bcc2a78243fc9c54d3ef5ac14da69
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/7e/d6ff82de6bcc2a78243fc9c54d3ef5ac14da69
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/7f/8f011eb73d6043d2e6db9d2c101195ae2801f2 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/7f/8f011eb73d6043d2e6db9d2c101195ae2801f2
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/7f/8f011eb73d6043d2e6db9d2c101195ae2801f2
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/7f/8f011eb73d6043d2e6db9d2c101195ae2801f2
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/83/51c19397f4fcd5238d10034fa7fa384f14d580 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/83/51c19397f4fcd5238d10034fa7fa384f14d580
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/83/51c19397f4fcd5238d10034fa7fa384f14d580
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/83/51c19397f4fcd5238d10034fa7fa384f14d580
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/88/8633a131a49f1b8981d70c13d666defed6ba15 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/88/8633a131a49f1b8981d70c13d666defed6ba15
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/88/8633a131a49f1b8981d70c13d666defed6ba15
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/88/8633a131a49f1b8981d70c13d666defed6ba15
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/8b/0156bd25a1ecd82ef7c4c53e9d6d312bb6d403 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/8b/0156bd25a1ecd82ef7c4c53e9d6d312bb6d403
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/8b/0156bd25a1ecd82ef7c4c53e9d6d312bb6d403
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/8b/0156bd25a1ecd82ef7c4c53e9d6d312bb6d403
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/90/dfebd90b0a89766c39928f22901b8c02b51fda b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/90/dfebd90b0a89766c39928f22901b8c02b51fda
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/90/dfebd90b0a89766c39928f22901b8c02b51fda
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/90/dfebd90b0a89766c39928f22901b8c02b51fda
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/98/d9bcb75a685dfbfd60f611c309410152935b3d b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/98/d9bcb75a685dfbfd60f611c309410152935b3d
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/98/d9bcb75a685dfbfd60f611c309410152935b3d
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/98/d9bcb75a685dfbfd60f611c309410152935b3d
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/9c/836a5c3f513818d300b410f8cb3a2e3bbd42a1 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/9c/836a5c3f513818d300b410f8cb3a2e3bbd42a1
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/9c/836a5c3f513818d300b410f8cb3a2e3bbd42a1
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/9c/836a5c3f513818d300b410f8cb3a2e3bbd42a1
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/9d/33ec0915534bf6401be9412203697791e40a04 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/9d/33ec0915534bf6401be9412203697791e40a04
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/9d/33ec0915534bf6401be9412203697791e40a04
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/9d/33ec0915534bf6401be9412203697791e40a04
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/b1/bd38b62a0800a4f6a80c34e21c5acffae52c7e b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/b1/bd38b62a0800a4f6a80c34e21c5acffae52c7e
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/b1/bd38b62a0800a4f6a80c34e21c5acffae52c7e
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/b1/bd38b62a0800a4f6a80c34e21c5acffae52c7e
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/b2/970eba9fd8dba8655094dc38fb4ee50b9bf23e b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/b2/970eba9fd8dba8655094dc38fb4ee50b9bf23e
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/b2/970eba9fd8dba8655094dc38fb4ee50b9bf23e
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/b2/970eba9fd8dba8655094dc38fb4ee50b9bf23e
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/b4/de3947675361a7770d29b8982c407b0ec6b2a0 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/b4/de3947675361a7770d29b8982c407b0ec6b2a0
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/b4/de3947675361a7770d29b8982c407b0ec6b2a0
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/b4/de3947675361a7770d29b8982c407b0ec6b2a0
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/b6/14152a335aabd8b5daa2c6abccc9425eb14177 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/b6/14152a335aabd8b5daa2c6abccc9425eb14177
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/b6/14152a335aabd8b5daa2c6abccc9425eb14177
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/b6/14152a335aabd8b5daa2c6abccc9425eb14177
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/b6/a7d89c68e0ca66e96a9a51892cc33db66fb8a3 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/b6/a7d89c68e0ca66e96a9a51892cc33db66fb8a3
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/b6/a7d89c68e0ca66e96a9a51892cc33db66fb8a3
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/b6/a7d89c68e0ca66e96a9a51892cc33db66fb8a3
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/b8/626c4cff2849624fb67f87cd0ad72b163671ad b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/b8/626c4cff2849624fb67f87cd0ad72b163671ad
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/b8/626c4cff2849624fb67f87cd0ad72b163671ad
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/b8/626c4cff2849624fb67f87cd0ad72b163671ad
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/cd/c58c0f95a2313ded9185e102bc35253f6a1bed b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/cd/c58c0f95a2313ded9185e102bc35253f6a1bed
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/cd/c58c0f95a2313ded9185e102bc35253f6a1bed
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/cd/c58c0f95a2313ded9185e102bc35253f6a1bed
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/d0/0491fd7e5bb6fa28c517a0bb32b8b506539d4d b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/d0/0491fd7e5bb6fa28c517a0bb32b8b506539d4d
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/d0/0491fd7e5bb6fa28c517a0bb32b8b506539d4d
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/d0/0491fd7e5bb6fa28c517a0bb32b8b506539d4d
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/d2/7a997859219951ecc95c351174c70ea0cf9d37 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/d2/7a997859219951ecc95c351174c70ea0cf9d37
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/d2/7a997859219951ecc95c351174c70ea0cf9d37
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/d2/7a997859219951ecc95c351174c70ea0cf9d37
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/d3/6e09d97bf2c1527118bde353ad64b157f8b269 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/d3/6e09d97bf2c1527118bde353ad64b157f8b269
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/d3/6e09d97bf2c1527118bde353ad64b157f8b269
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/d3/6e09d97bf2c1527118bde353ad64b157f8b269
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/d4/e982570808a24722649e852a92bda5cf54c9dd b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/d4/e982570808a24722649e852a92bda5cf54c9dd
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/d4/e982570808a24722649e852a92bda5cf54c9dd
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/d4/e982570808a24722649e852a92bda5cf54c9dd
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/d6/b24041cf04154f8f902651969675021f4d93a5 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/d6/b24041cf04154f8f902651969675021f4d93a5
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/d6/b24041cf04154f8f902651969675021f4d93a5
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/d6/b24041cf04154f8f902651969675021f4d93a5
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/d8/4e0684b1038552d5c8d86e67398d634f77ad3b b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/d8/4e0684b1038552d5c8d86e67398d634f77ad3b
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/d8/4e0684b1038552d5c8d86e67398d634f77ad3b
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/d8/4e0684b1038552d5c8d86e67398d634f77ad3b
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/da/8dacb2a073ebc2adeddceb3cc2b39b6c95c858 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/da/8dacb2a073ebc2adeddceb3cc2b39b6c95c858
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/da/8dacb2a073ebc2adeddceb3cc2b39b6c95c858
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/da/8dacb2a073ebc2adeddceb3cc2b39b6c95c858
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/db/76c03074879025735b647b825786a7b3fcfe7c b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/db/76c03074879025735b647b825786a7b3fcfe7c
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/db/76c03074879025735b647b825786a7b3fcfe7c
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/db/76c03074879025735b647b825786a7b3fcfe7c
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/e5/59f83c6e8dd11680de70b487725e37ff2e283f b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/e5/59f83c6e8dd11680de70b487725e37ff2e283f
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/e5/59f83c6e8dd11680de70b487725e37ff2e283f
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/e5/59f83c6e8dd11680de70b487725e37ff2e283f
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/ec/635144f60048986bc560c5576355344005e6e7 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/ec/635144f60048986bc560c5576355344005e6e7
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/ec/635144f60048986bc560c5576355344005e6e7
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/ec/635144f60048986bc560c5576355344005e6e7
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/f5/99e28b8ab0d8c9c57a486c89c4a5132dcbd3b2 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/f5/99e28b8ab0d8c9c57a486c89c4a5132dcbd3b2
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/f5/99e28b8ab0d8c9c57a486c89c4a5132dcbd3b2
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/f5/99e28b8ab0d8c9c57a486c89c4a5132dcbd3b2
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/objects/ff/231021500c7beb87de0d6d5edc29b6f9c000b1 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/ff/231021500c7beb87de0d6d5edc29b6f9c000b1
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/objects/ff/231021500c7beb87de0d6d5edc29b6f9c000b1
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/objects/ff/231021500c7beb87de0d6d5edc29b6f9c000b1
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/packed-refs b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/packed-refs
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/packed-refs
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/packed-refs
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/refs/bisect/bad b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/refs/bisect/bad
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/refs/bisect/bad
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/refs/bisect/bad
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/refs/bisect/good-38242e5215bc35b3e418c1d6d63fd0291001e10b b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/refs/bisect/good-38242e5215bc35b3e418c1d6d63fd0291001e10b
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/refs/bisect/good-38242e5215bc35b3e418c1d6d63fd0291001e10b
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/refs/bisect/good-38242e5215bc35b3e418c1d6d63fd0291001e10b
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/refs/bisect/good-3fbb9e626d4b7d30e58346b3eefaa342b15ab776 b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/refs/bisect/good-3fbb9e626d4b7d30e58346b3eefaa342b15ab776
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/refs/bisect/good-3fbb9e626d4b7d30e58346b3eefaa342b15ab776
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/refs/bisect/good-3fbb9e626d4b7d30e58346b3eefaa342b15ab776
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/refs/heads/master b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/refs/heads/master
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/refs/heads/master
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/refs/heads/master
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/refs/heads/other b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/refs/heads/other
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/refs/heads/other
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/refs/heads/other
diff --git a/test/integration/bisectFromOtherBranch/expected/.git_keep/refs/heads/test b/test/integration/bisectFromOtherBranch/expected/repo/.git_keep/refs/heads/test
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/.git_keep/refs/heads/test
rename to test/integration/bisectFromOtherBranch/expected/repo/.git_keep/refs/heads/test
diff --git a/test/integration/bisectFromOtherBranch/expected/myfile b/test/integration/bisectFromOtherBranch/expected/repo/myfile
similarity index 100%
rename from test/integration/bisectFromOtherBranch/expected/myfile
rename to test/integration/bisectFromOtherBranch/expected/repo/myfile
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/COMMIT_EDITMSG b/test/integration/branchAutocomplete/expected/repo/.git_keep/COMMIT_EDITMSG
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/COMMIT_EDITMSG
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/COMMIT_EDITMSG
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/FETCH_HEAD b/test/integration/branchAutocomplete/expected/repo/.git_keep/FETCH_HEAD
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/FETCH_HEAD
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/FETCH_HEAD
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/HEAD b/test/integration/branchAutocomplete/expected/repo/.git_keep/HEAD
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/HEAD
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/HEAD
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/config b/test/integration/branchAutocomplete/expected/repo/.git_keep/config
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/config
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/config
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/description b/test/integration/branchAutocomplete/expected/repo/.git_keep/description
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/description
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/description
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/index b/test/integration/branchAutocomplete/expected/repo/.git_keep/index
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/index
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/index
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/info/exclude b/test/integration/branchAutocomplete/expected/repo/.git_keep/info/exclude
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/info/exclude
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/info/exclude
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/logs/HEAD b/test/integration/branchAutocomplete/expected/repo/.git_keep/logs/HEAD
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/logs/HEAD
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/logs/HEAD
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/logs/refs/heads/four b/test/integration/branchAutocomplete/expected/repo/.git_keep/logs/refs/heads/four
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/logs/refs/heads/four
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/logs/refs/heads/four
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/logs/refs/heads/master b/test/integration/branchAutocomplete/expected/repo/.git_keep/logs/refs/heads/master
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/logs/refs/heads/master
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/logs/refs/heads/master
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/logs/refs/heads/one b/test/integration/branchAutocomplete/expected/repo/.git_keep/logs/refs/heads/one
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/logs/refs/heads/one
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/logs/refs/heads/one
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/logs/refs/heads/three b/test/integration/branchAutocomplete/expected/repo/.git_keep/logs/refs/heads/three
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/logs/refs/heads/three
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/logs/refs/heads/three
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/logs/refs/heads/two b/test/integration/branchAutocomplete/expected/repo/.git_keep/logs/refs/heads/two
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/logs/refs/heads/two
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/logs/refs/heads/two
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/objects/b4/9fda1c7a9af6a4f0b6b07a2cb31aecb8c01a6c b/test/integration/branchAutocomplete/expected/repo/.git_keep/objects/b4/9fda1c7a9af6a4f0b6b07a2cb31aecb8c01a6c
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/objects/b4/9fda1c7a9af6a4f0b6b07a2cb31aecb8c01a6c
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/objects/b4/9fda1c7a9af6a4f0b6b07a2cb31aecb8c01a6c
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/test/integration/branchAutocomplete/expected/repo/.git_keep/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/objects/f7/53f4dfc98d148a7e685c46c8d148bcac56707d b/test/integration/branchAutocomplete/expected/repo/.git_keep/objects/f7/53f4dfc98d148a7e685c46c8d148bcac56707d
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/objects/f7/53f4dfc98d148a7e685c46c8d148bcac56707d
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/objects/f7/53f4dfc98d148a7e685c46c8d148bcac56707d
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/refs/heads/four b/test/integration/branchAutocomplete/expected/repo/.git_keep/refs/heads/four
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/refs/heads/four
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/refs/heads/four
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/refs/heads/master b/test/integration/branchAutocomplete/expected/repo/.git_keep/refs/heads/master
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/refs/heads/master
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/refs/heads/master
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/refs/heads/one b/test/integration/branchAutocomplete/expected/repo/.git_keep/refs/heads/one
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/refs/heads/one
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/refs/heads/one
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/refs/heads/three b/test/integration/branchAutocomplete/expected/repo/.git_keep/refs/heads/three
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/refs/heads/three
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/refs/heads/three
diff --git a/test/integration/branchAutocomplete/expected/.git_keep/refs/heads/two b/test/integration/branchAutocomplete/expected/repo/.git_keep/refs/heads/two
similarity index 100%
rename from test/integration/branchAutocomplete/expected/.git_keep/refs/heads/two
rename to test/integration/branchAutocomplete/expected/repo/.git_keep/refs/heads/two
diff --git a/test/integration/branchAutocomplete/expected/myfile.txt b/test/integration/branchAutocomplete/expected/repo/myfile.txt
similarity index 100%
rename from test/integration/branchAutocomplete/expected/myfile.txt
rename to test/integration/branchAutocomplete/expected/repo/myfile.txt
diff --git a/test/integration/branchDelete/expected/.git_keep/COMMIT_EDITMSG b/test/integration/branchDelete/expected/repo/.git_keep/COMMIT_EDITMSG
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/COMMIT_EDITMSG
rename to test/integration/branchDelete/expected/repo/.git_keep/COMMIT_EDITMSG
diff --git a/test/integration/branchDelete/expected/.git_keep/FETCH_HEAD b/test/integration/branchDelete/expected/repo/.git_keep/FETCH_HEAD
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/FETCH_HEAD
rename to test/integration/branchDelete/expected/repo/.git_keep/FETCH_HEAD
diff --git a/test/integration/branchDelete/expected/.git_keep/HEAD b/test/integration/branchDelete/expected/repo/.git_keep/HEAD
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/HEAD
rename to test/integration/branchDelete/expected/repo/.git_keep/HEAD
diff --git a/test/integration/branchDelete/expected/.git_keep/config b/test/integration/branchDelete/expected/repo/.git_keep/config
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/config
rename to test/integration/branchDelete/expected/repo/.git_keep/config
diff --git a/test/integration/branchDelete/expected/.git_keep/description b/test/integration/branchDelete/expected/repo/.git_keep/description
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/description
rename to test/integration/branchDelete/expected/repo/.git_keep/description
diff --git a/test/integration/branchDelete/expected/.git_keep/index b/test/integration/branchDelete/expected/repo/.git_keep/index
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/index
rename to test/integration/branchDelete/expected/repo/.git_keep/index
diff --git a/test/integration/branchDelete/expected/.git_keep/info/exclude b/test/integration/branchDelete/expected/repo/.git_keep/info/exclude
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/info/exclude
rename to test/integration/branchDelete/expected/repo/.git_keep/info/exclude
diff --git a/test/integration/branchDelete/expected/.git_keep/logs/HEAD b/test/integration/branchDelete/expected/repo/.git_keep/logs/HEAD
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/logs/HEAD
rename to test/integration/branchDelete/expected/repo/.git_keep/logs/HEAD
diff --git a/test/integration/branchDelete/expected/.git_keep/logs/refs/heads/master b/test/integration/branchDelete/expected/repo/.git_keep/logs/refs/heads/master
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/logs/refs/heads/master
rename to test/integration/branchDelete/expected/repo/.git_keep/logs/refs/heads/master
diff --git a/test/integration/branchDelete/expected/.git_keep/logs/refs/heads/new-branch b/test/integration/branchDelete/expected/repo/.git_keep/logs/refs/heads/new-branch
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/logs/refs/heads/new-branch
rename to test/integration/branchDelete/expected/repo/.git_keep/logs/refs/heads/new-branch
diff --git a/test/integration/branchDelete/expected/.git_keep/logs/refs/heads/new-branch-2 b/test/integration/branchDelete/expected/repo/.git_keep/logs/refs/heads/new-branch-2
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/logs/refs/heads/new-branch-2
rename to test/integration/branchDelete/expected/repo/.git_keep/logs/refs/heads/new-branch-2
diff --git a/test/integration/branchDelete/expected/.git_keep/logs/refs/heads/new-branch-3 b/test/integration/branchDelete/expected/repo/.git_keep/logs/refs/heads/new-branch-3
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/logs/refs/heads/new-branch-3
rename to test/integration/branchDelete/expected/repo/.git_keep/logs/refs/heads/new-branch-3
diff --git a/test/integration/branchDelete/expected/.git_keep/logs/refs/heads/old-branch b/test/integration/branchDelete/expected/repo/.git_keep/logs/refs/heads/old-branch
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/logs/refs/heads/old-branch
rename to test/integration/branchDelete/expected/repo/.git_keep/logs/refs/heads/old-branch
diff --git a/test/integration/branchDelete/expected/.git_keep/logs/refs/heads/old-branch-3 b/test/integration/branchDelete/expected/repo/.git_keep/logs/refs/heads/old-branch-3
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/logs/refs/heads/old-branch-3
rename to test/integration/branchDelete/expected/repo/.git_keep/logs/refs/heads/old-branch-3
diff --git a/test/integration/branchDelete/expected/.git_keep/objects/1e/3e67b999db1576ad1ee08bf4f02bdf29e49442 b/test/integration/branchDelete/expected/repo/.git_keep/objects/1e/3e67b999db1576ad1ee08bf4f02bdf29e49442
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/objects/1e/3e67b999db1576ad1ee08bf4f02bdf29e49442
rename to test/integration/branchDelete/expected/repo/.git_keep/objects/1e/3e67b999db1576ad1ee08bf4f02bdf29e49442
diff --git a/test/integration/branchDelete/expected/.git_keep/objects/21/b436d66d2c515ad17285e53d9e6380d599b044 b/test/integration/branchDelete/expected/repo/.git_keep/objects/21/b436d66d2c515ad17285e53d9e6380d599b044
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/objects/21/b436d66d2c515ad17285e53d9e6380d599b044
rename to test/integration/branchDelete/expected/repo/.git_keep/objects/21/b436d66d2c515ad17285e53d9e6380d599b044
diff --git a/test/integration/branchDelete/expected/.git_keep/objects/38/143ad4a0fe2ab6ee53c2ef89a5d9e2bd9535da b/test/integration/branchDelete/expected/repo/.git_keep/objects/38/143ad4a0fe2ab6ee53c2ef89a5d9e2bd9535da
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/objects/38/143ad4a0fe2ab6ee53c2ef89a5d9e2bd9535da
rename to test/integration/branchDelete/expected/repo/.git_keep/objects/38/143ad4a0fe2ab6ee53c2ef89a5d9e2bd9535da
diff --git a/test/integration/branchDelete/expected/.git_keep/refs/heads/master b/test/integration/branchDelete/expected/repo/.git_keep/refs/heads/master
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/refs/heads/master
rename to test/integration/branchDelete/expected/repo/.git_keep/refs/heads/master
diff --git a/test/integration/branchDelete/expected/.git_keep/refs/heads/new-branch b/test/integration/branchDelete/expected/repo/.git_keep/refs/heads/new-branch
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/refs/heads/new-branch
rename to test/integration/branchDelete/expected/repo/.git_keep/refs/heads/new-branch
diff --git a/test/integration/branchDelete/expected/.git_keep/refs/heads/new-branch-2 b/test/integration/branchDelete/expected/repo/.git_keep/refs/heads/new-branch-2
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/refs/heads/new-branch-2
rename to test/integration/branchDelete/expected/repo/.git_keep/refs/heads/new-branch-2
diff --git a/test/integration/branchDelete/expected/.git_keep/refs/heads/new-branch-3 b/test/integration/branchDelete/expected/repo/.git_keep/refs/heads/new-branch-3
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/refs/heads/new-branch-3
rename to test/integration/branchDelete/expected/repo/.git_keep/refs/heads/new-branch-3
diff --git a/test/integration/branchDelete/expected/.git_keep/refs/heads/old-branch b/test/integration/branchDelete/expected/repo/.git_keep/refs/heads/old-branch
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/refs/heads/old-branch
rename to test/integration/branchDelete/expected/repo/.git_keep/refs/heads/old-branch
diff --git a/test/integration/branchDelete/expected/.git_keep/refs/heads/old-branch-3 b/test/integration/branchDelete/expected/repo/.git_keep/refs/heads/old-branch-3
similarity index 100%
rename from test/integration/branchDelete/expected/.git_keep/refs/heads/old-branch-3
rename to test/integration/branchDelete/expected/repo/.git_keep/refs/heads/old-branch-3
diff --git a/test/integration/branchDelete/expected/file0 b/test/integration/branchDelete/expected/repo/file0
similarity index 100%
rename from test/integration/branchDelete/expected/file0
rename to test/integration/branchDelete/expected/repo/file0
diff --git a/test/integration/branchRebase/expected/.git_keep/COMMIT_EDITMSG b/test/integration/branchRebase/expected/repo/.git_keep/COMMIT_EDITMSG
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/COMMIT_EDITMSG
rename to test/integration/branchRebase/expected/repo/.git_keep/COMMIT_EDITMSG
diff --git a/test/integration/branchRebase/expected/.git_keep/FETCH_HEAD b/test/integration/branchRebase/expected/repo/.git_keep/FETCH_HEAD
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/FETCH_HEAD
rename to test/integration/branchRebase/expected/repo/.git_keep/FETCH_HEAD
diff --git a/test/integration/branchRebase/expected/.git_keep/HEAD b/test/integration/branchRebase/expected/repo/.git_keep/HEAD
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/HEAD
rename to test/integration/branchRebase/expected/repo/.git_keep/HEAD
diff --git a/test/integration/branchRebase/expected/.git_keep/ORIG_HEAD b/test/integration/branchRebase/expected/repo/.git_keep/ORIG_HEAD
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/ORIG_HEAD
rename to test/integration/branchRebase/expected/repo/.git_keep/ORIG_HEAD
diff --git a/test/integration/branchRebase/expected/.git_keep/config b/test/integration/branchRebase/expected/repo/.git_keep/config
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/config
rename to test/integration/branchRebase/expected/repo/.git_keep/config
diff --git a/test/integration/branchRebase/expected/.git_keep/description b/test/integration/branchRebase/expected/repo/.git_keep/description
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/description
rename to test/integration/branchRebase/expected/repo/.git_keep/description
diff --git a/test/integration/branchRebase/expected/.git_keep/index b/test/integration/branchRebase/expected/repo/.git_keep/index
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/index
rename to test/integration/branchRebase/expected/repo/.git_keep/index
diff --git a/test/integration/branchRebase/expected/.git_keep/info/exclude b/test/integration/branchRebase/expected/repo/.git_keep/info/exclude
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/info/exclude
rename to test/integration/branchRebase/expected/repo/.git_keep/info/exclude
diff --git a/test/integration/branchRebase/expected/.git_keep/logs/HEAD b/test/integration/branchRebase/expected/repo/.git_keep/logs/HEAD
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/logs/HEAD
rename to test/integration/branchRebase/expected/repo/.git_keep/logs/HEAD
diff --git a/test/integration/branchRebase/expected/.git_keep/logs/refs/heads/develop b/test/integration/branchRebase/expected/repo/.git_keep/logs/refs/heads/develop
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/logs/refs/heads/develop
rename to test/integration/branchRebase/expected/repo/.git_keep/logs/refs/heads/develop
diff --git a/test/integration/branchRebase/expected/.git_keep/logs/refs/heads/master b/test/integration/branchRebase/expected/repo/.git_keep/logs/refs/heads/master
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/logs/refs/heads/master
rename to test/integration/branchRebase/expected/repo/.git_keep/logs/refs/heads/master
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/09/cbe8c6717c06a61876b7b641a46a62bf3c585d b/test/integration/branchRebase/expected/repo/.git_keep/objects/09/cbe8c6717c06a61876b7b641a46a62bf3c585d
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/09/cbe8c6717c06a61876b7b641a46a62bf3c585d
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/09/cbe8c6717c06a61876b7b641a46a62bf3c585d
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827 b/test/integration/branchRebase/expected/repo/.git_keep/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/1b/9ae5f5dff631baaa180a30afd9983f83dc27ca b/test/integration/branchRebase/expected/repo/.git_keep/objects/1b/9ae5f5dff631baaa180a30afd9983f83dc27ca
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/1b/9ae5f5dff631baaa180a30afd9983f83dc27ca
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/1b/9ae5f5dff631baaa180a30afd9983f83dc27ca
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/21/78af7503938665881174069be4d48fa483e4af b/test/integration/branchRebase/expected/repo/.git_keep/objects/21/78af7503938665881174069be4d48fa483e4af
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/21/78af7503938665881174069be4d48fa483e4af
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/21/78af7503938665881174069be4d48fa483e4af
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/2e/83133d8d6b88c588de66c3ff8405501b5215b4 b/test/integration/branchRebase/expected/repo/.git_keep/objects/2e/83133d8d6b88c588de66c3ff8405501b5215b4
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/2e/83133d8d6b88c588de66c3ff8405501b5215b4
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/2e/83133d8d6b88c588de66c3ff8405501b5215b4
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/34/c74161eef968fc951cf170a011fa8abfeddbcd b/test/integration/branchRebase/expected/repo/.git_keep/objects/34/c74161eef968fc951cf170a011fa8abfeddbcd
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/34/c74161eef968fc951cf170a011fa8abfeddbcd
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/34/c74161eef968fc951cf170a011fa8abfeddbcd
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/36/27f93f3cc779dc2f99484fb8ffa49953e43b2f b/test/integration/branchRebase/expected/repo/.git_keep/objects/36/27f93f3cc779dc2f99484fb8ffa49953e43b2f
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/36/27f93f3cc779dc2f99484fb8ffa49953e43b2f
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/36/27f93f3cc779dc2f99484fb8ffa49953e43b2f
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/3e/1706cdf670f5641be0715178471abfc9ed1748 b/test/integration/branchRebase/expected/repo/.git_keep/objects/3e/1706cdf670f5641be0715178471abfc9ed1748
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/3e/1706cdf670f5641be0715178471abfc9ed1748
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/3e/1706cdf670f5641be0715178471abfc9ed1748
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/42/1b29bba240f23ea39e216bb0873cd4012624b5 b/test/integration/branchRebase/expected/repo/.git_keep/objects/42/1b29bba240f23ea39e216bb0873cd4012624b5
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/42/1b29bba240f23ea39e216bb0873cd4012624b5
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/42/1b29bba240f23ea39e216bb0873cd4012624b5
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/42/597904331c82f6d5c8c902755c8dfa5767ea95 b/test/integration/branchRebase/expected/repo/.git_keep/objects/42/597904331c82f6d5c8c902755c8dfa5767ea95
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/42/597904331c82f6d5c8c902755c8dfa5767ea95
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/42/597904331c82f6d5c8c902755c8dfa5767ea95
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/4f/80ec0c7b09eeeb580d0c19947477c02bc88c25 b/test/integration/branchRebase/expected/repo/.git_keep/objects/4f/80ec0c7b09eeeb580d0c19947477c02bc88c25
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/4f/80ec0c7b09eeeb580d0c19947477c02bc88c25
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/4f/80ec0c7b09eeeb580d0c19947477c02bc88c25
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/5d/874a902548f753e50944827e572a7470aa9731 b/test/integration/branchRebase/expected/repo/.git_keep/objects/5d/874a902548f753e50944827e572a7470aa9731
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/5d/874a902548f753e50944827e572a7470aa9731
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/5d/874a902548f753e50944827e572a7470aa9731
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/5f/3e4598b46a912f0f95a4898743e979343c82f3 b/test/integration/branchRebase/expected/repo/.git_keep/objects/5f/3e4598b46a912f0f95a4898743e979343c82f3
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/5f/3e4598b46a912f0f95a4898743e979343c82f3
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/5f/3e4598b46a912f0f95a4898743e979343c82f3
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/60/91d709b275e712111d016d9b3a4fb44e63f1f6 b/test/integration/branchRebase/expected/repo/.git_keep/objects/60/91d709b275e712111d016d9b3a4fb44e63f1f6
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/60/91d709b275e712111d016d9b3a4fb44e63f1f6
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/60/91d709b275e712111d016d9b3a4fb44e63f1f6
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/69/f11ae88c8712fe38ffd0fe9ff9df05371500a6 b/test/integration/branchRebase/expected/repo/.git_keep/objects/69/f11ae88c8712fe38ffd0fe9ff9df05371500a6
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/69/f11ae88c8712fe38ffd0fe9ff9df05371500a6
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/69/f11ae88c8712fe38ffd0fe9ff9df05371500a6
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/70/95508e3cd0fd40572f8e711170db38ef2342d7 b/test/integration/branchRebase/expected/repo/.git_keep/objects/70/95508e3cd0fd40572f8e711170db38ef2342d7
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/70/95508e3cd0fd40572f8e711170db38ef2342d7
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/70/95508e3cd0fd40572f8e711170db38ef2342d7
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/7a/45b8933308e43f2597ee5d290862a62a9b46b3 b/test/integration/branchRebase/expected/repo/.git_keep/objects/7a/45b8933308e43f2597ee5d290862a62a9b46b3
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/7a/45b8933308e43f2597ee5d290862a62a9b46b3
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/7a/45b8933308e43f2597ee5d290862a62a9b46b3
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/88/c39cdc29c995f8e1a63ccd48e7bbd6d96cb8b8 b/test/integration/branchRebase/expected/repo/.git_keep/objects/88/c39cdc29c995f8e1a63ccd48e7bbd6d96cb8b8
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/88/c39cdc29c995f8e1a63ccd48e7bbd6d96cb8b8
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/88/c39cdc29c995f8e1a63ccd48e7bbd6d96cb8b8
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/8c/7a45270a95d66c8e3b843df3f466be5dc19960 b/test/integration/branchRebase/expected/repo/.git_keep/objects/8c/7a45270a95d66c8e3b843df3f466be5dc19960
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/8c/7a45270a95d66c8e3b843df3f466be5dc19960
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/8c/7a45270a95d66c8e3b843df3f466be5dc19960
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/8d/3bd1cbd5560c759c78a948bc0d24acb9cfae73 b/test/integration/branchRebase/expected/repo/.git_keep/objects/8d/3bd1cbd5560c759c78a948bc0d24acb9cfae73
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/8d/3bd1cbd5560c759c78a948bc0d24acb9cfae73
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/8d/3bd1cbd5560c759c78a948bc0d24acb9cfae73
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/9a/6521a3788b4d9e679b1709130ff8dc3f73ab18 b/test/integration/branchRebase/expected/repo/.git_keep/objects/9a/6521a3788b4d9e679b1709130ff8dc3f73ab18
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/9a/6521a3788b4d9e679b1709130ff8dc3f73ab18
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/9a/6521a3788b4d9e679b1709130ff8dc3f73ab18
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/9d/e8260b738a34a74533df54f2e404276aa96242 b/test/integration/branchRebase/expected/repo/.git_keep/objects/9d/e8260b738a34a74533df54f2e404276aa96242
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/9d/e8260b738a34a74533df54f2e404276aa96242
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/9d/e8260b738a34a74533df54f2e404276aa96242
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/a5/bce3fd2565d8f458555a0c6f42d0504a848bd5 b/test/integration/branchRebase/expected/repo/.git_keep/objects/a5/bce3fd2565d8f458555a0c6f42d0504a848bd5
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/a5/bce3fd2565d8f458555a0c6f42d0504a848bd5
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/a5/bce3fd2565d8f458555a0c6f42d0504a848bd5
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/a8/381c9130b03aef530b60b5a4546b93dc59ae12 b/test/integration/branchRebase/expected/repo/.git_keep/objects/a8/381c9130b03aef530b60b5a4546b93dc59ae12
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/a8/381c9130b03aef530b60b5a4546b93dc59ae12
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/a8/381c9130b03aef530b60b5a4546b93dc59ae12
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/cb/289e645aed5251ce74fa2eaf0cd1145b9cb014 b/test/integration/branchRebase/expected/repo/.git_keep/objects/cb/289e645aed5251ce74fa2eaf0cd1145b9cb014
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/cb/289e645aed5251ce74fa2eaf0cd1145b9cb014
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/cb/289e645aed5251ce74fa2eaf0cd1145b9cb014
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/cc/52f7d833c761b3b11a5fa1ae76ba9aba2edd6f b/test/integration/branchRebase/expected/repo/.git_keep/objects/cc/52f7d833c761b3b11a5fa1ae76ba9aba2edd6f
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/cc/52f7d833c761b3b11a5fa1ae76ba9aba2edd6f
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/cc/52f7d833c761b3b11a5fa1ae76ba9aba2edd6f
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/dc/d348507ba1da8f6479b9d964daa302b2fb9d9c b/test/integration/branchRebase/expected/repo/.git_keep/objects/dc/d348507ba1da8f6479b9d964daa302b2fb9d9c
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/dc/d348507ba1da8f6479b9d964daa302b2fb9d9c
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/dc/d348507ba1da8f6479b9d964daa302b2fb9d9c
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/df/6b0d2bcc76e6ec0fca20c227104a4f28bac41b b/test/integration/branchRebase/expected/repo/.git_keep/objects/df/6b0d2bcc76e6ec0fca20c227104a4f28bac41b
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/df/6b0d2bcc76e6ec0fca20c227104a4f28bac41b
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/df/6b0d2bcc76e6ec0fca20c227104a4f28bac41b
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/e3/ae5c6d8407e8307b9bc77923be78c901408f6e b/test/integration/branchRebase/expected/repo/.git_keep/objects/e3/ae5c6d8407e8307b9bc77923be78c901408f6e
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/e3/ae5c6d8407e8307b9bc77923be78c901408f6e
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/e3/ae5c6d8407e8307b9bc77923be78c901408f6e
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/e4/666ba294866d5c16f9afebcacf8f4adfee7439 b/test/integration/branchRebase/expected/repo/.git_keep/objects/e4/666ba294866d5c16f9afebcacf8f4adfee7439
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/e4/666ba294866d5c16f9afebcacf8f4adfee7439
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/e4/666ba294866d5c16f9afebcacf8f4adfee7439
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/e9/57aaf2eef0c03a9052b472d4862d9ee684c3e5 b/test/integration/branchRebase/expected/repo/.git_keep/objects/e9/57aaf2eef0c03a9052b472d4862d9ee684c3e5
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/e9/57aaf2eef0c03a9052b472d4862d9ee684c3e5
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/e9/57aaf2eef0c03a9052b472d4862d9ee684c3e5
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/f3/f762af4429ae89fa0dae3d0a5b500ca11630c4 b/test/integration/branchRebase/expected/repo/.git_keep/objects/f3/f762af4429ae89fa0dae3d0a5b500ca11630c4
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/f3/f762af4429ae89fa0dae3d0a5b500ca11630c4
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/f3/f762af4429ae89fa0dae3d0a5b500ca11630c4
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/f5/067da83b48f8588edce682fd2715a575f34373 b/test/integration/branchRebase/expected/repo/.git_keep/objects/f5/067da83b48f8588edce682fd2715a575f34373
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/f5/067da83b48f8588edce682fd2715a575f34373
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/f5/067da83b48f8588edce682fd2715a575f34373
diff --git a/test/integration/branchRebase/expected/.git_keep/objects/fe/427b52bbbe9dac81b463a162f37ab979ca772b b/test/integration/branchRebase/expected/repo/.git_keep/objects/fe/427b52bbbe9dac81b463a162f37ab979ca772b
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/objects/fe/427b52bbbe9dac81b463a162f37ab979ca772b
rename to test/integration/branchRebase/expected/repo/.git_keep/objects/fe/427b52bbbe9dac81b463a162f37ab979ca772b
diff --git a/test/integration/branchRebase/expected/.git_keep/refs/heads/develop b/test/integration/branchRebase/expected/repo/.git_keep/refs/heads/develop
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/refs/heads/develop
rename to test/integration/branchRebase/expected/repo/.git_keep/refs/heads/develop
diff --git a/test/integration/branchRebase/expected/.git_keep/refs/heads/master b/test/integration/branchRebase/expected/repo/.git_keep/refs/heads/master
similarity index 100%
rename from test/integration/branchRebase/expected/.git_keep/refs/heads/master
rename to test/integration/branchRebase/expected/repo/.git_keep/refs/heads/master
diff --git a/test/integration/branchRebase/expected/directory/file b/test/integration/branchRebase/expected/repo/directory/file
similarity index 100%
rename from test/integration/branchRebase/expected/directory/file
rename to test/integration/branchRebase/expected/repo/directory/file
diff --git a/test/integration/branchRebase/expected/directory/file2 b/test/integration/branchRebase/expected/repo/directory/file2
similarity index 100%
rename from test/integration/branchRebase/expected/directory/file2
rename to test/integration/branchRebase/expected/repo/directory/file2
diff --git a/test/integration/branchRebase/expected/file1 b/test/integration/branchRebase/expected/repo/file1
similarity index 100%
rename from test/integration/branchRebase/expected/file1
rename to test/integration/branchRebase/expected/repo/file1
diff --git a/test/integration/branchRebase/expected/file3 b/test/integration/branchRebase/expected/repo/file3
similarity index 100%
rename from test/integration/branchRebase/expected/file3
rename to test/integration/branchRebase/expected/repo/file3
diff --git a/test/integration/branchRebase/expected/file4 b/test/integration/branchRebase/expected/repo/file4
similarity index 100%
rename from test/integration/branchRebase/expected/file4
rename to test/integration/branchRebase/expected/repo/file4
diff --git a/test/integration/branchRebase/expected/file5 b/test/integration/branchRebase/expected/repo/file5
similarity index 100%
rename from test/integration/branchRebase/expected/file5
rename to test/integration/branchRebase/expected/repo/file5
diff --git a/test/integration/branchReset/expected/.git_keep/COMMIT_EDITMSG b/test/integration/branchReset/expected/repo/.git_keep/COMMIT_EDITMSG
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/COMMIT_EDITMSG
rename to test/integration/branchReset/expected/repo/.git_keep/COMMIT_EDITMSG
diff --git a/test/integration/branchReset/expected/.git_keep/FETCH_HEAD b/test/integration/branchReset/expected/repo/.git_keep/FETCH_HEAD
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/FETCH_HEAD
rename to test/integration/branchReset/expected/repo/.git_keep/FETCH_HEAD
diff --git a/test/integration/branchReset/expected/.git_keep/HEAD b/test/integration/branchReset/expected/repo/.git_keep/HEAD
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/HEAD
rename to test/integration/branchReset/expected/repo/.git_keep/HEAD
diff --git a/test/integration/branchReset/expected/.git_keep/ORIG_HEAD b/test/integration/branchReset/expected/repo/.git_keep/ORIG_HEAD
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/ORIG_HEAD
rename to test/integration/branchReset/expected/repo/.git_keep/ORIG_HEAD
diff --git a/test/integration/branchReset/expected/.git_keep/config b/test/integration/branchReset/expected/repo/.git_keep/config
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/config
rename to test/integration/branchReset/expected/repo/.git_keep/config
diff --git a/test/integration/branchReset/expected/.git_keep/description b/test/integration/branchReset/expected/repo/.git_keep/description
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/description
rename to test/integration/branchReset/expected/repo/.git_keep/description
diff --git a/test/integration/branchReset/expected/.git_keep/index b/test/integration/branchReset/expected/repo/.git_keep/index
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/index
rename to test/integration/branchReset/expected/repo/.git_keep/index
diff --git a/test/integration/branchReset/expected/.git_keep/info/exclude b/test/integration/branchReset/expected/repo/.git_keep/info/exclude
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/info/exclude
rename to test/integration/branchReset/expected/repo/.git_keep/info/exclude
diff --git a/test/integration/branchReset/expected/.git_keep/logs/HEAD b/test/integration/branchReset/expected/repo/.git_keep/logs/HEAD
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/logs/HEAD
rename to test/integration/branchReset/expected/repo/.git_keep/logs/HEAD
diff --git a/test/integration/branchReset/expected/.git_keep/logs/refs/heads/develop b/test/integration/branchReset/expected/repo/.git_keep/logs/refs/heads/develop
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/logs/refs/heads/develop
rename to test/integration/branchReset/expected/repo/.git_keep/logs/refs/heads/develop
diff --git a/test/integration/branchReset/expected/.git_keep/logs/refs/heads/master b/test/integration/branchReset/expected/repo/.git_keep/logs/refs/heads/master
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/logs/refs/heads/master
rename to test/integration/branchReset/expected/repo/.git_keep/logs/refs/heads/master
diff --git a/test/integration/branchReset/expected/.git_keep/objects/09/cbe8c6717c06a61876b7b641a46a62bf3c585d b/test/integration/branchReset/expected/repo/.git_keep/objects/09/cbe8c6717c06a61876b7b641a46a62bf3c585d
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/09/cbe8c6717c06a61876b7b641a46a62bf3c585d
rename to test/integration/branchReset/expected/repo/.git_keep/objects/09/cbe8c6717c06a61876b7b641a46a62bf3c585d
diff --git a/test/integration/branchReset/expected/.git_keep/objects/10/6606554f129e8b6e4b942908734deef5628dcd b/test/integration/branchReset/expected/repo/.git_keep/objects/10/6606554f129e8b6e4b942908734deef5628dcd
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/10/6606554f129e8b6e4b942908734deef5628dcd
rename to test/integration/branchReset/expected/repo/.git_keep/objects/10/6606554f129e8b6e4b942908734deef5628dcd
diff --git a/test/integration/branchReset/expected/.git_keep/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827 b/test/integration/branchReset/expected/repo/.git_keep/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827
rename to test/integration/branchReset/expected/repo/.git_keep/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827
diff --git a/test/integration/branchReset/expected/.git_keep/objects/1b/9ae5f5dff631baaa180a30afd9983f83dc27ca b/test/integration/branchReset/expected/repo/.git_keep/objects/1b/9ae5f5dff631baaa180a30afd9983f83dc27ca
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/1b/9ae5f5dff631baaa180a30afd9983f83dc27ca
rename to test/integration/branchReset/expected/repo/.git_keep/objects/1b/9ae5f5dff631baaa180a30afd9983f83dc27ca
diff --git a/test/integration/branchReset/expected/.git_keep/objects/21/78af7503938665881174069be4d48fa483e4af b/test/integration/branchReset/expected/repo/.git_keep/objects/21/78af7503938665881174069be4d48fa483e4af
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/21/78af7503938665881174069be4d48fa483e4af
rename to test/integration/branchReset/expected/repo/.git_keep/objects/21/78af7503938665881174069be4d48fa483e4af
diff --git a/test/integration/branchReset/expected/.git_keep/objects/27/ba706fa463253f9189b2f258430877d2b5ed4f b/test/integration/branchReset/expected/repo/.git_keep/objects/27/ba706fa463253f9189b2f258430877d2b5ed4f
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/27/ba706fa463253f9189b2f258430877d2b5ed4f
rename to test/integration/branchReset/expected/repo/.git_keep/objects/27/ba706fa463253f9189b2f258430877d2b5ed4f
diff --git a/test/integration/branchReset/expected/.git_keep/objects/34/c74161eef968fc951cf170a011fa8abfeddbcd b/test/integration/branchReset/expected/repo/.git_keep/objects/34/c74161eef968fc951cf170a011fa8abfeddbcd
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/34/c74161eef968fc951cf170a011fa8abfeddbcd
rename to test/integration/branchReset/expected/repo/.git_keep/objects/34/c74161eef968fc951cf170a011fa8abfeddbcd
diff --git a/test/integration/branchReset/expected/.git_keep/objects/37/14b55ba17f3d8b0233c6e5924a5e497eb09bb7 b/test/integration/branchReset/expected/repo/.git_keep/objects/37/14b55ba17f3d8b0233c6e5924a5e497eb09bb7
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/37/14b55ba17f3d8b0233c6e5924a5e497eb09bb7
rename to test/integration/branchReset/expected/repo/.git_keep/objects/37/14b55ba17f3d8b0233c6e5924a5e497eb09bb7
diff --git a/test/integration/branchReset/expected/.git_keep/objects/4f/80ec0c7b09eeeb580d0c19947477c02bc88c25 b/test/integration/branchReset/expected/repo/.git_keep/objects/4f/80ec0c7b09eeeb580d0c19947477c02bc88c25
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/4f/80ec0c7b09eeeb580d0c19947477c02bc88c25
rename to test/integration/branchReset/expected/repo/.git_keep/objects/4f/80ec0c7b09eeeb580d0c19947477c02bc88c25
diff --git a/test/integration/branchReset/expected/.git_keep/objects/5f/3e4598b46a912f0f95a4898743e979343c82f3 b/test/integration/branchReset/expected/repo/.git_keep/objects/5f/3e4598b46a912f0f95a4898743e979343c82f3
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/5f/3e4598b46a912f0f95a4898743e979343c82f3
rename to test/integration/branchReset/expected/repo/.git_keep/objects/5f/3e4598b46a912f0f95a4898743e979343c82f3
diff --git a/test/integration/branchReset/expected/.git_keep/objects/60/91d709b275e712111d016d9b3a4fb44e63f1f6 b/test/integration/branchReset/expected/repo/.git_keep/objects/60/91d709b275e712111d016d9b3a4fb44e63f1f6
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/60/91d709b275e712111d016d9b3a4fb44e63f1f6
rename to test/integration/branchReset/expected/repo/.git_keep/objects/60/91d709b275e712111d016d9b3a4fb44e63f1f6
diff --git a/test/integration/branchReset/expected/.git_keep/objects/7a/45b8933308e43f2597ee5d290862a62a9b46b3 b/test/integration/branchReset/expected/repo/.git_keep/objects/7a/45b8933308e43f2597ee5d290862a62a9b46b3
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/7a/45b8933308e43f2597ee5d290862a62a9b46b3
rename to test/integration/branchReset/expected/repo/.git_keep/objects/7a/45b8933308e43f2597ee5d290862a62a9b46b3
diff --git a/test/integration/branchReset/expected/.git_keep/objects/7c/d86f0c3e1894b4270d0bf9fa246c33568f9bf1 b/test/integration/branchReset/expected/repo/.git_keep/objects/7c/d86f0c3e1894b4270d0bf9fa246c33568f9bf1
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/7c/d86f0c3e1894b4270d0bf9fa246c33568f9bf1
rename to test/integration/branchReset/expected/repo/.git_keep/objects/7c/d86f0c3e1894b4270d0bf9fa246c33568f9bf1
diff --git a/test/integration/branchReset/expected/.git_keep/objects/88/c39cdc29c995f8e1a63ccd48e7bbd6d96cb8b8 b/test/integration/branchReset/expected/repo/.git_keep/objects/88/c39cdc29c995f8e1a63ccd48e7bbd6d96cb8b8
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/88/c39cdc29c995f8e1a63ccd48e7bbd6d96cb8b8
rename to test/integration/branchReset/expected/repo/.git_keep/objects/88/c39cdc29c995f8e1a63ccd48e7bbd6d96cb8b8
diff --git a/test/integration/branchReset/expected/.git_keep/objects/8b/24e74245461f6ad529c77c040fe17e415cf3da b/test/integration/branchReset/expected/repo/.git_keep/objects/8b/24e74245461f6ad529c77c040fe17e415cf3da
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/8b/24e74245461f6ad529c77c040fe17e415cf3da
rename to test/integration/branchReset/expected/repo/.git_keep/objects/8b/24e74245461f6ad529c77c040fe17e415cf3da
diff --git a/test/integration/branchReset/expected/.git_keep/objects/8c/7a45270a95d66c8e3b843df3f466be5dc19960 b/test/integration/branchReset/expected/repo/.git_keep/objects/8c/7a45270a95d66c8e3b843df3f466be5dc19960
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/8c/7a45270a95d66c8e3b843df3f466be5dc19960
rename to test/integration/branchReset/expected/repo/.git_keep/objects/8c/7a45270a95d66c8e3b843df3f466be5dc19960
diff --git a/test/integration/branchReset/expected/.git_keep/objects/9d/e8260b738a34a74533df54f2e404276aa96242 b/test/integration/branchReset/expected/repo/.git_keep/objects/9d/e8260b738a34a74533df54f2e404276aa96242
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/9d/e8260b738a34a74533df54f2e404276aa96242
rename to test/integration/branchReset/expected/repo/.git_keep/objects/9d/e8260b738a34a74533df54f2e404276aa96242
diff --git a/test/integration/branchReset/expected/.git_keep/objects/a5/bce3fd2565d8f458555a0c6f42d0504a848bd5 b/test/integration/branchReset/expected/repo/.git_keep/objects/a5/bce3fd2565d8f458555a0c6f42d0504a848bd5
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/a5/bce3fd2565d8f458555a0c6f42d0504a848bd5
rename to test/integration/branchReset/expected/repo/.git_keep/objects/a5/bce3fd2565d8f458555a0c6f42d0504a848bd5
diff --git a/test/integration/branchReset/expected/.git_keep/objects/a7/3e01f9d181ff16b3d821dd962e98accbd62936 b/test/integration/branchReset/expected/repo/.git_keep/objects/a7/3e01f9d181ff16b3d821dd962e98accbd62936
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/a7/3e01f9d181ff16b3d821dd962e98accbd62936
rename to test/integration/branchReset/expected/repo/.git_keep/objects/a7/3e01f9d181ff16b3d821dd962e98accbd62936
diff --git a/test/integration/branchReset/expected/.git_keep/objects/bd/8db1919bbbbd4509ad4d9fa3baf546460e17a2 b/test/integration/branchReset/expected/repo/.git_keep/objects/bd/8db1919bbbbd4509ad4d9fa3baf546460e17a2
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/bd/8db1919bbbbd4509ad4d9fa3baf546460e17a2
rename to test/integration/branchReset/expected/repo/.git_keep/objects/bd/8db1919bbbbd4509ad4d9fa3baf546460e17a2
diff --git a/test/integration/branchReset/expected/.git_keep/objects/c2/3c3e0496a9b3decc42344bfd94514f0834b93c b/test/integration/branchReset/expected/repo/.git_keep/objects/c2/3c3e0496a9b3decc42344bfd94514f0834b93c
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/c2/3c3e0496a9b3decc42344bfd94514f0834b93c
rename to test/integration/branchReset/expected/repo/.git_keep/objects/c2/3c3e0496a9b3decc42344bfd94514f0834b93c
diff --git a/test/integration/branchReset/expected/.git_keep/objects/cb/289e645aed5251ce74fa2eaf0cd1145b9cb014 b/test/integration/branchReset/expected/repo/.git_keep/objects/cb/289e645aed5251ce74fa2eaf0cd1145b9cb014
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/cb/289e645aed5251ce74fa2eaf0cd1145b9cb014
rename to test/integration/branchReset/expected/repo/.git_keep/objects/cb/289e645aed5251ce74fa2eaf0cd1145b9cb014
diff --git a/test/integration/branchReset/expected/.git_keep/objects/dc/d348507ba1da8f6479b9d964daa302b2fb9d9c b/test/integration/branchReset/expected/repo/.git_keep/objects/dc/d348507ba1da8f6479b9d964daa302b2fb9d9c
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/dc/d348507ba1da8f6479b9d964daa302b2fb9d9c
rename to test/integration/branchReset/expected/repo/.git_keep/objects/dc/d348507ba1da8f6479b9d964daa302b2fb9d9c
diff --git a/test/integration/branchReset/expected/.git_keep/objects/df/6b0d2bcc76e6ec0fca20c227104a4f28bac41b b/test/integration/branchReset/expected/repo/.git_keep/objects/df/6b0d2bcc76e6ec0fca20c227104a4f28bac41b
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/df/6b0d2bcc76e6ec0fca20c227104a4f28bac41b
rename to test/integration/branchReset/expected/repo/.git_keep/objects/df/6b0d2bcc76e6ec0fca20c227104a4f28bac41b
diff --git a/test/integration/branchReset/expected/.git_keep/objects/e3/ae5c6d8407e8307b9bc77923be78c901408f6e b/test/integration/branchReset/expected/repo/.git_keep/objects/e3/ae5c6d8407e8307b9bc77923be78c901408f6e
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/e3/ae5c6d8407e8307b9bc77923be78c901408f6e
rename to test/integration/branchReset/expected/repo/.git_keep/objects/e3/ae5c6d8407e8307b9bc77923be78c901408f6e
diff --git a/test/integration/branchReset/expected/.git_keep/objects/e4/666ba294866d5c16f9afebcacf8f4adfee7439 b/test/integration/branchReset/expected/repo/.git_keep/objects/e4/666ba294866d5c16f9afebcacf8f4adfee7439
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/e4/666ba294866d5c16f9afebcacf8f4adfee7439
rename to test/integration/branchReset/expected/repo/.git_keep/objects/e4/666ba294866d5c16f9afebcacf8f4adfee7439
diff --git a/test/integration/branchReset/expected/.git_keep/objects/ee/a0cf47f42fe8d027b4cecaf534ecc0673f7981 b/test/integration/branchReset/expected/repo/.git_keep/objects/ee/a0cf47f42fe8d027b4cecaf534ecc0673f7981
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/ee/a0cf47f42fe8d027b4cecaf534ecc0673f7981
rename to test/integration/branchReset/expected/repo/.git_keep/objects/ee/a0cf47f42fe8d027b4cecaf534ecc0673f7981
diff --git a/test/integration/branchReset/expected/.git_keep/objects/f3/f762af4429ae89fa0dae3d0a5b500ca11630c4 b/test/integration/branchReset/expected/repo/.git_keep/objects/f3/f762af4429ae89fa0dae3d0a5b500ca11630c4
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/f3/f762af4429ae89fa0dae3d0a5b500ca11630c4
rename to test/integration/branchReset/expected/repo/.git_keep/objects/f3/f762af4429ae89fa0dae3d0a5b500ca11630c4
diff --git a/test/integration/branchReset/expected/.git_keep/objects/fe/427b52bbbe9dac81b463a162f37ab979ca772b b/test/integration/branchReset/expected/repo/.git_keep/objects/fe/427b52bbbe9dac81b463a162f37ab979ca772b
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/objects/fe/427b52bbbe9dac81b463a162f37ab979ca772b
rename to test/integration/branchReset/expected/repo/.git_keep/objects/fe/427b52bbbe9dac81b463a162f37ab979ca772b
diff --git a/test/integration/branchReset/expected/.git_keep/refs/heads/develop b/test/integration/branchReset/expected/repo/.git_keep/refs/heads/develop
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/refs/heads/develop
rename to test/integration/branchReset/expected/repo/.git_keep/refs/heads/develop
diff --git a/test/integration/branchReset/expected/.git_keep/refs/heads/master b/test/integration/branchReset/expected/repo/.git_keep/refs/heads/master
similarity index 100%
rename from test/integration/branchReset/expected/.git_keep/refs/heads/master
rename to test/integration/branchReset/expected/repo/.git_keep/refs/heads/master
diff --git a/test/integration/branchReset/expected/directory/file b/test/integration/branchReset/expected/repo/directory/file
similarity index 100%
rename from test/integration/branchReset/expected/directory/file
rename to test/integration/branchReset/expected/repo/directory/file
diff --git a/test/integration/branchReset/expected/directory/file2 b/test/integration/branchReset/expected/repo/directory/file2
similarity index 100%
rename from test/integration/branchReset/expected/directory/file2
rename to test/integration/branchReset/expected/repo/directory/file2
diff --git a/test/integration/branchReset/expected/file1 b/test/integration/branchReset/expected/repo/file1
similarity index 100%
rename from test/integration/branchReset/expected/file1
rename to test/integration/branchReset/expected/repo/file1
diff --git a/test/integration/branchReset/expected/file3 b/test/integration/branchReset/expected/repo/file3
similarity index 100%
rename from test/integration/branchReset/expected/file3
rename to test/integration/branchReset/expected/repo/file3
diff --git a/test/integration/branchReset/expected/file4 b/test/integration/branchReset/expected/repo/file4
similarity index 100%
rename from test/integration/branchReset/expected/file4
rename to test/integration/branchReset/expected/repo/file4
diff --git a/test/integration/branchReset/expected/file5 b/test/integration/branchReset/expected/repo/file5
similarity index 100%
rename from test/integration/branchReset/expected/file5
rename to test/integration/branchReset/expected/repo/file5
diff --git a/test/integration/branchSuggestions/expected/.git_keep/HEAD b/test/integration/branchSuggestions/expected/.git_keep/HEAD
deleted file mode 100644
index e2b7d4d2e..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/HEAD
+++ /dev/null
@@ -1 +0,0 @@
-ref: refs/heads/new-branch-3
diff --git a/test/integration/branchSuggestions/expected/.git_keep/index b/test/integration/branchSuggestions/expected/.git_keep/index
deleted file mode 100644
index fbde0e92a..000000000
Binary files a/test/integration/branchSuggestions/expected/.git_keep/index and /dev/null differ
diff --git a/test/integration/branchSuggestions/expected/.git_keep/logs/HEAD b/test/integration/branchSuggestions/expected/.git_keep/logs/HEAD
deleted file mode 100644
index 4deacbbf3..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/logs/HEAD
+++ /dev/null
@@ -1,8 +0,0 @@
-0000000000000000000000000000000000000000 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 commit (initial): file0
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 checkout: moving from master to new-branch
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 checkout: moving from new-branch to new-branch-2
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 checkout: moving from new-branch-2 to new-branch-3
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 checkout: moving from new-branch-3 to old-branch
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 checkout: moving from old-branch to old-branch-2
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 checkout: moving from old-branch-2 to old-branch-3
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675450 +1000 checkout: moving from old-branch-3 to new-branch-3
diff --git a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/master b/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/master
deleted file mode 100644
index 8c6dabf38..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/master
+++ /dev/null
@@ -1 +0,0 @@
-0000000000000000000000000000000000000000 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 commit (initial): file0
diff --git a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/new-branch b/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/new-branch
deleted file mode 100644
index 530a272f0..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/new-branch
+++ /dev/null
@@ -1 +0,0 @@
-0000000000000000000000000000000000000000 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 branch: Created from HEAD
diff --git a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/new-branch-2 b/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/new-branch-2
deleted file mode 100644
index 530a272f0..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/new-branch-2
+++ /dev/null
@@ -1 +0,0 @@
-0000000000000000000000000000000000000000 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 branch: Created from HEAD
diff --git a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/new-branch-3 b/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/new-branch-3
deleted file mode 100644
index 530a272f0..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/new-branch-3
+++ /dev/null
@@ -1 +0,0 @@
-0000000000000000000000000000000000000000 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 branch: Created from HEAD
diff --git a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/old-branch b/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/old-branch
deleted file mode 100644
index 530a272f0..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/old-branch
+++ /dev/null
@@ -1 +0,0 @@
-0000000000000000000000000000000000000000 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 branch: Created from HEAD
diff --git a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/old-branch-2 b/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/old-branch-2
deleted file mode 100644
index 530a272f0..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/old-branch-2
+++ /dev/null
@@ -1 +0,0 @@
-0000000000000000000000000000000000000000 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 branch: Created from HEAD
diff --git a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/old-branch-3 b/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/old-branch-3
deleted file mode 100644
index 530a272f0..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/logs/refs/heads/old-branch-3
+++ /dev/null
@@ -1 +0,0 @@
-0000000000000000000000000000000000000000 75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 CI 1617675445 +1000 branch: Created from HEAD
diff --git a/test/integration/branchSuggestions/expected/.git_keep/objects/75/e9e90a1d58c37d97d46a543dfbfd0f33fc52d8 b/test/integration/branchSuggestions/expected/.git_keep/objects/75/e9e90a1d58c37d97d46a543dfbfd0f33fc52d8
deleted file mode 100644
index 16270b9a6..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/objects/75/e9e90a1d58c37d97d46a543dfbfd0f33fc52d8
+++ /dev/null
@@ -1,2 +0,0 @@
-xŤÍA
-Â0Fa×9Ĺ왉“Ä€ĐUŹ‘4°Đ)<ľ=‚ŰÇoéDő4v€WřcŚ%‹>ř–«V¶ąTˇQŐšôŻľÓ4Ó}šźř¦öŢpYz{x >8UGgafsÔc2đ'7uÝŔćÜď+ö
\ No newline at end of file
diff --git a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/master b/test/integration/branchSuggestions/expected/.git_keep/refs/heads/master
deleted file mode 100644
index ae478b1c7..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/master
+++ /dev/null
@@ -1 +0,0 @@
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8
diff --git a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/new-branch b/test/integration/branchSuggestions/expected/.git_keep/refs/heads/new-branch
deleted file mode 100644
index ae478b1c7..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/new-branch
+++ /dev/null
@@ -1 +0,0 @@
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8
diff --git a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/new-branch-2 b/test/integration/branchSuggestions/expected/.git_keep/refs/heads/new-branch-2
deleted file mode 100644
index ae478b1c7..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/new-branch-2
+++ /dev/null
@@ -1 +0,0 @@
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8
diff --git a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/new-branch-3 b/test/integration/branchSuggestions/expected/.git_keep/refs/heads/new-branch-3
deleted file mode 100644
index ae478b1c7..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/new-branch-3
+++ /dev/null
@@ -1 +0,0 @@
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8
diff --git a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/old-branch b/test/integration/branchSuggestions/expected/.git_keep/refs/heads/old-branch
deleted file mode 100644
index ae478b1c7..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/old-branch
+++ /dev/null
@@ -1 +0,0 @@
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8
diff --git a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/old-branch-2 b/test/integration/branchSuggestions/expected/.git_keep/refs/heads/old-branch-2
deleted file mode 100644
index ae478b1c7..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/old-branch-2
+++ /dev/null
@@ -1 +0,0 @@
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8
diff --git a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/old-branch-3 b/test/integration/branchSuggestions/expected/.git_keep/refs/heads/old-branch-3
deleted file mode 100644
index ae478b1c7..000000000
--- a/test/integration/branchSuggestions/expected/.git_keep/refs/heads/old-branch-3
+++ /dev/null
@@ -1 +0,0 @@
-75e9e90a1d58c37d97d46a543dfbfd0f33fc52d8
diff --git a/test/integration/branchSuggestions/recording.json b/test/integration/branchSuggestions/recording.json
deleted file mode 100644
index 207dbcb3f..000000000
--- a/test/integration/branchSuggestions/recording.json
+++ /dev/null
@@ -1 +0,0 @@
-{"KeyEvents":[{"Timestamp":639,"Mod":0,"Key":259,"Ch":0},{"Timestamp":1752,"Mod":0,"Key":256,"Ch":99},{"Timestamp":2183,"Mod":0,"Key":256,"Ch":110},{"Timestamp":2271,"Mod":0,"Key":256,"Ch":101},{"Timestamp":2327,"Mod":0,"Key":256,"Ch":119},{"Timestamp":2599,"Mod":0,"Key":256,"Ch":45},{"Timestamp":3583,"Mod":0,"Key":9,"Ch":9},{"Timestamp":3880,"Mod":0,"Key":258,"Ch":0},{"Timestamp":4175,"Mod":0,"Key":13,"Ch":13},{"Timestamp":4815,"Mod":0,"Key":256,"Ch":113}],"ResizeEvents":[{"Timestamp":0,"Width":272,"Height":74}]}
\ No newline at end of file
diff --git a/test/integration/branchSuggestions/setup.sh b/test/integration/branchSuggestions/setup.sh
deleted file mode 100644
index d67fa9291..000000000
--- a/test/integration/branchSuggestions/setup.sh
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/bin/sh
-
-set -e
-
-cd $1
-
-git init
-
-git config user.email "CI@example.com"
-git config user.name "CI"
-
-echo test0 > file0
-git add .
-git commit -am file0
-
-git checkout -b new-branch
-git checkout -b new-branch-2
-git checkout -b new-branch-3
-git checkout -b old-branch
-git checkout -b old-branch-2
-git checkout -b old-branch-3
diff --git a/test/integration/branchSuggestions/test.json b/test/integration/branchSuggestions/test.json
deleted file mode 100644
index fafad1962..000000000
--- a/test/integration/branchSuggestions/test.json
+++ /dev/null
@@ -1 +0,0 @@
-{ "description": "Checking out a branch with name suggestions", "speed": 100 }
diff --git a/test/integration/cherryPicking/expected/.git_keep/COMMIT_EDITMSG b/test/integration/cherryPicking/expected/repo/.git_keep/COMMIT_EDITMSG
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/COMMIT_EDITMSG
rename to test/integration/cherryPicking/expected/repo/.git_keep/COMMIT_EDITMSG
diff --git a/test/integration/branchSuggestions/expected/.git_keep/FETCH_HEAD b/test/integration/cherryPicking/expected/repo/.git_keep/FETCH_HEAD
similarity index 100%
rename from test/integration/branchSuggestions/expected/.git_keep/FETCH_HEAD
rename to test/integration/cherryPicking/expected/repo/.git_keep/FETCH_HEAD
diff --git a/test/integration/cherryPicking/expected/.git_keep/HEAD b/test/integration/cherryPicking/expected/repo/.git_keep/HEAD
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/HEAD
rename to test/integration/cherryPicking/expected/repo/.git_keep/HEAD
diff --git a/test/integration/cherryPicking/expected/.git_keep/ORIG_HEAD b/test/integration/cherryPicking/expected/repo/.git_keep/ORIG_HEAD
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/ORIG_HEAD
rename to test/integration/cherryPicking/expected/repo/.git_keep/ORIG_HEAD
diff --git a/test/integration/cherryPicking/expected/.git_keep/REBASE_HEAD b/test/integration/cherryPicking/expected/repo/.git_keep/REBASE_HEAD
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/REBASE_HEAD
rename to test/integration/cherryPicking/expected/repo/.git_keep/REBASE_HEAD
diff --git a/test/integration/branchSuggestions/expected/.git_keep/config b/test/integration/cherryPicking/expected/repo/.git_keep/config
similarity index 100%
rename from test/integration/branchSuggestions/expected/.git_keep/config
rename to test/integration/cherryPicking/expected/repo/.git_keep/config
diff --git a/test/integration/branchSuggestions/expected/.git_keep/description b/test/integration/cherryPicking/expected/repo/.git_keep/description
similarity index 100%
rename from test/integration/branchSuggestions/expected/.git_keep/description
rename to test/integration/cherryPicking/expected/repo/.git_keep/description
diff --git a/test/integration/cherryPicking/expected/.git_keep/index b/test/integration/cherryPicking/expected/repo/.git_keep/index
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/index
rename to test/integration/cherryPicking/expected/repo/.git_keep/index
diff --git a/test/integration/branchSuggestions/expected/.git_keep/info/exclude b/test/integration/cherryPicking/expected/repo/.git_keep/info/exclude
similarity index 100%
rename from test/integration/branchSuggestions/expected/.git_keep/info/exclude
rename to test/integration/cherryPicking/expected/repo/.git_keep/info/exclude
diff --git a/test/integration/cherryPicking/expected/.git_keep/logs/HEAD b/test/integration/cherryPicking/expected/repo/.git_keep/logs/HEAD
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/logs/HEAD
rename to test/integration/cherryPicking/expected/repo/.git_keep/logs/HEAD
diff --git a/test/integration/cherryPicking/expected/.git_keep/logs/refs/heads/base_branch b/test/integration/cherryPicking/expected/repo/.git_keep/logs/refs/heads/base_branch
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/logs/refs/heads/base_branch
rename to test/integration/cherryPicking/expected/repo/.git_keep/logs/refs/heads/base_branch
diff --git a/test/integration/cherryPicking/expected/.git_keep/logs/refs/heads/develop b/test/integration/cherryPicking/expected/repo/.git_keep/logs/refs/heads/develop
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/logs/refs/heads/develop
rename to test/integration/cherryPicking/expected/repo/.git_keep/logs/refs/heads/develop
diff --git a/test/integration/cherryPicking/expected/.git_keep/logs/refs/heads/feature/cherry-picking b/test/integration/cherryPicking/expected/repo/.git_keep/logs/refs/heads/feature/cherry-picking
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/logs/refs/heads/feature/cherry-picking
rename to test/integration/cherryPicking/expected/repo/.git_keep/logs/refs/heads/feature/cherry-picking
diff --git a/test/integration/cherryPicking/expected/.git_keep/logs/refs/heads/master b/test/integration/cherryPicking/expected/repo/.git_keep/logs/refs/heads/master
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/logs/refs/heads/master
rename to test/integration/cherryPicking/expected/repo/.git_keep/logs/refs/heads/master
diff --git a/test/integration/cherryPicking/expected/.git_keep/logs/refs/heads/other_branch b/test/integration/cherryPicking/expected/repo/.git_keep/logs/refs/heads/other_branch
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/logs/refs/heads/other_branch
rename to test/integration/cherryPicking/expected/repo/.git_keep/logs/refs/heads/other_branch
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/05/56e5da1cda4e150d6cc1182be6efdb061f59fe b/test/integration/cherryPicking/expected/repo/.git_keep/objects/05/56e5da1cda4e150d6cc1182be6efdb061f59fe
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/05/56e5da1cda4e150d6cc1182be6efdb061f59fe
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/05/56e5da1cda4e150d6cc1182be6efdb061f59fe
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/09/cbe8c6717c06a61876b7b641a46a62bf3c585d b/test/integration/cherryPicking/expected/repo/.git_keep/objects/09/cbe8c6717c06a61876b7b641a46a62bf3c585d
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/09/cbe8c6717c06a61876b7b641a46a62bf3c585d
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/09/cbe8c6717c06a61876b7b641a46a62bf3c585d
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/16/f2bcca6ce7bcc17277103a5555072a6c3322a2 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/16/f2bcca6ce7bcc17277103a5555072a6c3322a2
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/16/f2bcca6ce7bcc17277103a5555072a6c3322a2
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/16/f2bcca6ce7bcc17277103a5555072a6c3322a2
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/17/3a40ed58e33060166ccbfb7d0ccc0387be5f09 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/17/3a40ed58e33060166ccbfb7d0ccc0387be5f09
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/17/3a40ed58e33060166ccbfb7d0ccc0387be5f09
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/17/3a40ed58e33060166ccbfb7d0ccc0387be5f09
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/17/4a8c9444cfa700682d74059d9fa9be5749242c b/test/integration/cherryPicking/expected/repo/.git_keep/objects/17/4a8c9444cfa700682d74059d9fa9be5749242c
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/17/4a8c9444cfa700682d74059d9fa9be5749242c
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/17/4a8c9444cfa700682d74059d9fa9be5749242c
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/18/f469bc737f6c2a589205e2ddefceb32a7cc3a7 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/18/f469bc737f6c2a589205e2ddefceb32a7cc3a7
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/18/f469bc737f6c2a589205e2ddefceb32a7cc3a7
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/18/f469bc737f6c2a589205e2ddefceb32a7cc3a7
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/19/079c78db18112c5a2720896a040014a2d05f6d b/test/integration/cherryPicking/expected/repo/.git_keep/objects/19/079c78db18112c5a2720896a040014a2d05f6d
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/19/079c78db18112c5a2720896a040014a2d05f6d
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/19/079c78db18112c5a2720896a040014a2d05f6d
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/1b/9ae5f5dff631baaa180a30afd9983f83dc27ca b/test/integration/cherryPicking/expected/repo/.git_keep/objects/1b/9ae5f5dff631baaa180a30afd9983f83dc27ca
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/1b/9ae5f5dff631baaa180a30afd9983f83dc27ca
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/1b/9ae5f5dff631baaa180a30afd9983f83dc27ca
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/20/85c8dd0a80e95ed959e4db2ab98f66b970ad77 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/20/85c8dd0a80e95ed959e4db2ab98f66b970ad77
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/20/85c8dd0a80e95ed959e4db2ab98f66b970ad77
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/20/85c8dd0a80e95ed959e4db2ab98f66b970ad77
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/21/28c3c3def18d6e2a389957252fdb69ba85fce0 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/21/28c3c3def18d6e2a389957252fdb69ba85fce0
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/21/28c3c3def18d6e2a389957252fdb69ba85fce0
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/21/28c3c3def18d6e2a389957252fdb69ba85fce0
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/21/78af7503938665881174069be4d48fa483e4af b/test/integration/cherryPicking/expected/repo/.git_keep/objects/21/78af7503938665881174069be4d48fa483e4af
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/21/78af7503938665881174069be4d48fa483e4af
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/21/78af7503938665881174069be4d48fa483e4af
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/22/b0fd807dd5e428c2d818aef6a2311d7c11e885 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/22/b0fd807dd5e428c2d818aef6a2311d7c11e885
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/22/b0fd807dd5e428c2d818aef6a2311d7c11e885
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/22/b0fd807dd5e428c2d818aef6a2311d7c11e885
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/23/4e2fa9a01b8d7e849b0c2a1bbd550e788ea18d b/test/integration/cherryPicking/expected/repo/.git_keep/objects/23/4e2fa9a01b8d7e849b0c2a1bbd550e788ea18d
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/23/4e2fa9a01b8d7e849b0c2a1bbd550e788ea18d
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/23/4e2fa9a01b8d7e849b0c2a1bbd550e788ea18d
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/24/6f7487e08e6330ccbec4053e701145d53f64d4 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/24/6f7487e08e6330ccbec4053e701145d53f64d4
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/24/6f7487e08e6330ccbec4053e701145d53f64d4
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/24/6f7487e08e6330ccbec4053e701145d53f64d4
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/24/93c87610e0a9b8edfca592cb01a027f60ce587 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/24/93c87610e0a9b8edfca592cb01a027f60ce587
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/24/93c87610e0a9b8edfca592cb01a027f60ce587
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/24/93c87610e0a9b8edfca592cb01a027f60ce587
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/2c/f63d6da8c52131dd79622f8572b44a1267e420 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/2c/f63d6da8c52131dd79622f8572b44a1267e420
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/2c/f63d6da8c52131dd79622f8572b44a1267e420
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/2c/f63d6da8c52131dd79622f8572b44a1267e420
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/2e/cced19ece4424e0d3f26eb3ea2ccb6bfeafaa8 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/2e/cced19ece4424e0d3f26eb3ea2ccb6bfeafaa8
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/2e/cced19ece4424e0d3f26eb3ea2ccb6bfeafaa8
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/2e/cced19ece4424e0d3f26eb3ea2ccb6bfeafaa8
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/33/9e2d062760be9ecdb4bb90f97bdb0e634e7831 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/33/9e2d062760be9ecdb4bb90f97bdb0e634e7831
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/33/9e2d062760be9ecdb4bb90f97bdb0e634e7831
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/33/9e2d062760be9ecdb4bb90f97bdb0e634e7831
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/34/c74161eef968fc951cf170a011fa8abfeddbcd b/test/integration/cherryPicking/expected/repo/.git_keep/objects/34/c74161eef968fc951cf170a011fa8abfeddbcd
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/34/c74161eef968fc951cf170a011fa8abfeddbcd
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/34/c74161eef968fc951cf170a011fa8abfeddbcd
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/36/e0ef3e52c6e29e64980c71defbab6064d2da8c b/test/integration/cherryPicking/expected/repo/.git_keep/objects/36/e0ef3e52c6e29e64980c71defbab6064d2da8c
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/36/e0ef3e52c6e29e64980c71defbab6064d2da8c
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/36/e0ef3e52c6e29e64980c71defbab6064d2da8c
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/3e/0d4389ab458a8643281e494e3ebae7ce307eec b/test/integration/cherryPicking/expected/repo/.git_keep/objects/3e/0d4389ab458a8643281e494e3ebae7ce307eec
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/3e/0d4389ab458a8643281e494e3ebae7ce307eec
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/3e/0d4389ab458a8643281e494e3ebae7ce307eec
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/45/20f99d650662a3f597a200fea5f2599f528180 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/45/20f99d650662a3f597a200fea5f2599f528180
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/45/20f99d650662a3f597a200fea5f2599f528180
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/45/20f99d650662a3f597a200fea5f2599f528180
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/4f/80ec0c7b09eeeb580d0c19947477c02bc88c25 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/4f/80ec0c7b09eeeb580d0c19947477c02bc88c25
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/4f/80ec0c7b09eeeb580d0c19947477c02bc88c25
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/4f/80ec0c7b09eeeb580d0c19947477c02bc88c25
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/5d/2484f3cb6ce658e296526c48e1a376b2790dfc b/test/integration/cherryPicking/expected/repo/.git_keep/objects/5d/2484f3cb6ce658e296526c48e1a376b2790dfc
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/5d/2484f3cb6ce658e296526c48e1a376b2790dfc
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/5d/2484f3cb6ce658e296526c48e1a376b2790dfc
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/5d/a4d9200457542d875fe4def54ac98c16332db0 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/5d/a4d9200457542d875fe4def54ac98c16332db0
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/5d/a4d9200457542d875fe4def54ac98c16332db0
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/5d/a4d9200457542d875fe4def54ac98c16332db0
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/5f/3e4598b46a912f0f95a4898743e979343c82f3 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/5f/3e4598b46a912f0f95a4898743e979343c82f3
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/5f/3e4598b46a912f0f95a4898743e979343c82f3
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/5f/3e4598b46a912f0f95a4898743e979343c82f3
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/60/91d709b275e712111d016d9b3a4fb44e63f1f6 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/60/91d709b275e712111d016d9b3a4fb44e63f1f6
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/60/91d709b275e712111d016d9b3a4fb44e63f1f6
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/60/91d709b275e712111d016d9b3a4fb44e63f1f6
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/61/01e935461d4cd862ae4a720846e87880d198b9 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/61/01e935461d4cd862ae4a720846e87880d198b9
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/61/01e935461d4cd862ae4a720846e87880d198b9
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/61/01e935461d4cd862ae4a720846e87880d198b9
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/65/c0438e428cd1aa94588eaa52eb7ebad7ec62fd b/test/integration/cherryPicking/expected/repo/.git_keep/objects/65/c0438e428cd1aa94588eaa52eb7ebad7ec62fd
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/65/c0438e428cd1aa94588eaa52eb7ebad7ec62fd
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/65/c0438e428cd1aa94588eaa52eb7ebad7ec62fd
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/68/728b56ed31d03ca94496b9e2a45c62ba0f4e8f b/test/integration/cherryPicking/expected/repo/.git_keep/objects/68/728b56ed31d03ca94496b9e2a45c62ba0f4e8f
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/68/728b56ed31d03ca94496b9e2a45c62ba0f4e8f
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/68/728b56ed31d03ca94496b9e2a45c62ba0f4e8f
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/69/6a8fd43c580b3bed203977faab4566b052a4e4 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/69/6a8fd43c580b3bed203977faab4566b052a4e4
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/69/6a8fd43c580b3bed203977faab4566b052a4e4
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/69/6a8fd43c580b3bed203977faab4566b052a4e4
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/6b/6092c6840d05583489cc32a1260db0d5390a98 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/6b/6092c6840d05583489cc32a1260db0d5390a98
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/6b/6092c6840d05583489cc32a1260db0d5390a98
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/6b/6092c6840d05583489cc32a1260db0d5390a98
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/73/17cf7580efd92f974c8dfb3cde84eded8dafec b/test/integration/cherryPicking/expected/repo/.git_keep/objects/73/17cf7580efd92f974c8dfb3cde84eded8dafec
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/73/17cf7580efd92f974c8dfb3cde84eded8dafec
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/73/17cf7580efd92f974c8dfb3cde84eded8dafec
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/78/3666de4acbb22a9efc205197667f5136118c54 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/78/3666de4acbb22a9efc205197667f5136118c54
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/78/3666de4acbb22a9efc205197667f5136118c54
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/78/3666de4acbb22a9efc205197667f5136118c54
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/78/a5ec82970200538b70f5ac61c18acb45ccb8ee b/test/integration/cherryPicking/expected/repo/.git_keep/objects/78/a5ec82970200538b70f5ac61c18acb45ccb8ee
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/78/a5ec82970200538b70f5ac61c18acb45ccb8ee
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/78/a5ec82970200538b70f5ac61c18acb45ccb8ee
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/79/23e4a952f4b169373b0389be6a9db3cd929547 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/79/23e4a952f4b169373b0389be6a9db3cd929547
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/79/23e4a952f4b169373b0389be6a9db3cd929547
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/79/23e4a952f4b169373b0389be6a9db3cd929547
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/88/9b0fdfe5f2ae3d7df3066f3bc1e181fa712c8d b/test/integration/cherryPicking/expected/repo/.git_keep/objects/88/9b0fdfe5f2ae3d7df3066f3bc1e181fa712c8d
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/88/9b0fdfe5f2ae3d7df3066f3bc1e181fa712c8d
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/88/9b0fdfe5f2ae3d7df3066f3bc1e181fa712c8d
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/88/c39cdc29c995f8e1a63ccd48e7bbd6d96cb8b8 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/88/c39cdc29c995f8e1a63ccd48e7bbd6d96cb8b8
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/88/c39cdc29c995f8e1a63ccd48e7bbd6d96cb8b8
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/88/c39cdc29c995f8e1a63ccd48e7bbd6d96cb8b8
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/90/a84fd62f8033027fab3e567a81d5ed2a6a71cd b/test/integration/cherryPicking/expected/repo/.git_keep/objects/90/a84fd62f8033027fab3e567a81d5ed2a6a71cd
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/90/a84fd62f8033027fab3e567a81d5ed2a6a71cd
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/90/a84fd62f8033027fab3e567a81d5ed2a6a71cd
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/95/9d7a10da71acf97b17300b40a3b4f30903e09c b/test/integration/cherryPicking/expected/repo/.git_keep/objects/95/9d7a10da71acf97b17300b40a3b4f30903e09c
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/95/9d7a10da71acf97b17300b40a3b4f30903e09c
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/95/9d7a10da71acf97b17300b40a3b4f30903e09c
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/9b/b8cd97914c8e8a7b8a6ec6f94bca0b09fa0048 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/9b/b8cd97914c8e8a7b8a6ec6f94bca0b09fa0048
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/9b/b8cd97914c8e8a7b8a6ec6f94bca0b09fa0048
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/9b/b8cd97914c8e8a7b8a6ec6f94bca0b09fa0048
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/9d/e8260b738a34a74533df54f2e404276aa96242 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/9d/e8260b738a34a74533df54f2e404276aa96242
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/9d/e8260b738a34a74533df54f2e404276aa96242
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/9d/e8260b738a34a74533df54f2e404276aa96242
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/a5/bce3fd2565d8f458555a0c6f42d0504a848bd5 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/a5/bce3fd2565d8f458555a0c6f42d0504a848bd5
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/a5/bce3fd2565d8f458555a0c6f42d0504a848bd5
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/a5/bce3fd2565d8f458555a0c6f42d0504a848bd5
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/af/a76754c933269d7cd45630a7184a20849dbe9c b/test/integration/cherryPicking/expected/repo/.git_keep/objects/af/a76754c933269d7cd45630a7184a20849dbe9c
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/af/a76754c933269d7cd45630a7184a20849dbe9c
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/af/a76754c933269d7cd45630a7184a20849dbe9c
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/b4/121e2d6aa156227b6541431ddfb8594904b520 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/b4/121e2d6aa156227b6541431ddfb8594904b520
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/b4/121e2d6aa156227b6541431ddfb8594904b520
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/b4/121e2d6aa156227b6541431ddfb8594904b520
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/b8/ab98a9ab0599193a3f41a9cc5cb988283e6722 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/b8/ab98a9ab0599193a3f41a9cc5cb988283e6722
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/b8/ab98a9ab0599193a3f41a9cc5cb988283e6722
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/b8/ab98a9ab0599193a3f41a9cc5cb988283e6722
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/bd/6f34089ba29cbae102003bd973e9f37a235c2e b/test/integration/cherryPicking/expected/repo/.git_keep/objects/bd/6f34089ba29cbae102003bd973e9f37a235c2e
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/bd/6f34089ba29cbae102003bd973e9f37a235c2e
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/bd/6f34089ba29cbae102003bd973e9f37a235c2e
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/bf/cc5725cd2ef871ff804996f4e02beef3e4dec2 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/bf/cc5725cd2ef871ff804996f4e02beef3e4dec2
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/bf/cc5725cd2ef871ff804996f4e02beef3e4dec2
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/bf/cc5725cd2ef871ff804996f4e02beef3e4dec2
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/c1/dd146476a4a37fff75b88612a718281ea83b58 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/c1/dd146476a4a37fff75b88612a718281ea83b58
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/c1/dd146476a4a37fff75b88612a718281ea83b58
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/c1/dd146476a4a37fff75b88612a718281ea83b58
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/ce/ecbe69460104e09eb2cd7c865df520c5679a68 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/ce/ecbe69460104e09eb2cd7c865df520c5679a68
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/ce/ecbe69460104e09eb2cd7c865df520c5679a68
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/ce/ecbe69460104e09eb2cd7c865df520c5679a68
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/d0/60f7226715ca55b04e91fad2b8aca01badd993 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/d0/60f7226715ca55b04e91fad2b8aca01badd993
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/d0/60f7226715ca55b04e91fad2b8aca01badd993
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/d0/60f7226715ca55b04e91fad2b8aca01badd993
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/d8/a7c50dcab42b2b62e5c77cdcece620d3964bd4 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/d8/a7c50dcab42b2b62e5c77cdcece620d3964bd4
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/d8/a7c50dcab42b2b62e5c77cdcece620d3964bd4
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/d8/a7c50dcab42b2b62e5c77cdcece620d3964bd4
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/d8/e5ca46d2bbd7c115e5849e637efe2361203368 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/d8/e5ca46d2bbd7c115e5849e637efe2361203368
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/d8/e5ca46d2bbd7c115e5849e637efe2361203368
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/d8/e5ca46d2bbd7c115e5849e637efe2361203368
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/da/72a6dd6fbaaa4a2803a3c867437ab81a1a99a0 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/da/72a6dd6fbaaa4a2803a3c867437ab81a1a99a0
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/da/72a6dd6fbaaa4a2803a3c867437ab81a1a99a0
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/da/72a6dd6fbaaa4a2803a3c867437ab81a1a99a0
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/dc/d348507ba1da8f6479b9d964daa302b2fb9d9c b/test/integration/cherryPicking/expected/repo/.git_keep/objects/dc/d348507ba1da8f6479b9d964daa302b2fb9d9c
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/dc/d348507ba1da8f6479b9d964daa302b2fb9d9c
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/dc/d348507ba1da8f6479b9d964daa302b2fb9d9c
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/df/6b0d2bcc76e6ec0fca20c227104a4f28bac41b b/test/integration/cherryPicking/expected/repo/.git_keep/objects/df/6b0d2bcc76e6ec0fca20c227104a4f28bac41b
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/df/6b0d2bcc76e6ec0fca20c227104a4f28bac41b
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/df/6b0d2bcc76e6ec0fca20c227104a4f28bac41b
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/e3/ae5c6d8407e8307b9bc77923be78c901408f6e b/test/integration/cherryPicking/expected/repo/.git_keep/objects/e3/ae5c6d8407e8307b9bc77923be78c901408f6e
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/e3/ae5c6d8407e8307b9bc77923be78c901408f6e
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/e3/ae5c6d8407e8307b9bc77923be78c901408f6e
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/e4/48ae5bf6371d80ebee24a22b6df341797a6511 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/e4/48ae5bf6371d80ebee24a22b6df341797a6511
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/e4/48ae5bf6371d80ebee24a22b6df341797a6511
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/e4/48ae5bf6371d80ebee24a22b6df341797a6511
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/e4/666ba294866d5c16f9afebcacf8f4adfee7439 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/e4/666ba294866d5c16f9afebcacf8f4adfee7439
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/e4/666ba294866d5c16f9afebcacf8f4adfee7439
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/e4/666ba294866d5c16f9afebcacf8f4adfee7439
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/e4/aa98b835d0a871d9ea02e6d286f0fbb2204cdc b/test/integration/cherryPicking/expected/repo/.git_keep/objects/e4/aa98b835d0a871d9ea02e6d286f0fbb2204cdc
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/e4/aa98b835d0a871d9ea02e6d286f0fbb2204cdc
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/e4/aa98b835d0a871d9ea02e6d286f0fbb2204cdc
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/ea/a48cb1e3d47e1b8b8df47bdc248e991207cc3d b/test/integration/cherryPicking/expected/repo/.git_keep/objects/ea/a48cb1e3d47e1b8b8df47bdc248e991207cc3d
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/ea/a48cb1e3d47e1b8b8df47bdc248e991207cc3d
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/ea/a48cb1e3d47e1b8b8df47bdc248e991207cc3d
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/eb/90e8d7b137a1d89480c9b22fd03199da77c9c7 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/eb/90e8d7b137a1d89480c9b22fd03199da77c9c7
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/eb/90e8d7b137a1d89480c9b22fd03199da77c9c7
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/eb/90e8d7b137a1d89480c9b22fd03199da77c9c7
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/ef/029771f117b5f31c972dfa546037662e243ca7 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/ef/029771f117b5f31c972dfa546037662e243ca7
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/ef/029771f117b5f31c972dfa546037662e243ca7
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/ef/029771f117b5f31c972dfa546037662e243ca7
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/f1/46c7f7b874778c1ad0cf9aebe45ec2427c7de2 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/f1/46c7f7b874778c1ad0cf9aebe45ec2427c7de2
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/f1/46c7f7b874778c1ad0cf9aebe45ec2427c7de2
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/f1/46c7f7b874778c1ad0cf9aebe45ec2427c7de2
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/f3/7d8713ef1390c277b45a084a08c0c142ff7ed9 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/f3/7d8713ef1390c277b45a084a08c0c142ff7ed9
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/f3/7d8713ef1390c277b45a084a08c0c142ff7ed9
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/f3/7d8713ef1390c277b45a084a08c0c142ff7ed9
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/f3/f762af4429ae89fa0dae3d0a5b500ca11630c4 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/f3/f762af4429ae89fa0dae3d0a5b500ca11630c4
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/f3/f762af4429ae89fa0dae3d0a5b500ca11630c4
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/f3/f762af4429ae89fa0dae3d0a5b500ca11630c4
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/f4/ffac820a371104fe611d81bc13a45b70a3ebb3 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/f4/ffac820a371104fe611d81bc13a45b70a3ebb3
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/f4/ffac820a371104fe611d81bc13a45b70a3ebb3
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/f4/ffac820a371104fe611d81bc13a45b70a3ebb3
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/fa/cb56c48e4718f71c08116153c93d87bc699671 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/fa/cb56c48e4718f71c08116153c93d87bc699671
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/fa/cb56c48e4718f71c08116153c93d87bc699671
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/fa/cb56c48e4718f71c08116153c93d87bc699671
diff --git a/test/integration/cherryPicking/expected/.git_keep/objects/fd/31cea7e0b6e8d334280be34db8dd86cdda3007 b/test/integration/cherryPicking/expected/repo/.git_keep/objects/fd/31cea7e0b6e8d334280be34db8dd86cdda3007
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/objects/fd/31cea7e0b6e8d334280be34db8dd86cdda3007
rename to test/integration/cherryPicking/expected/repo/.git_keep/objects/fd/31cea7e0b6e8d334280be34db8dd86cdda3007
diff --git a/test/integration/cherryPicking/expected/.git_keep/refs/heads/base_branch b/test/integration/cherryPicking/expected/repo/.git_keep/refs/heads/base_branch
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/refs/heads/base_branch
rename to test/integration/cherryPicking/expected/repo/.git_keep/refs/heads/base_branch
diff --git a/test/integration/cherryPicking/expected/.git_keep/refs/heads/develop b/test/integration/cherryPicking/expected/repo/.git_keep/refs/heads/develop
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/refs/heads/develop
rename to test/integration/cherryPicking/expected/repo/.git_keep/refs/heads/develop
diff --git a/test/integration/cherryPicking/expected/.git_keep/refs/heads/feature/cherry-picking b/test/integration/cherryPicking/expected/repo/.git_keep/refs/heads/feature/cherry-picking
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/refs/heads/feature/cherry-picking
rename to test/integration/cherryPicking/expected/repo/.git_keep/refs/heads/feature/cherry-picking
diff --git a/test/integration/cherryPicking/expected/.git_keep/refs/heads/master b/test/integration/cherryPicking/expected/repo/.git_keep/refs/heads/master
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/refs/heads/master
rename to test/integration/cherryPicking/expected/repo/.git_keep/refs/heads/master
diff --git a/test/integration/cherryPicking/expected/.git_keep/refs/heads/other_branch b/test/integration/cherryPicking/expected/repo/.git_keep/refs/heads/other_branch
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/refs/heads/other_branch
rename to test/integration/cherryPicking/expected/repo/.git_keep/refs/heads/other_branch
diff --git a/test/integration/cherryPicking/expected/cherrypicking3 b/test/integration/cherryPicking/expected/repo/cherrypicking3
similarity index 100%
rename from test/integration/cherryPicking/expected/cherrypicking3
rename to test/integration/cherryPicking/expected/repo/cherrypicking3
diff --git a/test/integration/cherryPicking/expected/cherrypicking4 b/test/integration/cherryPicking/expected/repo/cherrypicking4
similarity index 100%
rename from test/integration/cherryPicking/expected/cherrypicking4
rename to test/integration/cherryPicking/expected/repo/cherrypicking4
diff --git a/test/integration/cherryPicking/expected/cherrypicking5 b/test/integration/cherryPicking/expected/repo/cherrypicking5
similarity index 100%
rename from test/integration/cherryPicking/expected/cherrypicking5
rename to test/integration/cherryPicking/expected/repo/cherrypicking5
diff --git a/test/integration/cherryPicking/expected/directory/file b/test/integration/cherryPicking/expected/repo/directory/file
similarity index 100%
rename from test/integration/cherryPicking/expected/directory/file
rename to test/integration/cherryPicking/expected/repo/directory/file
diff --git a/test/integration/cherryPicking/expected/directory/file2 b/test/integration/cherryPicking/expected/repo/directory/file2
similarity index 100%
rename from test/integration/cherryPicking/expected/directory/file2
rename to test/integration/cherryPicking/expected/repo/directory/file2
diff --git a/test/integration/cherryPicking/expected/file b/test/integration/cherryPicking/expected/repo/file
similarity index 100%
rename from test/integration/cherryPicking/expected/file
rename to test/integration/cherryPicking/expected/repo/file
diff --git a/test/integration/cherryPicking/expected/file1 b/test/integration/cherryPicking/expected/repo/file1
similarity index 100%
rename from test/integration/cherryPicking/expected/file1
rename to test/integration/cherryPicking/expected/repo/file1
diff --git a/test/integration/cherryPicking/expected/file3 b/test/integration/cherryPicking/expected/repo/file3
similarity index 100%
rename from test/integration/cherryPicking/expected/file3
rename to test/integration/cherryPicking/expected/repo/file3
diff --git a/test/integration/cherryPicking/expected/file4 b/test/integration/cherryPicking/expected/repo/file4
similarity index 100%
rename from test/integration/cherryPicking/expected/file4
rename to test/integration/cherryPicking/expected/repo/file4
diff --git a/test/integration/cherryPicking/expected/file5 b/test/integration/cherryPicking/expected/repo/file5
similarity index 100%
rename from test/integration/cherryPicking/expected/file5
rename to test/integration/cherryPicking/expected/repo/file5
diff --git a/test/integration/commit/expected/.git_keep/COMMIT_EDITMSG b/test/integration/commit/expected/.git_keep/COMMIT_EDITMSG
deleted file mode 100644
index 01f9a2aac..000000000
--- a/test/integration/commit/expected/.git_keep/COMMIT_EDITMSG
+++ /dev/null
@@ -1 +0,0 @@
-commit
diff --git a/test/integration/commit/expected/.git_keep/index b/test/integration/commit/expected/.git_keep/index
deleted file mode 100644
index 6bda11a96..000000000
Binary files a/test/integration/commit/expected/.git_keep/index and /dev/null differ
diff --git a/test/integration/commit/expected/.git_keep/logs/HEAD b/test/integration/commit/expected/.git_keep/logs/HEAD
deleted file mode 100644
index 66475b1c1..000000000
--- a/test/integration/commit/expected/.git_keep/logs/HEAD
+++ /dev/null
@@ -1,5 +0,0 @@
-0000000000000000000000000000000000000000 3df3d8761bc0f0828596b11845aeac175b7b7393 CI 1617671339 +1000 commit (initial): myfile1
-3df3d8761bc0f0828596b11845aeac175b7b7393 a7d53cc21fd53100f955377be379423b0e386274 CI 1617671339 +1000 commit: myfile2
-a7d53cc21fd53100f955377be379423b0e386274 4ba4f1ed711a9081fab21bc222469aa5176a01f8 CI 1617671339 +1000 commit: myfile3
-4ba4f1ed711a9081fab21bc222469aa5176a01f8 1440bc6cc888a09dca2329d1060eec6de78d9d21 CI 1617671339 +1000 commit: myfile4
-1440bc6cc888a09dca2329d1060eec6de78d9d21 e7560e2cd4783a261ad32496cefed2d9f69a46e7 CI 1617671342 +1000 commit: commit
diff --git a/test/integration/commit/expected/.git_keep/logs/refs/heads/master b/test/integration/commit/expected/.git_keep/logs/refs/heads/master
deleted file mode 100644
index 66475b1c1..000000000
--- a/test/integration/commit/expected/.git_keep/logs/refs/heads/master
+++ /dev/null
@@ -1,5 +0,0 @@
-0000000000000000000000000000000000000000 3df3d8761bc0f0828596b11845aeac175b7b7393 CI 1617671339 +1000 commit (initial): myfile1
-3df3d8761bc0f0828596b11845aeac175b7b7393 a7d53cc21fd53100f955377be379423b0e386274 CI 1617671339 +1000 commit: myfile2
-a7d53cc21fd53100f955377be379423b0e386274 4ba4f1ed711a9081fab21bc222469aa5176a01f8 CI 1617671339 +1000 commit: myfile3
-4ba4f1ed711a9081fab21bc222469aa5176a01f8 1440bc6cc888a09dca2329d1060eec6de78d9d21 CI 1617671339 +1000 commit: myfile4
-1440bc6cc888a09dca2329d1060eec6de78d9d21 e7560e2cd4783a261ad32496cefed2d9f69a46e7 CI 1617671342 +1000 commit: commit
diff --git a/test/integration/commit/expected/.git_keep/objects/14/40bc6cc888a09dca2329d1060eec6de78d9d21 b/test/integration/commit/expected/.git_keep/objects/14/40bc6cc888a09dca2329d1060eec6de78d9d21
deleted file mode 100644
index 0a95d28ba..000000000
Binary files a/test/integration/commit/expected/.git_keep/objects/14/40bc6cc888a09dca2329d1060eec6de78d9d21 and /dev/null differ
diff --git a/test/integration/commit/expected/.git_keep/objects/3d/f3d8761bc0f0828596b11845aeac175b7b7393 b/test/integration/commit/expected/.git_keep/objects/3d/f3d8761bc0f0828596b11845aeac175b7b7393
deleted file mode 100644
index 9abdddbd3..000000000
--- a/test/integration/commit/expected/.git_keep/objects/3d/f3d8761bc0f0828596b11845aeac175b7b7393
+++ /dev/null
@@ -1,3 +0,0 @@
-xŤÍA
-Â0@Q×9Ĺě™iĆI
-"BW=FšL°Đ!R"čííÜ~üÜĚÖÄrę»* J®d ŁĆ¬ĄDŐŔű"\Sľ.˝űłí0Íp›ć‡~’˝6˝äfw ˇ ĽáLčŽzLşţÉť}ëş)ą2r,Ď
\ No newline at end of file
diff --git a/test/integration/commit/expected/.git_keep/objects/4b/a4f1ed711a9081fab21bc222469aa5176a01f8 b/test/integration/commit/expected/.git_keep/objects/4b/a4f1ed711a9081fab21bc222469aa5176a01f8
deleted file mode 100644
index b67f58f76..000000000
Binary files a/test/integration/commit/expected/.git_keep/objects/4b/a4f1ed711a9081fab21bc222469aa5176a01f8 and /dev/null differ
diff --git a/test/integration/commit/expected/.git_keep/objects/a7/d53cc21fd53100f955377be379423b0e386274 b/test/integration/commit/expected/.git_keep/objects/a7/d53cc21fd53100f955377be379423b0e386274
deleted file mode 100644
index 77d08ca03..000000000
Binary files a/test/integration/commit/expected/.git_keep/objects/a7/d53cc21fd53100f955377be379423b0e386274 and /dev/null differ
diff --git a/test/integration/commit/expected/.git_keep/objects/e7/560e2cd4783a261ad32496cefed2d9f69a46e7 b/test/integration/commit/expected/.git_keep/objects/e7/560e2cd4783a261ad32496cefed2d9f69a46e7
deleted file mode 100644
index 9eb3492d1..000000000
Binary files a/test/integration/commit/expected/.git_keep/objects/e7/560e2cd4783a261ad32496cefed2d9f69a46e7 and /dev/null differ
diff --git a/test/integration/commit/expected/.git_keep/refs/heads/master b/test/integration/commit/expected/.git_keep/refs/heads/master
deleted file mode 100644
index c641d5ee6..000000000
--- a/test/integration/commit/expected/.git_keep/refs/heads/master
+++ /dev/null
@@ -1 +0,0 @@
-e7560e2cd4783a261ad32496cefed2d9f69a46e7
diff --git a/test/integration/commit/recording.json b/test/integration/commit/recording.json
deleted file mode 100644
index eb45c9fde..000000000
--- a/test/integration/commit/recording.json
+++ /dev/null
@@ -1 +0,0 @@
-{"KeyEvents":[{"Timestamp":527,"Mod":0,"Key":256,"Ch":32},{"Timestamp":830,"Mod":0,"Key":256,"Ch":99},{"Timestamp":1127,"Mod":0,"Key":256,"Ch":99},{"Timestamp":1190,"Mod":0,"Key":256,"Ch":111},{"Timestamp":1335,"Mod":0,"Key":256,"Ch":109},{"Timestamp":1447,"Mod":0,"Key":256,"Ch":109},{"Timestamp":1583,"Mod":0,"Key":256,"Ch":105},{"Timestamp":1606,"Mod":0,"Key":256,"Ch":116},{"Timestamp":1935,"Mod":0,"Key":13,"Ch":13},{"Timestamp":2353,"Mod":0,"Key":27,"Ch":0}],"ResizeEvents":[{"Timestamp":0,"Width":127,"Height":35}]}
diff --git a/test/integration/commitMultiline/expected/repo/.git_keep/COMMIT_EDITMSG b/test/integration/commitMultiline/expected/repo/.git_keep/COMMIT_EDITMSG
new file mode 100644
index 000000000..bf8858b06
--- /dev/null
+++ b/test/integration/commitMultiline/expected/repo/.git_keep/COMMIT_EDITMSG
@@ -0,0 +1,3 @@
+first line
+
+third line
diff --git a/test/integration/cherryPicking/expected/.git_keep/FETCH_HEAD b/test/integration/commitMultiline/expected/repo/.git_keep/FETCH_HEAD
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/FETCH_HEAD
rename to test/integration/commitMultiline/expected/repo/.git_keep/FETCH_HEAD
diff --git a/test/integration/commit/expected/.git_keep/HEAD b/test/integration/commitMultiline/expected/repo/.git_keep/HEAD
similarity index 100%
rename from test/integration/commit/expected/.git_keep/HEAD
rename to test/integration/commitMultiline/expected/repo/.git_keep/HEAD
diff --git a/test/integration/cherryPicking/expected/.git_keep/config b/test/integration/commitMultiline/expected/repo/.git_keep/config
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/config
rename to test/integration/commitMultiline/expected/repo/.git_keep/config
diff --git a/test/integration/cherryPicking/expected/.git_keep/description b/test/integration/commitMultiline/expected/repo/.git_keep/description
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/description
rename to test/integration/commitMultiline/expected/repo/.git_keep/description
diff --git a/test/integration/commitMultiline/expected/repo/.git_keep/index b/test/integration/commitMultiline/expected/repo/.git_keep/index
new file mode 100644
index 000000000..a08b4116e
Binary files /dev/null and b/test/integration/commitMultiline/expected/repo/.git_keep/index differ
diff --git a/test/integration/cherryPicking/expected/.git_keep/info/exclude b/test/integration/commitMultiline/expected/repo/.git_keep/info/exclude
similarity index 100%
rename from test/integration/cherryPicking/expected/.git_keep/info/exclude
rename to test/integration/commitMultiline/expected/repo/.git_keep/info/exclude
diff --git a/test/integration/commitMultiline/expected/repo/.git_keep/logs/HEAD b/test/integration/commitMultiline/expected/repo/.git_keep/logs/HEAD
new file mode 100644
index 000000000..88b99d1ab
--- /dev/null
+++ b/test/integration/commitMultiline/expected/repo/.git_keep/logs/HEAD
@@ -0,0 +1,5 @@
+0000000000000000000000000000000000000000 176069f0ded1db43eecb3b629a6077dba6c68295 CI 1645602422 +1100 commit (initial): myfile1
+176069f0ded1db43eecb3b629a6077dba6c68295 9f1b5440546da24daad7014ccf3e1f4d81f9414b CI 1645602422 +1100 commit: myfile2
+9f1b5440546da24daad7014ccf3e1f4d81f9414b 3933a268c502712421b7bfa04888319d6f108574 CI 1645602422 +1100 commit: myfile3
+3933a268c502712421b7bfa04888319d6f108574 37128a3020849daa0847462d14c384cc74c42ae0 CI 1645602422 +1100 commit: myfile4
+37128a3020849daa0847462d14c384cc74c42ae0 574013716a7f007a27b647b90cdbc78d006d792b CI 1645602427 +1100 commit: first line
diff --git a/test/integration/commitMultiline/expected/repo/.git_keep/logs/refs/heads/master b/test/integration/commitMultiline/expected/repo/.git_keep/logs/refs/heads/master
new file mode 100644
index 000000000..88b99d1ab
--- /dev/null
+++ b/test/integration/commitMultiline/expected/repo/.git_keep/logs/refs/heads/master
@@ -0,0 +1,5 @@
+0000000000000000000000000000000000000000 176069f0ded1db43eecb3b629a6077dba6c68295 CI 1645602422 +1100 commit (initial): myfile1
+176069f0ded1db43eecb3b629a6077dba6c68295 9f1b5440546da24daad7014ccf3e1f4d81f9414b CI 1645602422 +1100 commit: myfile2
+9f1b5440546da24daad7014ccf3e1f4d81f9414b 3933a268c502712421b7bfa04888319d6f108574 CI 1645602422 +1100 commit: myfile3
+3933a268c502712421b7bfa04888319d6f108574 37128a3020849daa0847462d14c384cc74c42ae0 CI 1645602422 +1100 commit: myfile4
+37128a3020849daa0847462d14c384cc74c42ae0 574013716a7f007a27b647b90cdbc78d006d792b CI