Add a jump-to-file menu to the focused main view's diff

n / N step through the files of a multi-file diff one at a time, which
is tedious when the diff spans many files. Add `f`, which pops up a menu
listing every file in the diff — in the order they appear, as
repo-relative paths — so you can jump straight to one.

Picking a file reuses the exact landing logic n / N use (navigate to the
file's first row), computed from the same backUpOverHeader machinery
AdjacentFile uses, so the menu and the step keys agree on where each
file begins and land identically.

The menu title and the keybinding description are hard-coded English for
now; this is a prototype.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-07-06 09:54:04 +02:00
parent 97b1b4cf7f
commit 1e6e55a755
2 changed files with 102 additions and 0 deletions

View file

@ -58,6 +58,41 @@ func (self *StagingHelper) AdjacentFile(view *gocui.View, anchorViewLine int, fo
return view.ViewLineForBufferLine(target)
}
// DiffFile is a file shown in a (possibly multi-file) diff: its absolute path and the
// view line its section starts at — the row that next/previous-file navigation lands on.
type DiffFile struct {
Path string
FirstViewLine int
}
// FilesInDiff lists the files shown in view's diff, in display order, each paired with
// the view line its section starts at. It is the jump-to-file menu's source: jumping to
// a file goes to its FirstViewLine, computed the same way (backUpOverHeader) that
// AdjacentFile lands on a file, so the menu and n/N agree on where each file begins. A
// file whose start row isn't currently mapped to a view line (not loaded yet) is skipped.
func (self *StagingHelper) FilesInDiff(view *gocui.View) []DiffFile {
resolved := self.resolveDiffLines(view.DiffLineContents())
paths := make([]string, len(resolved))
for i, r := range resolved {
if r.ok {
paths[i] = r.info.Path
}
}
var files []DiffFile
seen := map[string]bool{}
for i, path := range paths {
if path == "" || seen[path] {
continue
}
seen[path] = true
if viewLine, ok := view.ViewLineForBufferLine(backUpOverHeader(paths, i)); ok {
files = append(files, DiffFile{Path: path, FirstViewLine: viewLine})
}
}
return files
}
// FirstChangeLineInView returns the view line of the first change line at or below
// the top of the viewport, for placing the initial selection when focusing the main
// view: we select the first change the user can already see rather than jumping to

View file

@ -11,6 +11,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
@ -55,6 +56,7 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
var editTooltip string
var openPullRequestDescription string
var openPullRequestTooltip string
var jumpToFileDescription string
if selectionShown {
enterDescription = self.c.Tr.EnterStaging
editDescription = self.c.Tr.EditFile
@ -62,6 +64,7 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
// TODO: i18n-ize these
openPullRequestDescription = "Open pull request for selected line"
openPullRequestTooltip = "Open a browser at the selected line in the diff of the current branch's pull request, so that you can comment on it. Only works for local branches that have a pull request on GitHub."
jumpToFileDescription = "Jump to file"
}
var commitDescription string
@ -192,6 +195,11 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
Handler: self.nextFile,
Description: self.c.Tr.NextFile,
},
{
Keys: opts.GetKeys(config.Keybinding{"f"}),
Handler: self.openJumpToFileMenu,
Description: jumpToFileDescription,
},
{
// overriding this because we want to read all of the task's output before we start searching
Keys: opts.GetKeys(opts.Config.Universal.StartSearch),
@ -733,6 +741,65 @@ func (self *MainViewController) prevFile() error {
return nil
}
// openJumpToFileMenu pops up a menu listing the files in the focused main view's diff, in
// the order they appear, as repo-relative paths; picking one jumps straight to it. It's a
// complement to n / N for a diff that spans many files. A no-op when the main view holds
// no diff (nothing to list).
//
// The diff loads lazily, so we read it to the end first — otherwise a file past the loaded
// portion of a long diff would be missing from the menu (and have no view line to jump to).
// Same as handleGotoBottom.
func (self *MainViewController) openJumpToFileMenu() error {
manager := self.c.GetViewBufferManagerForView(self.context.GetView())
if manager == nil {
return nil
}
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
return self.showJumpToFileMenu()
})
})
return nil
}
func (self *MainViewController) showJumpToFileMenu() error {
files := self.c.Helpers().Staging.FilesInDiff(self.context.GetView())
if len(files) == 0 {
return nil
}
worktreePath := self.c.Git().RepoPaths.WorktreePath()
menuItems := lo.Map(files, func(file helpers.DiffFile, index int) *types.MenuItem {
label := file.Path
if rel, err := filepath.Rel(worktreePath, file.Path); err == nil {
label = rel
}
var keys []gocui.Key
if index < 9 {
keys = menuKey(rune(index + 1 + '0')) // Convert 1-based index to key
}
firstViewLine := file.FirstViewLine
return &types.MenuItem{
Label: label,
Keys: keys,
OnPress: func() error { self.jumpToFile(firstViewLine); return nil },
}
})
// TODO: i18n-ize this title
return self.c.Menu(types.CreateMenuOptions{Title: "Jump to file", Items: menuItems, HideCancel: true})
}
// jumpToFile moves the focused main view to the given view line exactly the way
// next/previous-file navigation does (see navigate), so jumping from the menu and
// stepping with n / N land identically — scrolling the file to the top with no selection,
// or moving the selection (or hunk selection) to it when one is showing.
func (self *MainViewController) jumpToFile(firstViewLine int) {
self.navigate(func(*gocui.View, int, bool) (int, bool) {
return firstViewLine, true
}, true)
}
// selectHunkAround re-selects the whole change block around the given change line,
// for hunk mode: the cursor goes to the block's first line and the range anchor to
// its last, so the native range highlight spans the block. With no change block