Open a clicked diff line in the editor

Clickable renderer gutters are small and unavailable on some diff rows.
Make the whole row an editor target without changing focus or selection,
including while a popup is focused.

Use both Alt and Shift because terminal mouse protocols do not deliver
either modifier consistently across Ghostty, iTerm2, and VS Code.
This commit is contained in:
Stefan Haller
2026-10-01 17:28:30 +02:00
parent 4f8e918d34
commit 0b502af9ed
10 changed files with 160 additions and 13 deletions
+2
View File
@@ -938,6 +938,8 @@ It is used, for example, when pasting a commit message into the commit message p
There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file when you invoke it from a focused diff.
You can also open a line in your editor with the mouse: alt-click or shift-click it. Both modifiers do the same thing, because some terminals only support one or the other. The click leaves the focus and the selection where they are, so it works while you are reading a diff from another panel, or while a popup is open.
To tell lazygit which editor to use for the `e` command, the easiest way to do that is to provide an editPreset config, e.g.
```yaml
@@ -241,6 +241,20 @@ func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts)
Key: gocui.MouseRelease,
Handler: self.onDragRelease,
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModAlt,
Handler: self.editClickedLine,
HandleWhenPopupPanelFocused: true,
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModShift,
Handler: self.editClickedLine,
HandleWhenPopupPanelFocused: true,
},
}
}
@@ -518,6 +532,10 @@ func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouse
return nil
}
func (self *MainViewController) editClickedLine(opts gocui.ViewMouseBindingOpts) error {
return self.editDiffLine(opts.Y)
}
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
// Carry the select mode over from the pane we're leaving, so that clicking into
// the other pane keeps hunk mode even the first time we enter it — its own mode
+19 -11
View File
@@ -51,34 +51,40 @@ func (self *GuiDriver) PressKeysRapidly(keyStrs ...string) {
}
func (self *GuiDriver) Click(x, y int) {
self.ClickWithModifier(x, y, gocui.ModNone)
}
// ClickWithModifier clicks with a keyboard modifier held down for the whole
// gesture, as a terminal reports it.
func (self *GuiDriver) ClickWithModifier(x, y int, modifier gocui.Modifier) {
self.CheckAllToastsAcknowledged()
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
self.replayMouseEvent(x, y, tcell.ButtonNone)
self.replayMouseEvent(x, y, tcell.ButtonPrimary, modifier)
self.replayMouseEvent(x, y, tcell.ButtonNone, modifier)
}
func (self *GuiDriver) ClickAndHold(x, y int) {
self.CheckAllToastsAcknowledged()
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
self.replayMouseEvent(x, y, tcell.ButtonPrimary, gocui.ModNone)
}
// MouseMove reports the mouse at a new position with the left button still
// held down, i.e. a drag movement. (No test needs pointer motion without a
// button held, so that variant doesn't exist.)
func (self *GuiDriver) MouseMove(x, y int) {
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
self.replayMouseEvent(x, y, tcell.ButtonPrimary, gocui.ModNone)
}
func (self *GuiDriver) ScrollWheelDown(x, y int) {
self.replayMouseEvent(x, y, tcell.WheelDown)
self.replayMouseEvent(x, y, tcell.WheelDown, gocui.ModNone)
}
func (self *GuiDriver) MouseRelease(x, y int) {
self.replayMouseEvent(x, y, tcell.ButtonNone)
self.replayMouseEvent(x, y, tcell.ButtonNone, gocui.ModNone)
}
func (self *GuiDriver) MouseReleaseWithoutWaiting(x, y int) {
self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonNone)
self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonNone, gocui.ModNone)
}
func (self *GuiDriver) WaitUntilIdle() {
@@ -89,14 +95,16 @@ func (self *GuiDriver) OnUIThreadAndWait(f func()) {
_ = self.gui.g.OnUIThreadAndWait(f)
}
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) {
self.replayMouseEventWithoutWaiting(x, y, buttons)
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask, modifier gocui.Modifier) {
self.replayMouseEventWithoutWaiting(x, y, buttons, modifier)
self.waitTillIdle()
}
func (self *GuiDriver) replayMouseEventWithoutWaiting(x, y int, buttons tcell.ButtonMask) {
func (self *GuiDriver) replayMouseEventWithoutWaiting(
x, y int, buttons tcell.ButtonMask, modifier gocui.Modifier,
) {
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
tcell.NewEventMouse(x, y, buttons, 0),
tcell.NewEventMouse(x, y, buttons, tcell.ModMask(modifier)),
0,
))
}
@@ -6,6 +6,7 @@ import (
"time"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
)
@@ -60,6 +61,12 @@ func (self *TestDriver) click(x, y int) {
self.Wait(self.inputDelay)
}
func (self *TestDriver) clickWithModifier(x, y int, modifier gocui.Modifier, what string) {
self.SetCaption(fmt.Sprintf("%s-clicking %d, %d", what, x, y))
self.gui.ClickWithModifier(x, y, modifier)
self.Wait(self.inputDelay)
}
func (self *TestDriver) clickAndHold(x, y int) {
self.SetCaption(fmt.Sprintf("Clicking and holding %d, %d", x, y))
self.mouseX, self.mouseY = x, y
+10 -2
View File
@@ -47,6 +47,10 @@ func (self *fakeGuiDriver) Click(x, y int) {
self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y})
}
func (self *fakeGuiDriver) ClickWithModifier(x, y int, modifier gocui.Modifier) {
self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y})
}
func (self *fakeGuiDriver) ClickAndHold(x, y int) {
self.heldCoordinates = append(self.heldCoordinates, coordinate{x: x, y: y})
}
@@ -200,6 +204,8 @@ func TestViewDriverPointerCoordinates(t *testing.T) {
viewDriver.
Click(1, 2).
AltClick(2, 3).
ShiftClick(4, 5).
FocusInAndClick(3, 4).
ClickAndHold(5, 6).
MouseMove(7, 8).
@@ -207,11 +213,13 @@ func TestViewDriverPointerCoordinates(t *testing.T) {
MouseMoveToView(targetViewDriver, 10, 11).
ScrollWheelDown()
assert.Equal(t, []coordinate{{12, 23}, {14, 25}}, guiDriver.clickedCoordinates)
assert.Equal(t,
[]coordinate{{12, 23}, {13, 24}, {15, 26}, {14, 25}},
guiDriver.clickedCoordinates)
assert.Equal(t, []coordinate{{16, 27}}, guiDriver.heldCoordinates)
assert.Equal(t, []coordinate{{18, 29}, {20, 30}, {51, 62}}, guiDriver.movedCoordinates)
assert.Equal(t, []coordinate{{11, 21}}, guiDriver.scrolledCoordinates)
assert.Equal(t, 7, guiDriver.onUIThreadCallCount)
assert.Equal(t, 9, guiDriver.onUIThreadCallCount)
}
func TestFailingFixture(t *testing.T) {
+19
View File
@@ -726,6 +726,25 @@ func (self *ViewDriver) Click(x, y int) *ViewDriver {
return self
}
// AltClick and ShiftClick click with a modifier held down. Both modifiers are
// bound to the same gestures, because no single one of them reaches lazygit in
// every terminal.
func (self *ViewDriver) AltClick(x, y int) *ViewDriver {
offsetX, offsetY, _ := self.viewGeometry()
self.t.clickWithModifier(offsetX+1+x, offsetY+1+y, gocui.ModAlt, "Alt")
return self
}
func (self *ViewDriver) ShiftClick(x, y int) *ViewDriver {
offsetX, offsetY, _ := self.viewGeometry()
self.t.clickWithModifier(offsetX+1+x, offsetY+1+y, gocui.ModShift, "Shift")
return self
}
func (self *ViewDriver) FocusInAndClick(x, y int) *ViewDriver {
offsetX, offsetY, _ := self.viewGeometry()
@@ -0,0 +1,66 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var EditClickedDiffLine = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Alt- or shift-click a line of the main view's diff to open it in the editor, without focusing the view",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command"
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused()
// The click points at the line itself, so the main view can stay unfocused
// and unselected. You read a diff where it is and click into it.
t.Views().Main().
AltClick(0, 8).
Tap(func() {
t.FileSystem().FileContent("edit-command", Contains("/repo/file1:3\n"))
}).
SelectionIsHidden()
t.Views().Files().
IsFocused()
// Shift-click is bound to the same thing, since neither modifier reaches
// lazygit in every terminal. A context line names a line of the file like any
// other row.
t.Views().Main().
ShiftClick(0, 9).
Tap(func() {
t.FileSystem().FileContent("edit-command", Contains("/repo/file1:4\n"))
})
// A popup taking the focus swallows clicks on the views behind it. This one
// stays live, so a diff can still be read and clicked into while a popup is
// up.
t.Views().Files().
Press(keys.Universal.Remove)
t.Views().Menu().
IsFocused()
t.Views().Main().
AltClick(0, 6).
Tap(func() {
t.FileSystem().FileContent("edit-command", Contains("/repo/file1:2\n"))
})
t.Views().Menu().
IsFocused().
Press(keys.Universal.Return)
},
})
@@ -11,6 +11,7 @@ var NoSelectionOverAConflictHint = NewIntegrationTest(NewIntegrationTestArgs{
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.ShowFileTree = false
config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command"
},
SetupRepo: func(shell *Shell) {
shell.RunShellCommand(`echo 1 > foo && echo 1 > bar`)
@@ -47,6 +48,20 @@ var NoSelectionOverAConflictHint = NewIntegrationTest(NewIntegrationTestArgs{
PressPrimaryAction().
Tap(func() {
t.ExpectToast(Contains("There is nothing to select here"))
}).
// A pane with nothing to select still has lines to point at. A modified
// click names its own line rather than acting on the selection, so it
// opens the file there. The file is in the working tree for a conflict
// like this one, holding the modified side; you may want to copy a piece
// of it elsewhere before resolving the conflict by deleting the file.
//
// The cursor moves where a plain click points even here, so the assertion
// below says which row the modified click then lands on.
Click(0, 17).
SelectedLine(Contains("+2")).
AltClick(0, 17).
Tap(func() {
t.FileSystem().FileContent("edit-command", Contains("/repo/bar:1\n"))
})
},
})
+1
View File
@@ -391,6 +391,7 @@ var tests = []*components.IntegrationTest{
main_view.DiscardLinesFromACommit,
main_view.DragRangeWithAutoscroll,
main_view.DragSelectsDiffLineRange,
main_view.EditClickedDiffLine,
main_view.EditHistoricalDiffLine,
main_view.EditHunkInFocusedDiff,
main_view.EditSelectedDiffLine,
+3
View File
@@ -28,6 +28,9 @@ type GuiDriver interface {
// user typing faster than lazygit processes the input.
PressKeysRapidly(...string)
Click(int, int)
// Click with a keyboard modifier held down, for the gestures that only exist
// as a modified click.
ClickWithModifier(int, int, gocui.Modifier)
ClickAndHold(int, int)
MouseMove(int, int)
MouseRelease(int, int)