From 1e6e55a7559a7d763061e730f61363df14c029ec Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 09:54:04 +0200 Subject: [PATCH] Add a jump-to-file menu to the focused main view's diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../helpers/diff_line_navigation.go | 35 ++++++++++ pkg/gui/controllers/main_view_controller.go | 67 +++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/pkg/gui/controllers/helpers/diff_line_navigation.go b/pkg/gui/controllers/helpers/diff_line_navigation.go index 9d583f6ab..ef7b4324a 100644 --- a/pkg/gui/controllers/helpers/diff_line_navigation.go +++ b/pkg/gui/controllers/helpers/diff_line_navigation.go @@ -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 diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 9a37b0265..d4ff16f85 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -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