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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-09-19 10:16:04 +02:00
co-authored by Claude Opus 5
parent 101424322a
commit 410d7209b1
2 changed files with 62 additions and 21 deletions
+62 -4
View File
@@ -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),
)
-17
View File
@@ -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
`),
},
}