From 939bf831fb523cc95b7179f16466d953f4f9fe9b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 09:54:26 +0200 Subject: [PATCH 1/9] Cache Decolorise's result for a string that decolorises to nothing Decolorise stores its result in a cache keyed by the string it was given, but the lookup treats an empty result as a miss, so a string that decolorises to nothing is recomputed on every call. Recomputing it means compiling two regexes, and that costs 4534ns against 24.7ns for a string the cache does answer for. Empty cells are everywhere in the list panels, and RenderDisplayStrings measures every cell twice, once to work out the column widths and once to pad it. A commits panel showing 50 lines with one empty column spends about 0.45ms of every render on this. Read the cache with the two-value form, so that an empty result counts as an answer, and compile the two regexes once at package level. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/utils/color.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/utils/color.go b/pkg/utils/color.go index 17e400337..7dfeec093 100644 --- a/pkg/utils/color.go +++ b/pkg/utils/color.go @@ -12,21 +12,22 @@ import ( var ( decoloriseCache = make(map[string]string) decoloriseMutex sync.RWMutex + + colorCodeRe = regexp.MustCompile(`\x1B\[([0-9]{1,3}(;[0-9]{1,3})*)?[mGK]`) + linkRe = regexp.MustCompile(`\x1B]8;[^;]*;(.*?)(\x1B.|\x07)`) ) // Decolorise strips a string of color func Decolorise(str string) string { decoloriseMutex.RLock() - val := decoloriseCache[str] + val, ok := decoloriseCache[str] decoloriseMutex.RUnlock() - if val != "" { + if ok { return val } - re := regexp.MustCompile(`\x1B\[([0-9]{1,3}(;[0-9]{1,3})*)?[mGK]`) - linkRe := regexp.MustCompile(`\x1B]8;[^;]*;(.*?)(\x1B.|\x07)`) - ret := re.ReplaceAllString(str, "") + ret := colorCodeRe.ReplaceAllString(str, "") ret = linkRe.ReplaceAllString(ret, "") decoloriseMutex.Lock() From 53e9c730349c92f3a22d0d977242d68e20395cb9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 09:00:11 +0200 Subject: [PATCH 2/9] Separate the text of a commit's hash, bisect and action cells from its styling These three cells are built and styled in one step, so their width can only be measured by stripping the styling off again. A later commit needs those widths to reserve space for the columns they go into. Pull the text out into getHashText, getActionText and a getBisectStatusText that no longer styles what it returns, and apply the styling at the call site. A commit with no hash, such as a "break" or "update-ref" todo, now gets an empty hash cell instead of one holding nothing but colour codes. Nothing can see the difference: both render as the same number of blank columns. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/presentation/commits.go | 68 ++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/pkg/gui/presentation/commits.go b/pkg/gui/presentation/commits.go index 67fa62ac8..4bad77ce9 100644 --- a/pkg/gui/presentation/commits.go +++ b/pkg/gui/presentation/commits.go @@ -313,24 +313,18 @@ func getBisectStatus(index int, commitHash string, bisectInfo *git_commands.Bise } func getBisectStatusText(bisectStatus BisectStatus, bisectInfo *git_commands.BisectInfo) string { - if bisectStatus == BisectStatusNone { - return "" - } - - style := getBisectStatusColor(bisectStatus) - switch bisectStatus { case BisectStatusNew: - return style.Sprintf("<-- " + bisectInfo.NewTerm()) + return "<-- " + bisectInfo.NewTerm() case BisectStatusOld: - return style.Sprintf("<-- " + bisectInfo.OldTerm()) + return "<-- " + bisectInfo.OldTerm() case BisectStatusCurrent: // TODO: i18n - return style.Sprintf("<-- current") + return "<-- current" case BisectStatusSkipped: - return style.Sprintf("<-- skipped") + return "<-- skipped" case BisectStatusCandidate: - return style.Sprintf("?") + return "?" case BisectStatusNone: return "" } @@ -338,6 +332,33 @@ func getBisectStatusText(bisectStatus BisectStatus, bisectInfo *git_commands.Bis return "" } +func getHashText(commit *models.Commit, hashLength int) string { + hash := commit.Hash() + if hashLength >= len(hash) { + return hash + } + if hashLength > 0 { + return hash[:hashLength] + } + if !icons.IsIconEnabled() { // hashLength <= 0 + return "*" + } + return "" +} + +func getActionText(commit *models.Commit) string { + if commit.Action == models.ActionNone { + return "" + } + + text := commit.Action.String() + // Only show the flag for fixup commands (where -C changes the meaning) + if commit.ActionFlag != "" && commit.Action == todo.Fixup { + text += " " + commit.ActionFlag + } + return text +} + func displayCommit( common *common.Common, commit *models.Commit, @@ -356,17 +377,15 @@ func displayCommit( bisectStatus BisectStatus, bisectInfo *git_commands.BisectInfo, ) []string { - bisectString := getBisectStatusText(bisectStatus, bisectInfo) + bisectString := "" + if bisectText := getBisectStatusText(bisectStatus, bisectInfo); bisectText != "" { + bisectString = getBisectStatusColor(bisectStatus).Sprint(bisectText) + } - hashString := "" hashColor := getHashColor(commit, diffName, cherryPickedCommitHashSet, bisectStatus, bisectInfo) - hashLength := common.UserConfig().Gui.CommitHashLength - if hashLength >= len(commit.Hash()) { - hashString = hashColor.Sprint(commit.Hash()) - } else if hashLength > 0 { - hashString = hashColor.Sprint(commit.Hash()[:hashLength]) - } else if !icons.IsIconEnabled() { // hashLength <= 0 - hashString = hashColor.Sprint("*") + hashString := "" + if hashText := getHashText(commit, common.UserConfig().Gui.CommitHashLength); hashText != "" { + hashString = hashColor.Sprint(hashText) } divergenceString := "" @@ -384,13 +403,8 @@ func displayCommit( } actionString := "" - if commit.Action != models.ActionNone { - actionStr := commit.Action.String() - // Only show the flag for fixup commands (where -C changes the meaning) - if commit.ActionFlag != "" && commit.Action == todo.Fixup { - actionStr += " " + commit.ActionFlag - } - actionString = actionColorMap(commit.Action, commit.Status).Sprint(actionStr) + if actionText := getActionText(commit); actionText != "" { + actionString = actionColorMap(commit.Action, commit.Status).Sprint(actionText) } tagString := "" From 43e63bcc244c8c02b18d3e653996b19e147bbbf1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 09:01:45 +0200 Subject: [PATCH 3/9] Return early from WithPadding when there is nothing to pad to A caller that asks for a padding of zero gets its string back unchanged, but only after WithPadding has measured it, and measuring means a Decolorise lookup and a width scan over the result. A later commit pads four cells of every commit in the list, and asks for a padding of zero for all four whenever no rebase or bisect is in progress and the date column is hidden, so make that case cost nothing. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/utils/formatting.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/utils/formatting.go b/pkg/utils/formatting.go index 080f58f87..4fb1702f4 100644 --- a/pkg/utils/formatting.go +++ b/pkg/utils/formatting.go @@ -36,6 +36,12 @@ func StringWidth(s string) int { // WithPadding pads a string as much as you want func WithPadding(str string, padding int, alignment Alignment) string { + if padding <= 0 { + // Nothing to pad to, and measuring the string isn't free: Decolorise + // compiles a regex whenever it is called with a string it hasn't cached. + return str + } + uncoloredStr := Decolorise(str) width := StringWidth(uncoloredStr) if padding < width { From 56c2ffb8f807a8756bb7eeb6f8645cf9e0c96594 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 09:06:39 +0200 Subject: [PATCH 4/9] Don't strip leading spaces from a scenario's expected output formatExpected runs the expected output through strings.TrimSpace to get rid of the newlines that the raw string literal starts and ends with, but that also eats the indentation of the first line. A scenario whose first line starts with an empty column can't express what it expects, and a later commit adds two of those. Trim the newlines only. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/presentation/commits_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/gui/presentation/commits_test.go b/pkg/gui/presentation/commits_test.go index 12e0fc1d6..eb5365f03 100644 --- a/pkg/gui/presentation/commits_test.go +++ b/pkg/gui/presentation/commits_test.go @@ -17,8 +17,13 @@ import ( "github.com/xo/terminfo" ) +// Scenarios write their expected output as a raw string literal, indented with +// tabs so that it lines up with the surrounding code. Strip that indentation, +// along with the newlines after the opening backtick and before the closing +// one. Spaces are left alone, so that a scenario can expect a line that starts +// with an empty column. func formatExpected(expected string) string { - return strings.TrimSpace(strings.ReplaceAll(expected, "\t", "")) + return strings.Trim(strings.ReplaceAll(expected, "\t", ""), "\n") } func TestGetCommitListDisplayStrings(t *testing.T) { From 101424322ac02854f8703784c2feea49c2f18b39 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 09:06:51 +0200 Subject: [PATCH 5/9] Demonstrate that a commit list's columns are as wide as the visible lines need The commits panel renders only the lines that are on screen, so the width of a column follows what happens to be visible, and a column all of whose visible lines are empty disappears altogether. Scrolling past the pending rebase todos takes the action column away with them, and everything to its right jumps to the left. Cover the same problem for two more columns: the hash column, which goes away while only hashless todos such as "update-ref" are on screen, and the date column in the expanded commits panel, which is as narrow as the time format while only commits from today are on screen. This file is deliberately left un-gofumpt'd: gofumpt indents the body of the commented-out block by a tab, which would fill the diff of the commit that swaps the two blocks with whitespace-only changes. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/presentation/commits_test.go | 55 ++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/pkg/gui/presentation/commits_test.go b/pkg/gui/presentation/commits_test.go index eb5365f03..7efb5c5ae 100644 --- a/pkg/gui/presentation/commits_test.go +++ b/pkg/gui/presentation/commits_test.go @@ -274,6 +274,12 @@ func TestGetCommitListDisplayStrings(t *testing.T) { bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + /* EXPECTED: + expected: formatExpected(` + hash4 ○ commit4 + hash5 ○ commit5 + `), + ACTUAL: */ expected: formatExpected(` hash4 ○ commit4 hash5 ○ commit5 @@ -338,6 +344,31 @@ func TestGetCommitListDisplayStrings(t *testing.T) { hash2 pick commit2 `), }, + { + testName: "only showing TODO commits that have no hash", + commitOpts: []models.NewCommitOpts{ + {Name: "refs/heads/branch1", Action: todo.UpdateRef}, + {Name: "refs/heads/branch2", Action: todo.UpdateRef}, + {Name: "commit1", Hash: "hash1", Parents: []string{"hash2"}, Action: todo.Pick}, + {Name: "commit2", Hash: "hash2", Parents: []string{"hash3"}}, + }, + startIdx: 0, + endIdx: 2, + showGraph: false, + bisectInfo: git_commands.NewNullBisectInfo(), + cherryPickedCommitHashSet: set.New[string](), + now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + /* EXPECTED: + expected: formatExpected(` + update-ref branch1 + update-ref branch2 + `), + ACTUAL: */ + expected: formatExpected(` + update-ref branch1 + update-ref branch2 + `), + }, { testName: "graph in divergence view - all commits visible", commitOpts: []models.NewCommitOpts{ @@ -530,6 +561,30 @@ func TestGetCommitListDisplayStrings(t *testing.T) { hash2 2019-12-20 Jesse Duffield commit2 `), }, + { + testName: "only showing commits from today", + commitOpts: []models.NewCommitOpts{ + {Name: "commit1", Hash: "hash1", UnixTimestamp: 1577844184, AuthorName: "Jesse Duffield"}, + {Name: "commit2", Hash: "hash2", UnixTimestamp: 1576844184, AuthorName: "Jesse Duffield"}, + }, + fullDescription: true, + timeFormat: "2006-01-02", + shortTimeFormat: "3:04PM", + startIdx: 0, + endIdx: 1, + showGraph: false, + bisectInfo: git_commands.NewNullBisectInfo(), + cherryPickedCommitHashSet: set.New[string](), + now: time.Date(2020, 1, 1, 5, 3, 4, 0, time.UTC), + /* EXPECTED: + expected: formatExpected(` + hash1 2:03AM Jesse Duffield commit1 + `), + ACTUAL: */ + expected: formatExpected(` + hash1 2:03AM Jesse Duffield commit1 + `), + }, } oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone) From 410d7209b1821b7df9c42319286cceeb8d9504a2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 09:10:09 +0200 Subject: [PATCH 6/9] Reserve the width that all commits need for a commit list's columns During an interactive rebase, scrolling down far enough that the pending todos leave the screen makes the author and subject of every commit jump to the left; scrolling back up makes them jump back. During a bisect the column that holds "<-- current" and "?" comes and goes the same way, and in the expanded commits panel the date column is only as wide as gui.shortTimeFormat while nothing but today's commits is on screen. A column is as wide as the widest string in it, and a column whose strings are all empty is dropped altogether. The commits and sub-commits panels render only the lines that are on screen, so those widths come from the visible lines alone and follow the scroll position. Pad the hash, bisect, action and date cell of every line to the width that all the commits in the list need. A padded cell is no longer empty, so its column is never dropped, and the column is already as wide as the whole list needs, so its width no longer depends on what is visible. The date column is the exception. Formatting the date of every commit on every render costs too much, so it measures the oldest commit only. That covers the conventional time formats; getReservedColumnWidths says what it misses, and why it can never reserve width that no commit asks for. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/presentation/commits.go | 66 ++++++++++++++++++++++++++-- pkg/gui/presentation/commits_test.go | 17 ------- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/pkg/gui/presentation/commits.go b/pkg/gui/presentation/commits.go index 4bad77ce9..ace3b1b3c 100644 --- a/pkg/gui/presentation/commits.go +++ b/pkg/gui/presentation/commits.go @@ -177,6 +177,10 @@ func GetCommitListDisplayStrings( (hasRebaseUpdateRefsConfig || b.CommitHash != commits[0].Hash()) })) + reservedWidths := getReservedColumnWidths( + commits, common.UserConfig().Gui.CommitHashLength, fullDescription, + timeFormat, shortTimeFormat, now, bisectInfo, bisectBounds) + lines := make([][]string, 0, len(filteredCommits)) var bisectStatus BisectStatus willBeRebased := markedBaseCommit == "" @@ -204,11 +208,64 @@ func GetCommitListDisplayStrings( fullDescription, bisectStatus, bisectInfo, + reservedWidths, )) } return lines } +// The width of a column is the width of the widest string in it, and a column +// whose strings are all empty is left out entirely. The panels that show a +// commit list hand over only the lines that are on screen, so several columns +// would change their width, or come and go, as the user scrolls. These are the +// widths those columns need for all the commits in the list. +type reservedColumnWidths struct { + hash int + bisect int + description int + action int +} + +// precondition: commits is not empty +func getReservedColumnWidths( + commits []*models.Commit, + hashLength int, + fullDescription bool, + timeFormat string, + shortTimeFormat string, + now time.Time, + bisectInfo *git_commands.BisectInfo, + bisectBounds *bisectBounds, +) reservedColumnWidths { + result := reservedColumnWidths{} + + for i, commit := range commits { + result.hash = max(result.hash, utils.StringWidth(getHashText(commit, hashLength))) + if commit.IsTODO() { + result.action = max(result.action, utils.StringWidth(getActionText(commit))) + } + bisectStatus := getBisectStatus(i, commit.Hash(), bisectInfo, bisectBounds) + result.bisect = max(result.bisect, + utils.StringWidth(getBisectStatusText(bisectStatus, bisectInfo))) + } + + if fullDescription { + // Formatting the date of every commit on every render would be too + // expensive, so measure the oldest one only. It is the one least likely + // to be from today, and so the one most likely to be shown in the long + // time format; with a conventional time format that one is both wider + // than the short format and the same width for every date, which makes + // it the width the whole column needs. An unconventional format can + // break either of those assumptions, and then some of the column's + // width still comes and goes; it can never reserve more width than one + // of the commits asks for, though. + result.description = utils.StringWidth(utils.UnixToDateSmart( + now, commits[len(commits)-1].UnixTimestamp, timeFormat, shortTimeFormat)) + } + + return result +} + func getbisectBounds(commits []*models.Commit, bisectInfo *git_commands.BisectInfo) *bisectBounds { if !bisectInfo.Bisecting() { return nil @@ -376,6 +433,7 @@ func displayCommit( fullDescription bool, bisectStatus BisectStatus, bisectInfo *git_commands.BisectInfo, + reservedWidths reservedColumnWidths, ) []string { bisectString := "" if bisectText := getBisectStatusText(bisectStatus, bisectInfo); bisectText != "" { @@ -457,10 +515,10 @@ func displayCommit( cols = append( cols, divergenceString, - hashString, - bisectString, - descriptionString, - actionString, + utils.WithPadding(hashString, reservedWidths.hash, utils.AlignLeft), + utils.WithPadding(bisectString, reservedWidths.bisect, utils.AlignLeft), + utils.WithPadding(descriptionString, reservedWidths.description, utils.AlignLeft), + utils.WithPadding(actionString, reservedWidths.action, utils.AlignLeft), author, graphLine+mark+tagString+theme.DefaultTextColor.Sprint(name), ) diff --git a/pkg/gui/presentation/commits_test.go b/pkg/gui/presentation/commits_test.go index 7efb5c5ae..3c3ce0044 100644 --- a/pkg/gui/presentation/commits_test.go +++ b/pkg/gui/presentation/commits_test.go @@ -274,16 +274,10 @@ func TestGetCommitListDisplayStrings(t *testing.T) { bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), - /* EXPECTED: expected: formatExpected(` hash4 ○ commit4 hash5 ○ commit5 `), - ACTUAL: */ - expected: formatExpected(` - hash4 ○ commit4 - hash5 ○ commit5 - `), }, { testName: "only showing TODO commits", @@ -358,16 +352,10 @@ func TestGetCommitListDisplayStrings(t *testing.T) { bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), - /* EXPECTED: expected: formatExpected(` update-ref branch1 update-ref branch2 `), - ACTUAL: */ - expected: formatExpected(` - update-ref branch1 - update-ref branch2 - `), }, { testName: "graph in divergence view - all commits visible", @@ -576,14 +564,9 @@ func TestGetCommitListDisplayStrings(t *testing.T) { bisectInfo: git_commands.NewNullBisectInfo(), cherryPickedCommitHashSet: set.New[string](), now: time.Date(2020, 1, 1, 5, 3, 4, 0, time.UTC), - /* EXPECTED: expected: formatExpected(` hash1 2:03AM Jesse Duffield commit1 `), - ACTUAL: */ - expected: formatExpected(` - hash1 2:03AM Jesse Duffield commit1 - `), }, } From 0720a05ae41ad3a55d551b6c1726bdbcbf215c1e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 09:46:34 +0200 Subject: [PATCH 7/9] Hand the whole reflog to GetReflogCommitListDisplayStrings The reflog commits context slices the visible lines out itself and passes only those, so the function has no way to look at the rest of the list. A following commit needs the oldest entry to work out how much width the date column needs. Take the whole list along with the range to render, the way GetCommitListDisplayStrings already does. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/context/reflog_commits_context.go | 9 +++------ pkg/gui/presentation/reflog_commits.go | 8 ++++++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pkg/gui/context/reflog_commits_context.go b/pkg/gui/context/reflog_commits_context.go index 6358fbbb0..71314bf66 100644 --- a/pkg/gui/context/reflog_commits_context.go +++ b/pkg/gui/context/reflog_commits_context.go @@ -27,13 +27,10 @@ func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext { ) getDisplayStrings := func(startIdx int, endIdx int) [][]string { - commits := viewModel.GetItems() - if startIdx >= len(commits) { - return nil - } - return presentation.GetReflogCommitListDisplayStrings( - commits[startIdx:endIdx], + viewModel.GetItems(), + startIdx, + endIdx, c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL, c.Modes().CherryPicking.SelectedHashSet(), c.Modes().Diffing.Ref, diff --git a/pkg/gui/presentation/reflog_commits.go b/pkg/gui/presentation/reflog_commits.go index b84a10e59..785776864 100644 --- a/pkg/gui/presentation/reflog_commits.go +++ b/pkg/gui/presentation/reflog_commits.go @@ -12,7 +12,11 @@ import ( "github.com/samber/lo" ) -func GetReflogCommitListDisplayStrings(commits []*models.Commit, fullDescription bool, cherryPickedCommitHashSet *set.Set[string], diffName string, now time.Time, timeFormat string, shortTimeFormat string, parseEmoji bool) [][]string { +func GetReflogCommitListDisplayStrings(commits []*models.Commit, startIdx int, endIdx int, fullDescription bool, cherryPickedCommitHashSet *set.Set[string], diffName string, now time.Time, timeFormat string, shortTimeFormat string, parseEmoji bool) [][]string { + if startIdx >= len(commits) { + return nil + } + var displayFunc func(*models.Commit, reflogCommitDisplayAttributes) []string if fullDescription { displayFunc = getFullDescriptionDisplayStringsForReflogCommit @@ -20,7 +24,7 @@ func GetReflogCommitListDisplayStrings(commits []*models.Commit, fullDescription displayFunc = getDisplayStringsForReflogCommit } - return lo.Map(commits, func(commit *models.Commit, _ int) []string { + return lo.Map(commits[startIdx:endIdx], func(commit *models.Commit, _ int) []string { diffed := commit.Hash() == diffName cherryPicked := cherryPickedCommitHashSet.Includes(commit.Hash()) return displayFunc(commit, From 7cf7b51467650896986e03dc67d4f2ba56d3dbd2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 09:47:12 +0200 Subject: [PATCH 8/9] Demonstrate that the reflog's date column is as wide as the visible lines need The reflog panel renders only the lines that are on screen too, so the expanded panel's date column is only as wide as gui.shortTimeFormat while nothing but entries from today is visible, and the message of every entry jumps to the left. A reflog reaches back in time, so scrolling crosses that boundary soon enough. GetReflogCommitListDisplayStrings had no tests at all, so cover the two shapes it can return along the way. This file is deliberately left un-gofumpt'd: gofumpt indents the body of the commented-out block by a tab, which would fill the diff of the commit that swaps the two blocks with whitespace-only changes. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/presentation/reflog_commits_test.go | 121 ++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 pkg/gui/presentation/reflog_commits_test.go diff --git a/pkg/gui/presentation/reflog_commits_test.go b/pkg/gui/presentation/reflog_commits_test.go new file mode 100644 index 000000000..ead3212e4 --- /dev/null +++ b/pkg/gui/presentation/reflog_commits_test.go @@ -0,0 +1,121 @@ +package presentation + +import ( + "strings" + "testing" + "time" + + "github.com/gookit/color" + "github.com/jesseduffield/generics/set" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/xo/terminfo" +) + +func TestGetReflogCommitListDisplayStrings(t *testing.T) { + scenarios := []struct { + testName string + commitOpts []models.NewCommitOpts + fullDescription bool + timeFormat string + shortTimeFormat string + now time.Time + startIdx int + endIdx int + expected string + }{ + { + testName: "no commits", + commitOpts: []models.NewCommitOpts{}, + startIdx: 0, + endIdx: 1, + now: time.Date(2020, 1, 1, 5, 3, 4, 0, time.UTC), + expected: "", + }, + { + testName: "some commits", + commitOpts: []models.NewCommitOpts{ + {Name: "checkout: moving from master to mybranch", Hash: "hash1"}, + {Name: "commit: make a change", Hash: "hash2"}, + }, + startIdx: 0, + endIdx: 2, + now: time.Date(2020, 1, 1, 5, 3, 4, 0, time.UTC), + expected: formatExpected(` + hash1 checkout: moving from master to mybranch + hash2 commit: make a change + `), + }, + { + testName: "full description", + commitOpts: []models.NewCommitOpts{ + {Name: "commit: today", Hash: "hash1", UnixTimestamp: 1577844184}, + {Name: "commit: a while ago", Hash: "hash2", UnixTimestamp: 1576844184}, + }, + fullDescription: true, + timeFormat: "2006-01-02", + shortTimeFormat: "3:04PM", + startIdx: 0, + endIdx: 2, + now: time.Date(2020, 1, 1, 5, 3, 4, 0, time.UTC), + expected: formatExpected(` + hash1 2:03AM commit: today + hash2 2019-12-20 commit: a while ago + `), + }, + { + testName: "only showing commits from today", + commitOpts: []models.NewCommitOpts{ + {Name: "commit: today", Hash: "hash1", UnixTimestamp: 1577844184}, + {Name: "commit: a while ago", Hash: "hash2", UnixTimestamp: 1576844184}, + }, + fullDescription: true, + timeFormat: "2006-01-02", + shortTimeFormat: "3:04PM", + startIdx: 0, + endIdx: 1, + now: time.Date(2020, 1, 1, 5, 3, 4, 0, time.UTC), + /* EXPECTED: + expected: formatExpected(` + hash1 2:03AM commit: today + `), + ACTUAL: */ + expected: formatExpected(` + hash1 2:03AM commit: today + `), + }, + } + + oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone) + defer color.ForceSetColorLevel(oldColorLevel) + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + hashPool := &utils.StringPool{} + + commits := lo.Map(s.commitOpts, + func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) }) + + result := GetReflogCommitListDisplayStrings( + commits, + s.startIdx, + s.endIdx, + s.fullDescription, + set.New[string](), + "", + s.now, + s.timeFormat, + s.shortTimeFormat, + false, + ) + + renderedLines, _ := utils.RenderDisplayStrings(result, nil) + renderedResult := strings.Join(renderedLines, "\n") + t.Logf("\n%s", renderedResult) + + assert.EqualValues(t, s.expected, renderedResult) + }) + } +} From 1d67763b7eddb05a3a7269e16d71abaa54c2abb8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 09:48:51 +0200 Subject: [PATCH 9/9] Reserve the width the whole reflog needs for its date column Pad the date of every line to the width that the oldest entry in the reflog asks for, so that the column keeps its width as the user scrolls. This is the same treatment the commits panel's date column gets, with the same limits; getReservedColumnWidths spells them out. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/presentation/reflog_commits.go | 26 +++++++++++++++------ pkg/gui/presentation/reflog_commits_test.go | 5 ---- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/pkg/gui/presentation/reflog_commits.go b/pkg/gui/presentation/reflog_commits.go index 785776864..50305b1d3 100644 --- a/pkg/gui/presentation/reflog_commits.go +++ b/pkg/gui/presentation/reflog_commits.go @@ -18,8 +18,13 @@ func GetReflogCommitListDisplayStrings(commits []*models.Commit, startIdx int, e } var displayFunc func(*models.Commit, reflogCommitDisplayAttributes) []string + reservedDateWidth := 0 if fullDescription { displayFunc = getFullDescriptionDisplayStringsForReflogCommit + // See getReservedColumnWidths for why the oldest entry alone decides + // how much width the date column needs. + reservedDateWidth = utils.StringWidth(utils.UnixToDateSmart( + now, commits[len(commits)-1].UnixTimestamp, timeFormat, shortTimeFormat)) } else { displayFunc = getDisplayStringsForReflogCommit } @@ -29,12 +34,13 @@ func GetReflogCommitListDisplayStrings(commits []*models.Commit, startIdx int, e cherryPicked := cherryPickedCommitHashSet.Includes(commit.Hash()) return displayFunc(commit, reflogCommitDisplayAttributes{ - cherryPicked: cherryPicked, - diffed: diffed, - parseEmoji: parseEmoji, - timeFormat: timeFormat, - shortTimeFormat: shortTimeFormat, - now: now, + cherryPicked: cherryPicked, + diffed: diffed, + parseEmoji: parseEmoji, + timeFormat: timeFormat, + shortTimeFormat: shortTimeFormat, + now: now, + reservedDateWidth: reservedDateWidth, }) }) } @@ -59,6 +65,9 @@ type reflogCommitDisplayAttributes struct { timeFormat string shortTimeFormat string now time.Time + // The width the date column needs for the whole reflog, not just for the + // lines that are on screen + reservedDateWidth int } func getFullDescriptionDisplayStringsForReflogCommit(c *models.Commit, attrs reflogCommitDisplayAttributes) []string { @@ -67,9 +76,12 @@ func getFullDescriptionDisplayStringsForReflogCommit(c *models.Commit, attrs ref name = emoji.Sprint(name) } + date := style.FgMagenta.Sprint( + utils.UnixToDateSmart(attrs.now, c.UnixTimestamp, attrs.timeFormat, attrs.shortTimeFormat)) + return []string{ reflogHashColor(attrs.cherryPicked, attrs.diffed).Sprint(c.ShortHash()), - style.FgMagenta.Sprint(utils.UnixToDateSmart(attrs.now, c.UnixTimestamp, attrs.timeFormat, attrs.shortTimeFormat)), + utils.WithPadding(date, attrs.reservedDateWidth, utils.AlignLeft), theme.DefaultTextColor.Sprint(name), } } diff --git a/pkg/gui/presentation/reflog_commits_test.go b/pkg/gui/presentation/reflog_commits_test.go index ead3212e4..9a81a7021 100644 --- a/pkg/gui/presentation/reflog_commits_test.go +++ b/pkg/gui/presentation/reflog_commits_test.go @@ -77,14 +77,9 @@ func TestGetReflogCommitListDisplayStrings(t *testing.T) { startIdx: 0, endIdx: 1, now: time.Date(2020, 1, 1, 5, 3, 4, 0, time.UTC), - /* EXPECTED: expected: formatExpected(` hash1 2:03AM commit: today `), - ACTUAL: */ - expected: formatExpected(` - hash1 2:03AM commit: today - `), }, }