From b03287352487a2bd06b9fc24e6596dce4f401f3a Mon Sep 17 00:00:00 2001 From: Damien Sawyer Date: Fri, 10 Jul 2026 18:41:44 +1000 Subject: [PATCH] Add an overview mode to the commits panel, showing only merges and ref tips In repos with many merged PR branches the commits panel is dominated by run-of-the-mill commits, making it hard to see the shape of the history. Overview mode (bound to `~`, also in the log menu) hides every commit that is neither a merge commit nor pointed to by a local or remote branch, a tag, or HEAD, leaving just the landmarks. Rebase todo entries stay visible so mid-rebase views keep working. The graph keeps exactly the shape it has in the full log: the model keeps the full commit list next to the displayed subset, the graph's pipe sets are computed from the full list, and only the displayed rows are emitted. Lane positions and merge join lines therefore don't change when rows are hidden. When the mode is enabled while the selected commit is one that gets hidden, the selection moves to the nearest commit above it that stays visible and is scrolled into view, and disabling the mode jumps back to the original commit, unless the selection was moved in the meantime. Enabling the mode also turns off the commit limit: the condensed list can have far fewer rows than the load-more heuristic's threshold (selection approaching the end of the list), so the heuristic might never kick in. While active, the mode is shown in the information section with a reset button, and the commits view's title and tab label get an "(overview)" suffix, so hidden rows can't be mistaken for missing history and the mode can be cancelled from anywhere. Commands that feed the selection's list position into the rebase machinery (squash, fixup, edit, move up/down, reword, drop, amend, and friends) prompt to exit the mode first, exactly like filter mode does: with rows hidden, "the commit below" on screen is not the commit below in the real history. --- docs-master/Config.md | 1 + docs-master/keybindings/Keybindings_en.md | 1 + docs-master/keybindings/Keybindings_ja.md | 1 + docs-master/keybindings/Keybindings_ko.md | 1 + docs-master/keybindings/Keybindings_nl.md | 1 + docs-master/keybindings/Keybindings_pl.md | 1 + docs-master/keybindings/Keybindings_pt.md | 1 + docs-master/keybindings/Keybindings_ru.md | 1 + docs-master/keybindings/Keybindings_zh-CN.md | 1 + docs-master/keybindings/Keybindings_zh-TW.md | 1 + pkg/config/user_config.go | 2 + pkg/gui/context/local_commits_context.go | 73 ++++++++- pkg/gui/context/sub_commits_context.go | 1 + pkg/gui/controllers/helpers/mode_helper.go | 99 ++++++++++++ pkg/gui/controllers/helpers/refresh_helper.go | 36 +++++ .../helpers/refresh_helper_test.go | 37 +++++ .../controllers/local_commits_controller.go | 69 +++++++-- pkg/gui/presentation/commits.go | 53 ++++--- pkg/gui/presentation/commits_test.go | 47 ++++++ pkg/gui/types/common.go | 31 ++-- pkg/i18n/english.go | 12 ++ .../tests/commit/toggle_overview_mode.go | 146 ++++++++++++++++++ ...erview_mode_scrolls_selection_into_view.go | 43 ++++++ pkg/integration/tests/test_list.go | 2 + schema-master/config.json | 14 ++ 25 files changed, 629 insertions(+), 46 deletions(-) create mode 100644 pkg/integration/tests/commit/toggle_overview_mode.go create mode 100644 pkg/integration/tests/commit/toggle_overview_mode_scrolls_selection_into_view.go diff --git a/docs-master/Config.md b/docs-master/Config.md index 1d101be1863..ae4776eddf2 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -799,6 +799,7 @@ keybinding: resetCherryPick: copyCommitAttributeToClipboard: "y" openLogMenu: + toggleOverviewMode: "~" openInBrowser: o openPullRequestInBrowser: G viewBisectOptions: b diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 4aa202740d0..b2d0e2285bf 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -106,6 +106,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | +| `` ~ `` | Toggle overview mode | Only show merge commits, and commits that a local or remote branch, a tag, or HEAD points to; hide all other commits. Useful for getting a high-level view of the history. | | `` G `` | Open pull request in browser | | | `` `` | Checkout | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 5b9c798a1e8..043e9c140da 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -86,6 +86,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` t `` | リバート | 選択したコミットの変更を逆に適用する、リバートコミットを作成します。 | | `` T `` | コミットにタグを付ける | 選択したコミットを指すタグを新規作成します。タグ名とオプションの説明を入力するよう促されます。 | | `` `` | ログオプションを表示 | コミットログのオプションを表示します(例:並び順の変更、Gitグラフの非表示、Gitグラフ全体の表示)。 | +| `` ~ `` | Toggle overview mode | Only show merge commits, and commits that a local or remote branch, a tag, or HEAD points to; hide all other commits. Useful for getting a high-level view of the history. | | `` G `` | Open pull request in browser | | | `` `` | チェックアウト(ブランチの切り替え) | 選択したコミットをデタッチドヘッド(特定のブランチに属さない状態)としてチェックアウトします。 | | `` y `` | コミット属性をクリップボードにコピー | コミット属性をクリップボードにコピーします(例:ハッシュ、URL、差分、メッセージ、作者)。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index d1eb3afb1cb..a28e1b86ddc 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -317,6 +317,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | 로그 메뉴 열기 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | +| `` ~ `` | Toggle overview mode | Only show merge commits, and commits that a local or remote branch, a tag, or HEAD points to; hide all other commits. Useful for getting a high-level view of the history. | | `` G `` | Open pull request in browser | | | `` `` | 체크아웃 | Checkout the selected commit as a detached HEAD. | | `` y `` | 커밋 attribute 복사 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 0eb61729b5e..3d9c9cf2789 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -178,6 +178,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | +| `` ~ `` | Toggle overview mode | Only show merge commits, and commits that a local or remote branch, a tag, or HEAD points to; hide all other commits. Useful for getting a high-level view of the history. | | `` G `` | Open pull request in browser | | | `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 719a7e6c4fe..24b17cc352c 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -79,6 +79,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` t `` | Cofnij | Utwórz commit cofający dla wybranego commita, który stosuje zmiany wybranego commita w odwrotnej kolejności. | | `` T `` | Otaguj commit | Utwórz nowy tag wskazujący na wybrany commit. Zostaniesz poproszony o wprowadzenie nazwy tagu i opcjonalnego opisu. | | `` `` | Zobacz opcje logów | Zobacz opcje dla logów commitów, np. zmiana kolejności sortowania, ukrywanie grafu gita, pokazywanie całego grafu gita. | +| `` ~ `` | Toggle overview mode | Only show merge commits, and commits that a local or remote branch, a tag, or HEAD points to; hide all other commits. Useful for getting a high-level view of the history. | | `` G `` | Otwórz żądanie ściągnięcia w przeglądarce | | | `` `` | Przełącz | Przełącz wybrany commit jako odłączoną HEAD. | | `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 5fcba268804..397d1c3d8b1 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -182,6 +182,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` t `` | Reverter | Crie um commit reverter para o commit selecionado, que aplica as alterações do commit selecionado em reverso. | | `` T `` | Etiquetar commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | +| `` ~ `` | Toggle overview mode | Only show merge commits, and commits that a local or remote branch, a tag, or HEAD points to; hide all other commits. Useful for getting a high-level view of the history. | | `` G `` | Open pull request in browser | | | `` `` | Verificar | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 6831354480b..e23d2e954a8 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -188,6 +188,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | Пометить коммит тегом | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | Открыть меню журнала | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | +| `` ~ `` | Toggle overview mode | Only show merge commits, and commits that a local or remote branch, a tag, or HEAD points to; hide all other commits. Useful for getting a high-level view of the history. | | `` G `` | Open pull request in browser | | | `` `` | Переключить | Checkout the selected commit as a detached HEAD. | | `` y `` | Скопировать атрибут коммита | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index b4f1134ecfe..cde4cf4884b 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -143,6 +143,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` t `` | 撤销(Revert) | 为所选提交创建还原提交,这会反向应用所选提交的更改。 | | `` T `` | 标签提交 | 创建一个新标签指向所选提交。您可以在弹窗中输入标签名称和描述(可选)。 | | `` `` | 打开日志菜单 | 查看提交日志的选项,例如更改排序顺序、隐藏 git graph、显示整个 git graph。 | +| `` ~ `` | Toggle overview mode | Only show merge commits, and commits that a local or remote branch, a tag, or HEAD points to; hide all other commits. Useful for getting a high-level view of the history. | | `` G `` | 在浏览器中打开拉取请求 | | | `` `` | 检出 | 检出所选择的提交作为分离HEAD。 | | `` y `` | 复制提交属性到剪贴板 | 复制提交属性到剪贴板(如hash、URL、diff、消息、作者)。 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index b50e25d354e..5b0decc816a 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -202,6 +202,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | 開啟記錄選單 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | +| `` ~ `` | Toggle overview mode | Only show merge commits, and commits that a local or remote branch, a tag, or HEAD points to; hide all other commits. Useful for getting a high-level view of the history. | | `` G `` | Open pull request in browser | | | `` `` | 檢出 | Checkout the selected commit as a detached HEAD. | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 30ce0377d5a..149ef4b7790 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -630,6 +630,7 @@ type KeybindingCommitsConfig struct { ResetCherryPick Keybinding `yaml:"resetCherryPick"` CopyCommitAttributeToClipboard Keybinding `yaml:"copyCommitAttributeToClipboard"` OpenLogMenu Keybinding `yaml:"openLogMenu"` + ToggleOverviewMode Keybinding `yaml:"toggleOverviewMode"` OpenInBrowser Keybinding `yaml:"openInBrowser"` OpenPullRequestInBrowser Keybinding `yaml:"openPullRequestInBrowser"` ViewBisectOptions Keybinding `yaml:"viewBisectOptions"` @@ -1144,6 +1145,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { ResetCherryPick: Keybinding{""}, CopyCommitAttributeToClipboard: Keybinding{"y"}, OpenLogMenu: Keybinding{""}, + ToggleOverviewMode: Keybinding{"~"}, OpenInBrowser: Keybinding{"o"}, OpenPullRequestInBrowser: Keybinding{"G"}, ViewBisectOptions: Keybinding{"b"}, diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index d929aca88e4..278d7445cc9 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -44,9 +44,18 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { hasRebaseUpdateRefsConfig := c.Git().Config.GetRebaseUpdateRefs() + // In overview mode the model only holds the displayed subset of the + // commits; the graph is rendered from the full list so that hiding + // rows doesn't change its shape. + commits := c.Model().Commits + displayIndices := overviewDisplayIndices(c, startIdx, endIdx) + if displayIndices != nil { + commits = c.Model().OverviewCommitsSource + } + return presentation.GetCommitListDisplayStrings( c.Common, - c.Model().Commits, + commits, c.Model().Branches, c.Model().CheckedOutBranch, hasRebaseUpdateRefsConfig, @@ -61,6 +70,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { selectedCommitHashPtr, startIdx, endIdx, + displayIndices, shouldShowGraph(c), c.Model().BisectInfo, ) @@ -149,6 +159,17 @@ type LocalCommitsViewModel struct { // If this is true we'll use git log --all when fetching the commits. showWholeGitGraph bool + + // If this is true we only show merge commits and commits that a local or + // remote branch, a tag, or HEAD points to. + overviewMode bool + + // When enabling overview mode hides the selected commit, we remember it + // here, along with the commit the selection was moved to, so that + // disabling the mode can jump back to it if the selection hasn't moved + // in the meantime. + overviewReturnHash string + overviewAutoSelectedHash string } func NewLocalCommitsViewModel(getModel func() []*models.Commit, c *ContextCommon) *LocalCommitsViewModel { @@ -243,10 +264,60 @@ func (self *LocalCommitsViewModel) GetShowWholeGitGraph() bool { return self.showWholeGitGraph } +func (self *LocalCommitsViewModel) SetOverviewMode(value bool) { + self.overviewMode = value +} + +func (self *LocalCommitsViewModel) GetOverviewMode() bool { + return self.overviewMode +} + +func (self *LocalCommitsViewModel) SetOverviewReturnHashes(returnHash string, autoSelectedHash string) { + self.overviewReturnHash = returnHash + self.overviewAutoSelectedHash = autoSelectedHash +} + +func (self *LocalCommitsViewModel) GetOverviewReturnHashes() (string, string) { + return self.overviewReturnHash, self.overviewAutoSelectedHash +} + func (self *LocalCommitsViewModel) GetCommits() []*models.Commit { return self.getModel() } +// overviewDisplayIndices returns, for the given window of displayed rows, the +// indices of those rows in Model().OverviewCommitsSource, or nil when overview +// mode is off. It validates that the mapping matches the displayed commits and +// returns nil on a mismatch (which can happen transiently when something other +// than a commits refresh replaces Model().Commits, e.g. the mid-rebase todo +// refresh), so the caller falls back to rendering from the displayed list. +func overviewDisplayIndices(c *ContextCommon, startIdx int, endIdx int) []int { + indices := c.Model().OverviewCommitIndices + if indices == nil { + return nil + } + + displayed := c.Model().Commits + source := c.Model().OverviewCommitsSource + if len(indices) != len(displayed) { + return nil + } + + endIdx = min(endIdx, len(displayed)) + if startIdx >= endIdx { + return []int{} + } + + window := indices[startIdx:endIdx] + for i, modelIdx := range window { + if modelIdx >= len(source) || source[modelIdx] != displayed[startIdx+i] { + return nil + } + } + + return window +} + func shouldShowGraph(c *ContextCommon) bool { if c.Modes().Filtering.Active() { return false diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index fee5492ac4b..a8e3b40e6c3 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -75,6 +75,7 @@ func NewSubCommitsContext( selectedCommitHashPtr, startIdx, endIdx, + nil, shouldShowGraph(c), git_commands.NewNullBisectInfo(), ) diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index 4947e42d1c8..7a457988392 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" @@ -92,6 +93,16 @@ func (self *ModeHelper) Statuses() []ModeStatus { }, Reset: self.ExitFilterMode, }, + { + IsActive: self.c.Contexts().LocalCommits.GetOverviewMode, + InfoLabel: func() string { + return self.withResetButton(self.c.Tr.OverviewModeStatus, style.FgBlue) + }, + CancelLabel: func() string { + return self.c.Tr.ExitOverviewMode + }, + Reset: self.ExitOverviewMode, + }, { IsActive: self.c.Modes().MarkedBaseCommit.Active, InfoLabel: func() string { @@ -182,6 +193,94 @@ func (self *ModeHelper) ExitFilterMode() error { return self.ClearFiltering() } +// updateCommitsViewTitle gives the commits view's title and tab label an +// "(overview)" suffix while overview mode is on. The tab strip is shared by +// all views of the panel, so updating the commits view's own tab entry is +// enough for the label to show whichever tab is selected. +func (self *ModeHelper) updateCommitsViewTitle() { + view := self.c.Views().Commits + title := lo.Ternary(self.c.Contexts().LocalCommits.GetOverviewMode(), + self.c.Tr.CommitsOverviewTitle, self.c.Tr.CommitsTitle) + view.Title = title + if len(view.Tabs) > 0 { + view.Tabs[view.TabIndex] = title + } +} + +func (self *ModeHelper) EnterOverviewMode() error { + commitsContext := self.c.Contexts().LocalCommits + + // If the selected commit is about to be hidden, remember it and move the + // selection to the nearest commit above it that stays visible (or the top + // one); exiting the mode jumps back to it if the selection hasn't moved + // in the meantime. + hiddenSelectionHash := "" + autoSelectHash := "" + if selected := commitsContext.GetSelected(); selected != nil && !commitShownInOverview(selected) { + hiddenSelectionHash = selected.Hash() + commits := self.c.Model().Commits + for i := commitsContext.GetSelectedLineIdx() - 1; i >= 0; i-- { + if commitShownInOverview(commits[i]) { + autoSelectHash = commits[i].Hash() + break + } + } + } + + commitsContext.SetOverviewMode(true) + self.updateCommitsViewTitle() + // The condensed list can have far fewer rows than the threshold of the + // "load more commits when the selection approaches the end of the list" + // heuristic, which would then never kick in; load the whole history + // instead. + commitsContext.SetLimitCommits(false) + + return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error { + self.c.RefreshFromWorker(types.RefreshOptions{ + Mode: types.SYNC, + Scope: []types.RefreshableView{types.COMMITS}, + Then: func() error { + if hiddenSelectionHash == "" { + return nil + } + if autoSelectHash == "" || !commitsContext.SelectCommitByHash(autoSelectHash) { + commitsContext.SetSelection(0) + } + commitsContext.SetOverviewReturnHashes( + hiddenSelectionHash, commitsContext.GetSelectedCommitHash()) + self.c.PostRefreshUpdate(commitsContext) + commitsContext.FocusLine(true) + return nil + }, + }) + return nil + }) +} + +func (self *ModeHelper) ExitOverviewMode() error { + commitsContext := self.c.Contexts().LocalCommits + + returnHash, autoSelectedHash := commitsContext.GetOverviewReturnHashes() + jumpBack := returnHash != "" && commitsContext.GetSelectedCommitHash() == autoSelectedHash + + commitsContext.SetOverviewMode(false) + commitsContext.SetOverviewReturnHashes("", "") + self.updateCommitsViewTitle() + + self.c.Refresh(types.RefreshOptions{ + Scope: []types.RefreshableView{types.COMMITS}, + Then: func() error { + if jumpBack { + commitsContext.SelectCommitByHash(returnHash) + self.c.PostRefreshUpdate(commitsContext) + commitsContext.FocusLine(true) + } + return nil + }, + }) + return nil +} + func (self *ModeHelper) ClearFiltering() error { selectedCommitHash := self.c.Contexts().LocalCommits.GetSelectedCommitHash() self.c.Modes().Filtering.Reset() diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 3c8524ffe8f..5d07dd8fa3a 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -579,6 +579,7 @@ type capturedCommitState struct { selectionRange *localCommitSelectionRange limitCommits bool showWholeGitGraph bool + overviewMode bool filterPath string filterAuthor string mainBranches *git_commands.MainBranches @@ -601,6 +602,7 @@ func (self *RefreshHelper) captureCommitsState(commitSelection types.CommitSelec selectionRange: selectionRange, limitCommits: self.c.Contexts().LocalCommits.GetLimitCommits(), showWholeGitGraph: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), + overviewMode: self.c.Contexts().LocalCommits.GetOverviewMode(), filterPath: self.c.Modes().Filtering.GetPath(), filterAuthor: self.c.Modes().Filtering.GetAuthor(), mainBranches: self.c.Model().MainBranches, @@ -685,11 +687,21 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, if err != nil { return err } + + var overviewSource []*models.Commit + var overviewIndices []int + if captured.overviewMode { + overviewSource = commits + commits, overviewIndices = filterCommitsForOverview(commits) + } + workingTreeState := self.c.Git().Status.WorkingTreeState() self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits + self.c.Model().OverviewCommitsSource = overviewSource + self.c.Model().OverviewCommitIndices = overviewIndices self.RefreshAuthors(commits) self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState if checkedOutRef != nil { @@ -733,6 +745,30 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, return nil } +// commitShownInOverview says whether the commits panel's overview mode shows +// the given commit: merge commits, commits that a ref points to, and rebase +// todo entries. +func commitShownInOverview(commit *models.Commit) bool { + return commit.IsTODO() || commit.IsMerge() || commit.ExtraInfo != "" +} + +// filterCommitsForOverview returns only the commits that overview mode shows. +// The second return value holds, for each kept commit, its index in the full +// slice; the graph is rendered from the full slice so that hiding rows +// doesn't change its shape. +func filterCommitsForOverview(commits []*models.Commit) ([]*models.Commit, []int) { + kept := make([]*models.Commit, 0, len(commits)) + indices := make([]int, 0, len(commits)) + for i, commit := range commits { + if commitShownInOverview(commit) { + kept = append(kept, commit) + indices = append(indices, i) + } + } + + return kept, indices +} + type localCommitSelectionRange struct { selectedHash string selectedIsTODO bool diff --git a/pkg/gui/controllers/helpers/refresh_helper_test.go b/pkg/gui/controllers/helpers/refresh_helper_test.go index 3a5f6ea829c..8d2dc19f76c 100644 --- a/pkg/gui/controllers/helpers/refresh_helper_test.go +++ b/pkg/gui/controllers/helpers/refresh_helper_test.go @@ -160,6 +160,43 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { } } +func TestFilterCommitsForOverview(t *testing.T) { + hashPool := &utils.StringPool{} + + commit := func(hash string, extraInfo string, parents ...string) *models.Commit { + return models.NewCommit(hashPool, models.NewCommitOpts{ + Hash: hash, + ExtraInfo: extraInfo, + Parents: parents, + }) + } + + commits := []*models.Commit{ + commit("master3", "(HEAD -> master)", "master2"), + commit("master2", "", "M"), + commit("M", "", "master1", "feature2"), + commit("feature2", "(feature)", "feature1"), + commit("feature1", "", "base"), + commit("master1", "", "base"), + commit("base", "(tag: v1)"), + } + todoCommit := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "todo", Action: todo.Pick}) + commits = append([]*models.Commit{todoCommit}, commits...) + + kept, indices := filterCommitsForOverview(commits) + + // merge commits, ref tips, and rebase todo entries are kept; everything + // else is hidden + assert.Equal(t, []string{"todo", "master3", "M", "feature2", "base"}, + lo.Map(kept, func(commit *models.Commit, _ int) string { return commit.Hash() })) + + // each kept commit's index points back at it in the full slice + assert.Equal(t, []int{0, 1, 3, 4, 7}, indices) + for i, modelIdx := range indices { + assert.Same(t, commits[modelIdx], kept[i]) + } +} + func TestGetGithubBaseRemote(t *testing.T) { cases := []struct { name string diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 23e94adf7fd..0bd5290545f 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -56,7 +56,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ bindings := []*types.Binding{ { Keys: opts.GetKeys(opts.Config.Commits.SquashDown), - Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.squashDown)), + Handler: opts.Guards.OutsideFilterMode(self.outsideOverviewMode(self.withItemsRange(self.squashDown))), GetDisabledReason: self.require( self.itemRangeSelected( self.midRebaseCommandEnabled, @@ -69,7 +69,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(opts.Config.Commits.MarkCommitAsFixup), - Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.fixup)), + Handler: opts.Guards.OutsideFilterMode(self.outsideOverviewMode(self.withItemsRange(self.fixup))), GetDisabledReason: self.require( self.itemRangeSelected( self.midRebaseCommandEnabled, @@ -91,7 +91,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(opts.Config.Commits.RenameCommit), - Handler: self.withItem(self.reword), + Handler: self.outsideOverviewMode(self.withItem(self.reword)), GetDisabledReason: self.require( self.singleItemSelected(self.rewordEnabled), ), @@ -102,7 +102,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(opts.Config.Commits.RenameCommitWithEditor), - Handler: self.withItem(self.rewordEditor), + Handler: self.outsideOverviewMode(self.withItem(self.rewordEditor)), GetDisabledReason: self.require( self.singleItemSelected(self.rewordEnabled), ), @@ -110,7 +110,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(opts.Config.Universal.Remove), - Handler: self.withItemsRange(self.drop), + Handler: self.outsideOverviewMode(self.withItemsRange(self.drop)), GetDisabledReason: self.require( self.itemRangeSelected( self.canDropCommits, @@ -122,7 +122,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(editCommitKey), - Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.edit)), + Handler: opts.Guards.OutsideFilterMode(self.outsideOverviewMode(self.withItemsRange(self.edit))), GetDisabledReason: self.require( self.itemRangeSelected(self.midRebaseCommandEnabled), ), @@ -136,7 +136,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ // we're calling it 'quick-start interactive rebase' to differentiate it from // when you manually select the base commit. Keys: opts.GetKeys(opts.Config.Commits.StartInteractiveRebase), - Handler: opts.Guards.OutsideFilterMode(self.quickStartInteractiveRebase), + Handler: opts.Guards.OutsideFilterMode(self.outsideOverviewMode(self.quickStartInteractiveRebase)), GetDisabledReason: self.require(self.notMidRebase(self.c.Tr.AlreadyRebasing), self.canFindCommitForQuickStart), Description: self.c.Tr.QuickStartInteractiveRebase, Tooltip: utils.ResolvePlaceholderString(self.c.Tr.QuickStartInteractiveRebaseTooltip, map[string]string{ @@ -145,7 +145,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(opts.Config.Commits.PickCommit), - Handler: opts.Guards.OutsideFilterMode(self.withItems(self.pick)), + Handler: opts.Guards.OutsideFilterMode(self.outsideOverviewMode(self.withItems(self.pick))), GetDisabledReason: self.require( self.itemRangeSelected(self.pickEnabled), ), @@ -154,7 +154,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(opts.Config.Commits.CreateFixupCommit), - Handler: opts.Guards.OutsideFilterMode(self.withItem(self.createFixupCommit)), + Handler: opts.Guards.OutsideFilterMode(self.outsideOverviewMode(self.withItem(self.createFixupCommit))), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CreateFixupCommit, Tooltip: utils.ResolvePlaceholderString( @@ -166,7 +166,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(opts.Config.Commits.SquashAboveCommits), - Handler: opts.Guards.OutsideFilterMode(self.squashFixupCommits), + Handler: opts.Guards.OutsideFilterMode(self.outsideOverviewMode(self.squashFixupCommits)), GetDisabledReason: self.require( self.notMidRebase(self.c.Tr.AlreadyRebasing), ), @@ -176,7 +176,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(opts.Config.Commits.MoveDownCommit), - Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.moveDown)), + Handler: opts.Guards.OutsideFilterMode(self.outsideOverviewMode(self.withItemsRange(self.moveDown))), GetDisabledReason: self.require(self.itemRangeSelected( self.midRebaseMoveCommandEnabled, self.canMoveDown, @@ -185,7 +185,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(opts.Config.Commits.MoveUpCommit), - Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.moveUp)), + Handler: opts.Guards.OutsideFilterMode(self.outsideOverviewMode(self.withItemsRange(self.moveUp))), GetDisabledReason: self.require(self.itemRangeSelected( self.midRebaseMoveCommandEnabled, self.canMoveUp, @@ -194,14 +194,14 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(opts.Config.Commits.PasteCommits), - Handler: opts.Guards.OutsideFilterMode(self.paste), + Handler: opts.Guards.OutsideFilterMode(self.outsideOverviewMode(self.paste)), GetDisabledReason: self.require(self.canPaste), Description: self.c.Tr.PasteCommits, DisplayStyle: &style.FgCyan, }, { Keys: opts.GetKeys(opts.Config.Commits.MarkCommitAsBaseForRebase), - Handler: opts.Guards.OutsideFilterMode(self.withItem(self.markAsBaseCommit)), + Handler: opts.Guards.OutsideFilterMode(self.outsideOverviewMode(self.withItem(self.markAsBaseCommit))), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.MarkAsBaseCommit, Tooltip: self.c.Tr.MarkAsBaseCommitTooltip, @@ -216,7 +216,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(opts.Config.Commits.AmendToCommit), - Handler: self.withItem(self.amendTo), + Handler: self.outsideOverviewMode(self.withItem(self.amendTo)), GetDisabledReason: self.require(self.singleItemSelected(self.canAmend)), Description: self.c.Tr.Amend, Tooltip: self.c.Tr.AmendCommitTooltip, @@ -224,7 +224,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Keys: opts.GetKeys(opts.Config.Commits.ResetCommitAuthor), - Handler: self.withItemsRange(self.amendAttribute), + Handler: self.outsideOverviewMode(self.withItemsRange(self.amendAttribute)), GetDisabledReason: self.require(self.itemRangeSelected(self.canAmendRange)), Description: self.c.Tr.AmendCommitAttribute, Tooltip: self.c.Tr.AmendCommitAttributeTooltip, @@ -251,6 +251,12 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Tooltip: self.c.Tr.OpenLogMenuTooltip, OpensMenu: true, }, + { + Keys: opts.GetKeys(opts.Config.Commits.ToggleOverviewMode), + Handler: self.toggleOverviewMode, + Description: self.c.Tr.ToggleOverviewMode, + Tooltip: self.c.Tr.ToggleOverviewModeTooltip, + }, { Keys: opts.GetKeys(opts.Config.Commits.OpenPullRequestInBrowser), Handler: self.openPRInBrowser, @@ -1202,6 +1208,32 @@ func (self *LocalCommitsController) openSearch() error { return self.c.Helpers().Search.OpenSearchPrompt(self.context()) } +// Commands that pick the rebase base from the position of the selection in +// the commits list would operate relative to commits that overview mode +// hides, so require exiting the mode first (like filter mode does). +func (self *LocalCommitsController) outsideOverviewMode(f func() error) func() error { + return func() error { + if !self.context().GetOverviewMode() { + return f() + } + + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.MustExitFilterModeTitle, + Prompt: self.c.Tr.MustExitOverviewModePrompt, + HandleConfirm: self.c.Helpers().Mode.ExitOverviewMode, + }) + + return nil + } +} + +func (self *LocalCommitsController) toggleOverviewMode() error { + if self.context().GetOverviewMode() { + return self.c.Helpers().Mode.ExitOverviewMode() + } + return self.c.Helpers().Mode.EnterOverviewMode() +} + func (self *LocalCommitsController) handleOpenLogMenu() error { return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.LogMenuTitle, @@ -1223,6 +1255,11 @@ func (self *LocalCommitsController) handleOpenLogMenu() error { }) }, }, + { + Label: self.c.Tr.ToggleOverviewMode, + Tooltip: self.c.Tr.ToggleOverviewModeTooltip, + OnPress: self.toggleOverviewMode, + }, { Label: self.c.Tr.ShowGitGraph, Tooltip: self.c.Tr.ShowGitGraphTooltip, diff --git a/pkg/gui/presentation/commits.go b/pkg/gui/presentation/commits.go index 67fa62ac82c..581613ae92b 100644 --- a/pkg/gui/presentation/commits.go +++ b/pkg/gui/presentation/commits.go @@ -54,6 +54,11 @@ func GetCommitListDisplayStrings( selectedCommitHashPtr *string, startIdx int, endIdx int, + // If non-nil, the indices into commits of the rows to render (ascending, + // already windowed), overriding startIdx/endIdx; used by the commits + // panel's overview mode to omit rows while the graph is still rendered + // from the full list. Must be nil for a divergence log. + displayIndices []int, showGraph bool, bisectInfo *git_commands.BisectInfo, ) [][]string { @@ -64,15 +69,22 @@ func GetCommitListDisplayStrings( return nil } - if startIdx >= len(commits) { - return nil + if displayIndices == nil { + if startIdx >= len(commits) { + return nil + } + displayIndices = lo.RangeFrom(startIdx, endIdx-startIdx) + } else { + if len(displayIndices) == 0 { + return nil + } + startIdx = displayIndices[0] + endIdx = displayIndices[len(displayIndices)-1] + 1 } // this is where my non-TODO commits begin rebaseOffset := min(indexOfFirstNonTODOCommit(commits), endIdx) - filteredCommits := commits[startIdx:endIdx] - bisectBounds := getbisectBounds(commits, bisectInfo) // function expects to be passed the index of the commit in terms of the `commits` slice @@ -129,24 +141,29 @@ func GetCommitListDisplayStrings( return allGraphLines[idx-startIdx] } } else { - // this is where the graph begins (may be beyond the TODO commits depending on startIdx, - // but we'll never include TODO commits as part of the graph because it'll be messy) - graphOffset := max(startIdx, rebaseOffset) - + // the graph never includes TODO commits because it'll be messy, + // so only render rows from the first non-TODO commit on pipeSets := loadPipesets(commits[rebaseOffset:]) - pipeSetOffset := max(startIdx-rebaseOffset, 0) - graphPipeSets := pipeSets[pipeSetOffset:max(endIdx-rebaseOffset, 0)] - graphCommits := commits[graphOffset:endIdx] + graphIndices := lo.Filter(displayIndices, func(idx int, _ int) bool { + return idx >= rebaseOffset + }) + graphPipeSets := lo.Map(graphIndices, func(idx int, _ int) []graph.Pipe { + return pipeSets[idx-rebaseOffset] + }) + graphCommits := lo.Map(graphIndices, func(idx int, _ int) *models.Commit { + return commits[idx] + }) graphLines := graph.RenderAux( graphPipeSets, graphCommits, selectedCommitHashPtr, ) + graphLineByIndex := make(map[int]string, len(graphIndices)) + for i, idx := range graphIndices { + graphLineByIndex[idx] = graphLines[i] + } getGraphLine = func(idx int) string { - if idx >= graphOffset { - return graphLines[idx-graphOffset] - } - return "" + return graphLineByIndex[idx] } } } else { @@ -177,11 +194,11 @@ func GetCommitListDisplayStrings( (hasRebaseUpdateRefsConfig || b.CommitHash != commits[0].Hash()) })) - lines := make([][]string, 0, len(filteredCommits)) + lines := make([][]string, 0, len(displayIndices)) var bisectStatus BisectStatus willBeRebased := markedBaseCommit == "" - for i, commit := range filteredCommits { - unfilteredIdx := i + startIdx + for _, unfilteredIdx := range displayIndices { + commit := commits[unfilteredIdx] bisectStatus = getBisectStatus(unfilteredIdx, commit.Hash(), bisectInfo, bisectBounds) isMarkedBaseCommit := commit.Hash() != "" && commit.Hash() == markedBaseCommit if isMarkedBaseCommit { diff --git a/pkg/gui/presentation/commits_test.go b/pkg/gui/presentation/commits_test.go index 12e0fc1d661..3de8f495b12 100644 --- a/pkg/gui/presentation/commits_test.go +++ b/pkg/gui/presentation/commits_test.go @@ -39,6 +39,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) { selectedCommitHashPtr *string startIdx int endIdx int + displayIndices []int showGraph bool bisectInfo *git_commands.BisectInfo expected string @@ -209,6 +210,51 @@ func TestGetCommitListDisplayStrings(t *testing.T) { hash5 ○ commit5 `), }, + { + testName: "showing graph with display indices, keeping the shape of the full graph", + commitOpts: []models.NewCommitOpts{ + {Name: "commit1", Hash: "hash1", Parents: []string{"hash2", "hash3"}}, + {Name: "commit2", Hash: "hash2", Parents: []string{"hash3"}}, + {Name: "commit3", Hash: "hash3", Parents: []string{"hash4"}}, + {Name: "commit4", Hash: "hash4", Parents: []string{"hash5"}}, + {Name: "commit5", Hash: "hash5", Parents: []string{"hash7"}}, + }, + startIdx: 0, + endIdx: 5, + displayIndices: []int{0, 2, 4}, + showGraph: true, + bisectInfo: git_commands.NewNullBisectInfo(), + cherryPickedCommitHashSet: set.New[string](), + now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + expected: formatExpected(` + hash1 ◎─╮ commit1 + hash3 ○─╯ commit3 + hash5 ○ commit5 + `), + }, + { + testName: "showing graph with display indices, including rebase commits", + commitOpts: []models.NewCommitOpts{ + {Name: "commit1", Hash: "hash1", Parents: []string{"hash2", "hash3"}, Action: todo.Pick}, + {Name: "commit2", Hash: "hash2", Parents: []string{"hash3"}, Action: todo.Pick}, + {Name: "commit3", Hash: "hash3", Parents: []string{"hash4"}}, + {Name: "commit4", Hash: "hash4", Parents: []string{"hash5"}}, + {Name: "commit5", Hash: "hash5", Parents: []string{"hash7"}}, + }, + startIdx: 0, + endIdx: 5, + displayIndices: []int{0, 1, 2, 4}, + showGraph: true, + bisectInfo: git_commands.NewNullBisectInfo(), + cherryPickedCommitHashSet: set.New[string](), + now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + expected: formatExpected(` + hash1 pick commit1 + hash2 pick commit2 + hash3 ○ commit3 + hash5 ○ commit5 + `), + }, { testName: "showing graph, including rebase commits", commitOpts: []models.NewCommitOpts{ @@ -564,6 +610,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) { s.selectedCommitHashPtr, s.startIdx, s.endIdx, + s.displayIndices, s.showGraph, s.bisectInfo, ) diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 08ff53bb0fd..0792dc03cab 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -316,17 +316,26 @@ func (self *MenuItem) ID() 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 - Worktrees []*models.Worktree - PullRequests []*models.GithubPullRequest - PullRequestsMap map[string]*models.GithubPullRequest + CommitFiles []*models.CommitFile + Files []*models.File + Submodules []*models.SubmoduleConfig + Branches []*models.Branch + Commits []*models.Commit + StashEntries []*models.StashEntry + + // When the commits panel's overview mode is active, Commits contains only + // the rows the overview shows; OverviewCommitsSource then holds the full + // commit list, and OverviewCommitIndices holds, for each entry of Commits, + // its index in OverviewCommitsSource. The graph is rendered from the full + // list so that hiding rows doesn't change its shape. Both are nil while + // overview mode is off. + OverviewCommitsSource []*models.Commit + OverviewCommitIndices []int + SubCommits []*models.Commit + Remotes []*models.Remote + Worktrees []*models.Worktree + PullRequests []*models.GithubPullRequest + PullRequestsMap map[string]*models.GithubPullRequest // FilteredReflogCommits are the ones that appear in the reflog panel. // When in filtering mode we only include the ones that match the given path diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 69ea7012f27..e1c28221738 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -665,6 +665,10 @@ type TranslationSet struct { WillCancelExistingFilterTooltip string MustExitFilterModeTitle string MustExitFilterModePrompt string + MustExitOverviewModePrompt string + OverviewModeStatus string + ExitOverviewMode string + CommitsOverviewTitle string Diff string EnterRefToDiff string EnterRefName string @@ -808,6 +812,8 @@ type TranslationSet struct { OpenLogMenuTooltip string LogMenuTitle string ToggleShowGitGraphAll string + ToggleOverviewMode string + ToggleOverviewModeTooltip string ShowGitGraph string ShowGitGraphTooltip string SortOrder string @@ -1817,6 +1823,10 @@ func EnglishTranslationSet() *TranslationSet { WillCancelExistingFilterTooltip: "Note: this will cancel the existing filter", MustExitFilterModeTitle: "Command not available", MustExitFilterModePrompt: "Command not available in filter-by-path mode. Exit filter-by-path mode?", + MustExitOverviewModePrompt: "Command not available in overview mode. Exit overview mode?", + OverviewModeStatus: "Showing commits overview", + ExitOverviewMode: "Exit overview mode", + CommitsOverviewTitle: "Commits (overview)", Diff: "Diff", EnterRefToDiff: "Enter ref to diff", EnterRefName: "Enter ref:", @@ -1959,6 +1969,8 @@ func EnglishTranslationSet() *TranslationSet { OpenLogMenuTooltip: "View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph.", LogMenuTitle: "Commit Log Options", ToggleShowGitGraphAll: "Toggle show whole git graph (pass the `--all` flag to `git log`)", + ToggleOverviewMode: "Toggle overview mode", + ToggleOverviewModeTooltip: "Only show merge commits, and commits that a local or remote branch, a tag, or HEAD points to; hide all other commits. Useful for getting a high-level view of the history.", ShowGitGraph: "Show git graph", ShowGitGraphTooltip: "Show or hide the git graph in the commit log.\n\nThe default can be changed in the config file with the key 'git.log.showGraph'.", SortOrder: "Sort order", diff --git a/pkg/integration/tests/commit/toggle_overview_mode.go b/pkg/integration/tests/commit/toggle_overview_mode.go new file mode 100644 index 00000000000..009dd0af215 --- /dev/null +++ b/pkg/integration/tests/commit/toggle_overview_mode.go @@ -0,0 +1,146 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ToggleOverviewMode = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Toggle overview mode to only show merge commits and commits that a ref points to", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + // leave the graph on so that rendering it over the rewritten parents + // is exercised too + config.GetUserConfig().Git.Log.ShowGraph = "always" + }, + SetupRepo: func(shell *Shell) { + shell. + EmptyCommitWithDate("base", "2023-04-07 10:00:00"). + NewBranch("feature"). + EmptyCommitWithDate("feature-1", "2023-04-07 11:00:00"). + EmptyCommitWithDate("feature-2", "2023-04-07 12:00:00"). + Checkout("master"). + EmptyCommitWithDate("master-1", "2023-04-07 13:00:00"). + Merge("feature"). + EmptyCommitWithDate("master-2", "2023-04-07 14:00:00"). + CreateLightweightTag("some-tag", "HEAD"). + EmptyCommitWithDate("master-3", "2023-04-07 15:00:00") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("master-3"), + Contains("master-2"), + Contains("Merge branch 'feature'"), + Contains("feature-2"), + Contains("feature-1"), + Contains("master-1"), + Contains("base"), + ). + Press(keys.Commits.ToggleOverviewMode). + Lines( + // the tip of the checked-out branch, the merge commit, the + // tagged commit, and the tip of the feature branch remain; + // the graph keeps the shape it has in the full list, with the + // merge's join line and the feature branch's lane intact + Contains("master-3"), + Contains("master-2"), + Contains("Merge branch 'feature'").Contains("◎─╮"), + Contains("feature-2").Contains("│ ○"), + ) + + t.Views().Information().Content(Contains("Showing commits overview")) + + t.Views().Commits(). + Title(Equals("Commits (overview)")). + Press(keys.Commits.ToggleOverviewMode). + Title(Equals("Commits")). + Lines( + Contains("master-3"), + Contains("master-2"), + Contains("Merge branch 'feature'"), + Contains("feature-2"), + Contains("feature-1"), + Contains("master-1"), + Contains("base"), + ) + + t.Views().Information().Content(DoesNotContain("Showing commits overview")) + + // collapsing while on a commit that gets hidden moves the selection to + // the nearest visible commit above it, and expanding again jumps back + t.Views().Commits(). + NavigateToLine(Contains("feature-1")). + Press(keys.Commits.ToggleOverviewMode). + Lines( + Contains("master-3"), + Contains("master-2"), + Contains("Merge branch 'feature'"), + Contains("feature-2").IsSelected(), + ). + Press(keys.Commits.ToggleOverviewMode). + Lines( + Contains("master-3"), + Contains("master-2"), + Contains("Merge branch 'feature'"), + Contains("feature-2"), + Contains("feature-1").IsSelected(), + Contains("master-1"), + Contains("base"), + ) + + // ... but not if the selection was moved while collapsed + t.Views().Commits(). + NavigateToLine(Contains("master-1")). + Press(keys.Commits.ToggleOverviewMode). + Lines( + Contains("master-3"), + Contains("master-2"), + Contains("Merge branch 'feature'"), + Contains("feature-2").IsSelected(), + ). + NavigateToLine(Contains("master-3")). + Press(keys.Commits.ToggleOverviewMode). + Lines( + Contains("master-3").IsSelected(), + Contains("master-2"), + Contains("Merge branch 'feature'"), + Contains("feature-2"), + Contains("feature-1"), + Contains("master-1"), + Contains("base"), + ) + + // commands that rebase relative to the selection's position in the + // list require exiting overview mode first + t.Views().Commits(). + Press(keys.Commits.ToggleOverviewMode). + Lines( + Contains("master-3"), + Contains("master-2"), + Contains("Merge branch 'feature'"), + Contains("feature-2"), + ). + Press(keys.Commits.SquashDown) + + t.ExpectPopup().Confirmation(). + Title(Equals("Command not available")). + Content(Equals("Command not available in overview mode. Exit overview mode?")). + Confirm() + + t.Views().Commits(). + Lines( + Contains("master-3"), + Contains("master-2"), + Contains("Merge branch 'feature'"), + Contains("feature-2"), + Contains("feature-1"), + Contains("master-1"), + Contains("base"), + ) + + t.Views().Information().Content(DoesNotContain("Showing commits overview")) + }, +}) diff --git a/pkg/integration/tests/commit/toggle_overview_mode_scrolls_selection_into_view.go b/pkg/integration/tests/commit/toggle_overview_mode_scrolls_selection_into_view.go new file mode 100644 index 00000000000..a1447d4afdb --- /dev/null +++ b/pkg/integration/tests/commit/toggle_overview_mode_scrolls_selection_into_view.go @@ -0,0 +1,43 @@ +package commit + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ToggleOverviewModeScrollsSelectionIntoView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Enabling overview mode scrolls the moved selection into view", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + // A long tail of tagged commits keeps the overview list taller than + // the viewport, so the view can't get away with not scrolling; a long + // run of plain commits on top lets us scroll the selection far away + // from where it ends up after collapsing. + shell.EmptyCommit("base") + for i := 1; i <= 80; i++ { + shell.EmptyCommit(fmt.Sprintf("tagged-%02d", i)) + shell.CreateLightweightTag(fmt.Sprintf("tag-%02d", i), "HEAD") + } + for i := 1; i <= 60; i++ { + shell.EmptyCommit(fmt.Sprintf("plain-%02d", i)) + } + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // plain-01 is deep down the list, so navigating to it scrolls the + // view; every commit above it is plain except the HEAD commit, so + // collapsing moves the selection to the very top of the overview, + // which must scroll back into view. + t.Views().Commits(). + Focus(). + NavigateToLine(Contains("plain-01")). + Press(keys.Commits.ToggleOverviewMode). + SelectedLine(Contains("plain-60")). + TopLines( + Contains("plain-60"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index abf13073ed0..bad15d6d0d8 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -159,6 +159,8 @@ var tests = []*components.IntegrationTest{ commit.StageRangeOfLines, commit.Staged, commit.StagedWithoutHooks, + commit.ToggleOverviewMode, + commit.ToggleOverviewModeScrollsSelectionIntoView, commit.Unstaged, config.CustomCommandsInPerRepoConfig, config.NegativeRefspec, diff --git a/schema-master/config.json b/schema-master/config.json index 82dbebb0b7f..82e4aa831ae 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -1575,6 +1575,20 @@ ], "default": "\u003cctrl+l\u003e" }, + "toggleOverviewMode": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "~" + }, "openInBrowser": { "oneOf": [ {