From 25d5e782d87efc248c11080bfd6e62ecbf7c47f8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 20 Aug 2026 10:14:32 +0200 Subject: [PATCH] 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. --- docs-master/Config.md | 2 + pkg/gui/controllers/main_view_controller.go | 18 +++++ pkg/gui/gui_driver.go | 30 +++++---- pkg/integration/components/test_driver.go | 7 ++ pkg/integration/components/test_test.go | 12 +++- pkg/integration/components/view_driver.go | 19 ++++++ .../tests/main_view/edit_clicked_diff_line.go | 66 +++++++++++++++++++ .../no_selection_over_a_conflict_hint.go | 15 +++++ pkg/integration/tests/test_list.go | 1 + pkg/integration/types/types.go | 3 + 10 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 pkg/integration/tests/main_view/edit_clicked_diff_line.go diff --git a/docs-master/Config.md b/docs-master/Config.md index 9fb456f69..83c1aaf85 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -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 diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 5e717a804..816152604 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -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, + }, } } @@ -514,6 +528,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 diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 07a0b7d4c..af261b453 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -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, )) } diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index bd5bbfc24..825ce5688 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -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 diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index 3b608e698..ef77ddaec 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -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) { diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index c0d7e9222..827fc0c6a 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -725,6 +725,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() diff --git a/pkg/integration/tests/main_view/edit_clicked_diff_line.go b/pkg/integration/tests/main_view/edit_clicked_diff_line.go new file mode 100644 index 000000000..792fd09c8 --- /dev/null +++ b/pkg/integration/tests/main_view/edit_clicked_diff_line.go @@ -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) + }, +}) diff --git a/pkg/integration/tests/main_view/no_selection_over_a_conflict_hint.go b/pkg/integration/tests/main_view/no_selection_over_a_conflict_hint.go index 45cd19ce2..d4748365a 100644 --- a/pkg/integration/tests/main_view/no_selection_over_a_conflict_hint.go +++ b/pkg/integration/tests/main_view/no_selection_over_a_conflict_hint.go @@ -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")) }) }, }) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index dcfcf9466..2402e799f 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -390,6 +390,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, diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 325f4ea38..b6655aebc 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -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)