Repository: jesseduffield/lazygit (language: go)

Issue:
Support terminal hyperlinks to allow opening diff lines in editor when using delta pager
**Is your feature request related to a problem? Please describe.**
I'd like to be able to click / use a keybinding on lines of a diff in lazygit and have my editor open at that line.

**Describe the solution you'd like**
delta can be used as a pager with lazygit. `delta --hyperlinks` formats all line numbers in the diff as terminal (OSC) hyperlinks; e.g. with `--hyperlinks-file-link-format = "vscode://file/{path}:{line}"` a click on a line number opens that line in VSCode. I'd like the hyperlinks emitted by delta to be clickable (and have a keybinding to open?) inside layzgit.

**Describe alternatives you've considered**
- One alternative could be to construct customizable OSC hyperlinks for diff lines natively in lazygit. 
- Something could probably be done with custom commands but I think it would be less convenient than clicking on the line numbers

**Additional context**
- lazygit was modified to ignore OSC hyperlinks at https://github.com/jesseduffield/gocui/pull/24 (previously it was incorrectly displaying raw escape sequences)
- Clickable URL functionality was added to lazygit in https://github.com/jesseduffield/lazygit/pull/3446, but this PR opted not to use terminal hyperlink technology.
- VSCode, JetBrains IDEs, and Zed all support custom URL protocols this. Alternatively users can use `file://`, or customize the URL to send a request to an arbitrary service of their choice.
- OSC Hyperlinks are supported by many terminal emulators on any platforms today, as well as by tmux.
- In addition to delta, `GNU ls`, `ripgrep`, `fd`, etc all emit OSC hyperlinks.




Discussion / hints:
@dandavison A draft PR is available for early testing here: #3825.

While testing I found two issues with this:
- when command-clicking a link, iTerm2 also passes the click to lazygit, which means that when you are in the commit files panel, you'll go into custom patch staging mode. Very annoying. I filed this as an [iTerm2 issue](https://gitlab.com/gnachman/iterm2/-/issues/11803), but since the built-in terminal of VS Code behaves the same, I'm wondering if we are doing something wrong.
- when using the `vscode:` URI scheme, VS Code always opens the file in the current workspace, even if the file's workspace is also open in another window. This is very annoying, the `code` command-line tool doesn't have this problem. I filed this as a VS Code issue [here](https://github.com/microsoft/vscode/issues/225506).

Apart from that, however, the feature is fantastic, and I'm wondering how I could live without it for so long.
After testing various terminal emulators and seeing how varied their support for hyperlinks is, I came to the conclusion that OSC 8 hyperlinks and TUI apps don't mix well. Actually, thinking about it there's little reason for a TUI app to emit OSC 8 hyperlinks and rely on the terminal to handle them; we're better off doing this ourselves.

So I'm proposing a hybrid approach now, where we parse OSC 8 sequences coming from pagers, but then don't pass them on to the tcell layer. Instead, we remember the information in the gocui cell struct, and take care of underlining the links and handling clicks in them on our side (in gocui, that is).

I've modified the PR (#3825, still in draft mode) accordingly, and I'm very happy with the behavior now. It has several advantages:
- allows simple non-modifier clicks to launch links, like in a web browser (good for people like me who don't like having to put down their coffee mug)
- works in all terminal emulators, even those that don't support hyperlinks at all
- consistent UI: the underline looks the same in all terminal emulators (for OSC 8 hyperlinks, some underline them solid, some dotted, some only on hover, etc.)

Possible downsides:
- no mouse hover support. It would be nice to somehow highlight the URL as you hover over it; we could theoretically add this, but it's a bit of work, so I won't do that in the initial version.
- possible security issues, as there's no way to see what link you are getting sent to. So it would be possible for a malicious pager to send you to a fishing website. Some terminal emulators solve this by a) showing the link target in a status bar as you hover over it, and/or b) showing a confirmation dialog the first time you click a link for a particular target. We could theoretically build these in to lazygit or gocui, but again it's a lot of work, and not for the initial version.

I have been using this version for the last two days, and it has already become a total game changer for me.

@dandavison Please try it out and let me know what you think!
Based on a quick try so far, this is working beautifully for me in Wezterm and Alacritty.

My biggest question (and it's not a very big one) is about double-click to copy. In particular I'm thinking of commit SHAs, which delta hyperlinks (configurably, but the idea is it links to the web hosting of the repo), and which it is common to want to copy to system clipboard. And this this might partly be a question about copying text in lazygit regardless of your PR.

In Alacritty, I see that I can do it by holding down shift and double-clicking on the commit SHA. The unexpected behavior I'm seeing is that if I then click without shift held, on a bit of "blank" screen in lazygit, then the element remains highlighted, whereas my expectation (from web browsers) is that the highlighting will disappear. To make it disappear I have to shift-click elsewhere. In contrast, in Wezterm, the highlighting goes away if the mouse moves away from the element (i.e. on blur). Sorry this is a rather unclear question -- could you just confirm that the behavior related to double-click highlighting on hyperlinked elements is all as you expect?

I think it makes sense to me that a TUI can have its own opinions about how UI elements are presented that may differ from the terminal's native presentation of analogous elements. Of course, it results in some discord if I split terminal panes with lazygit in one.
I'm not really sure I can follow. When you say "double-click to copy", do you literally mean that, or do you mean "double click to select, and then press Cmd-C to copy"?

In iTerm2, I can hold the option key to select text (by dragging or double-clicking), which temporarily overrides mouse capturing. But of course, if I select a lazygit hyperlink this way and copy it, I copy the text and not the link target, which appears to be what you want.
> When you say "double-click to copy", do you literally mean that, or do you mean "double click to select, and then press Cmd-C to copy"?

Yes, that's what I mean. In Alacritty:
1. I hold down shift and double-click to select a commit SHA (I must hold down shift or else it would open the link)
2. Now the commit SHA is highlighted
3. Now, suppose I want the highlighting to go away. I was expecting a plain click in any blank region of the lazygit UI to have that effect, but it does not. A shift-click in a blank region does though.

I'm not saying this is wrong, I just want to check with you that it's as it should be.
iTerm2 does remove the highlight when you plain-click somewhere else. But that's the behavior of the terminal emulator, lazygit doesn't have any influence on it. It also sounds unrelated to hyperlinks to me.

Still curious: if you select an OSC 8 hyperlink in Alacritty and then copy, does it copy the text or the link target?
> if you select an OSC 8 hyperlink in Alacritty and then copy, does it copy the text or the link target?

It copied the text (as desired)

> It also sounds unrelated to hyperlinks to me.

Ah yes you're right I think this isn unrelated to hyperlinks; I'm just new to shift-clicking to select. (Natively Alacritty removes the highlight with a plain click; in lazygit that doesn't happen, but that's not relevant to this issue and possibly intended.)
> It copied the text (as desired)

In that case I'd recommend to use ctrl-o in the commits panel to copy commit hashes, that should be faster than shift-double-clicking and copying manually. I do this all the time.

> in lazygit that doesn't happen

Again, this has nothing to do with lazygit, but with Alacritty's handling of TUI apps. Whether it's intentional or not I have no idea, but after my recent (brief) interaction with the Alacritty devs I am not interested in finding out. 😄 
> after my recent (brief) interaction with the Alacritty devs I am not interested in finding out. 😄

@stefanhaller if there's a link to that interaction I would love to read it
Oh it's probably not as bad as I made it sound: https://github.com/alacritty/alacritty/issues/8129
Haha yeah not so bad. I saw how disagreeable alacritty's devs were about adding support for tabs/panes a while back and was wondering if you had accidentally revived that debate because that would be a spectacle to behold.
Oh, and now you got the popcorn out already; sorry to disappoint...

Fix patch:
diff --git a/docs/Custom_Pagers.md b/docs/Custom_Pagers.md
index f1b01b382dc..4324f77dc21 100644
--- a/docs/Custom_Pagers.md
+++ b/docs/Custom_Pagers.md
@@ -26,6 +26,8 @@ git:
 
 ![](https://i.imgur.com/QJpQkF3.png)
 
+A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `pager:` config to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
+
 ## Diff-so-fancy
 
 ```yaml
diff --git a/go.mod b/go.mod
index 59ea178ece7..73a312f9997 100644
--- a/go.mod
+++ b/go.mod
@@ -16,7 +16,7 @@ require (
 	github.com/integrii/flaggy v1.4.0
 	github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68
 	github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d
-	github.com/jesseduffield/gocui v0.3.1-0.20240824081936-a3adeb73f602
+	github.com/jesseduffield/gocui v0.3.1-0.20240824083442-15b7fbca7ae9
 	github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10
 	github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
 	github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e
diff --git a/go.sum b/go.sum
index 1605df6054e..caeb5acf75a 100644
--- a/go.sum
+++ b/go.sum
@@ -188,8 +188,8 @@ github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68 h1:EQP2Tv8T
 github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68/go.mod h1:+LLj9/WUPAP8LqCchs7P+7X0R98HiFujVFANdNaxhGk=
 github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d h1:bO+OmbreIv91rCe8NmscRwhFSqkDJtzWCPV4Y+SQuXE=
 github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d/go.mod h1:nGNEErzf+NRznT+N2SWqmHnDnF9aLgANB1CUNEan09o=
-github.com/jesseduffield/gocui v0.3.1-0.20240824081936-a3adeb73f602 h1:nzGt/sRT0WCancALG5Q9e4DlQWGo7QUMc35rApdt+aM=
-github.com/jesseduffield/gocui v0.3.1-0.20240824081936-a3adeb73f602/go.mod h1:XtEbqCbn45keRXEu+OMZkjN5gw6AEob59afsgHjokZ8=
+github.com/jesseduffield/gocui v0.3.1-0.20240824083442-15b7fbca7ae9 h1:1muwCO0cmCGHpOvNz1qTOrCFPECnBAV87yDE9Fgwy6U=
+github.com/jesseduffield/gocui v0.3.1-0.20240824083442-15b7fbca7ae9/go.mod h1:XtEbqCbn45keRXEu+OMZkjN5gw6AEob59afsgHjokZ8=
 github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 h1:jmpr7KpX2+2GRiE91zTgfq49QvgiqB0nbmlwZ8UnOx0=
 github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10/go.mod h1:aA97kHeNA+sj2Hbki0pvLslmE4CbDyhBeSSTUUnOuVo=
 github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY=
diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go
index 25f05906b95..8b5919c3c78 100644
--- a/pkg/gui/controllers/helpers/confirmation_helper.go
+++ b/pkg/gui/controllers/helpers/confirmation_helper.go
@@ -259,11 +259,7 @@ func underlineLinks(text string) string {
 		} else {
 			linkEnd += linkStart
 		}
-		underlinedLink := style.AttrUnderline.Sprint(remaining[linkStart:linkEnd])
-		if strings.HasSuffix(underlinedLink, "\x1b[0m") {
-			// Replace the "all styles off" code with "underline off" code
-			underlinedLink = underlinedLink[:len(underlinedLink)-2] + "24m"
-		}
+		underlinedLink := style.PrintSimpleHyperlink(remaining[linkStart:linkEnd])
 		result += remaining[:linkStart] + underlinedLink
 		remaining = remaining[linkEnd:]
 	}
diff --git a/pkg/gui/controllers/status_controller.go b/pkg/gui/controllers/status_controller.go
index 05b3181c144..ab7a6a0d507 100644
--- a/pkg/gui/controllers/status_controller.go
+++ b/pkg/gui/controllers/status_controller.go
@@ -71,11 +71,6 @@ func (self *StatusController) GetKeybindings(opts types.KeybindingsOpts) []*type
 
 func (self *StatusController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
 	return []*gocui.ViewMouseBinding{
-		{
-			ViewName: "main",
-			Key:      gocui.MouseLeft,
-			Handler:  self.onClickMain,
-		},
 		{
 			ViewName: self.Context().GetViewName(),
 			Key:      gocui.MouseLeft,
@@ -84,10 +79,6 @@ func (self *StatusController) GetMouseKeybindings(opts types.KeybindingsOpts) []
 	}
 }
 
-func (self *StatusController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
-	return self.c.HandleGenericClick(self.c.Views().Main)
-}
-
 func (self *StatusController) GetOnRenderToMain() func() error {
 	return func() error {
 		switch self.c.UserConfig().Gui.StatusPanelView {
@@ -219,12 +210,12 @@ func (self *StatusController) showDashboard() error {
 		[]string{
 			lazygitTitle(),
 			fmt.Sprintf("Copyright %d Jesse Duffield", time.Now().Year()),
-			fmt.Sprintf("Keybindings: %s", style.AttrUnderline.Sprint(fmt.Sprintf(constants.Links.Docs.Keybindings, versionStr))),
-			fmt.Sprintf("Config Options: %s", style.AttrUnderline.Sprint(fmt.Sprintf(constants.Links.Docs.Config, versionStr))),
-			fmt.Sprintf("Tutorial: %s", style.AttrUnderline.Sprint(constants.Links.Docs.Tutorial)),
-			fmt.Sprintf("Raise an Issue: %s", style.AttrUnderline.Sprint(constants.Links.Issues)),
-			fmt.Sprintf("Release Notes: %s", style.AttrUnderline.Sprint(constants.Links.Releases)),
-			style.FgMagenta.Sprintf("Become a sponsor: %s", style.AttrUnderline.Sprint(constants.Links.Donate)), // caffeine ain't free
+			fmt.Sprintf("Keybindings: %s", style.PrintSimpleHyperlink(fmt.Sprintf(constants.Links.Docs.Keybindings, versionStr))),
+			fmt.Sprintf("Config Options: %s", style.PrintSimpleHyperlink(fmt.Sprintf(constants.Links.Docs.Config, versionStr))),
+			fmt.Sprintf("Tutorial: %s", style.PrintSimpleHyperlink(constants.Links.Docs.Tutorial)),
+			fmt.Sprintf("Raise an Issue: %s", style.PrintSimpleHyperlink(constants.Links.Issues)),
+			fmt.Sprintf("Release Notes: %s", style.PrintSimpleHyperlink(constants.Links.Releases)),
+			style.FgMagenta.Sprintf("Become a sponsor: %s", style.PrintSimpleHyperlink(constants.Links.Donate)), // caffeine ain't free
 		}, "\n\n") + "\n"
 
 	return self.c.RenderToMainViews(types.RefreshMainOpts{
diff --git a/pkg/gui/global_handlers.go b/pkg/gui/global_handlers.go
index e75dfb8f550..9721b4b2ab0 100644
--- a/pkg/gui/global_handlers.go
+++ b/pkg/gui/global_handlers.go
@@ -109,14 +109,6 @@ func (gui *Gui) scrollDownConfirmationPanel() error {
 	return nil
 }
 
-func (gui *Gui) handleConfirmationClick() error {
-	if gui.Views.Confirmation.Editable {
-		return nil
-	}
-
-	return gui.handleGenericClick(gui.Views.Confirmation)
-}
-
 func (gui *Gui) handleCopySelectedSideContextItemToClipboard() error {
 	return gui.handleCopySelectedSideContextItemToClipboardWithTruncation(-1)
 }
diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go
index c26774fd0f2..d608fb3a434 100644
--- a/pkg/gui/gui.go
+++ b/pkg/gui/gui.go
@@ -7,6 +7,7 @@ import (
 	"os"
 	"path/filepath"
 	"reflect"
+	"regexp"
 	"sort"
 	"strings"
 	"sync"
@@ -359,6 +360,28 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
 		return nil
 	})
 
+	gui.g.SetOpenHyperlinkFunc(func(url string) error {
+		if strings.HasPrefix(url, "lazygit-edit:") {
+			re := regexp.MustCompile(`^lazygit-edit://(.+?)(?::(\d+))?$`)
+			matches := re.FindStringSubmatch(url)
+			if matches == nil {
+				return fmt.Errorf(gui.Tr.InvalidLazygitEditURL, url)
+			}
+			filepath := matches[1]
+			if matches[2] != "" {
+				lineNumber := utils.MustConvertToInt(matches[2])
+				return gui.helpers.Files.EditFileAtLine(filepath, lineNumber)
+			}
+			return gui.helpers.Files.EditFiles([]string{filepath})
+		}
+
+		if err := gui.os.OpenLink(url); err != nil {
+			return fmt.Errorf(gui.Tr.FailedToOpenURL, url, err)
+		}
+
+		return nil
+	})
+
 	// if a context key has been given, push that instead, and set its index to 0
 	if contextKey != context.NO_CONTEXT {
 		contextToPush = gui.c.ContextForKey(contextKey)
diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go
index 2b132c3d1e0..1c56301d977 100644
--- a/pkg/gui/gui_common.go
+++ b/pkg/gui/gui_common.go
@@ -33,10 +33,6 @@ func (self *guiCommon) PostRefreshUpdate(context types.Context) error {
 	return self.gui.postRefreshUpdate(context)
 }
 
-func (self *guiCommon) HandleGenericClick(view *gocui.View) error {
-	return self.gui.handleGenericClick(view)
-}
-
 func (self *guiCommon) RunSubprocessAndRefresh(cmdObj oscommands.ICmdObj) error {
 	return self.gui.runSubprocessWithSuspenseAndRefresh(cmdObj)
 }
diff --git a/pkg/gui/information_panel.go b/pkg/gui/information_panel.go
index 3eac1e77cf4..03e4dd8788e 100644
--- a/pkg/gui/information_panel.go
+++ b/pkg/gui/information_panel.go
@@ -14,8 +14,8 @@ func (gui *Gui) informationStr() string {
 	}
 
 	if gui.g.Mouse {
-		donate := style.FgMagenta.SetUnderline().Sprint(gui.c.Tr.Donate)
-		askQuestion := style.FgYellow.SetUnderline().Sprint(gui.c.Tr.AskQuestion)
+		donate := style.FgMagenta.Sprint(style.PrintHyperlink(gui.c.Tr.Donate, constants.Links.Donate))
+		askQuestion := style.FgYellow.Sprint(style.PrintHyperlink(gui.c.Tr.AskQuestion, constants.Links.Discussions))
 		return fmt.Sprintf("%s %s %s", donate, askQuestion, gui.Config.GetVersion())
 	} else {
 		return gui.Config.GetVersion()
@@ -39,28 +39,5 @@ func (gui *Gui) handleInfoClick() error {
 		return activeMode.Reset()
 	}
 
-	var title, url string
-
-	// if we're not in an active mode we show the donate button
-	if cx <= utils.StringWidth(gui.c.Tr.Donate) {
-		url = constants.Links.Donate
-		title = gui.c.Tr.Donate
-	} else if cx <= utils.StringWidth(gui.c.Tr.Donate)+1+utils.StringWidth(gui.c.Tr.AskQuestion) {
-		url = constants.Links.Discussions
-		title = gui.c.Tr.AskQuestion
-	}
-	err := gui.os.OpenLink(url)
-	if err != nil {
-		// Opening the link via the OS failed for some reason. (For example, this
-		// can happen if the `os.openLink` config key references a command that
-		// doesn't exist, or that errors when called.)
-		//
-		// In that case, rather than crash the app, fall back to simply showing a
-		// dialog asking the user to visit the URL.
-		placeholders := map[string]string{"url": url}
-		message := utils.ResolvePlaceholderString(gui.c.Tr.PleaseGoToURL, placeholders)
-		return gui.c.Alert(title, message)
-	}
-
 	return nil
 }
diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go
index 357c752aa56..a3da576596c 100644
--- a/pkg/gui/keybindings.go
+++ b/pkg/gui/keybindings.go
@@ -248,12 +248,6 @@ func (self *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBi
 			Modifier: gocui.ModNone,
 			Handler:  self.scrollDownConfirmationPanel,
 		},
-		{
-			ViewName: "confirmation",
-			Key:      gocui.MouseLeft,
-			Modifier: gocui.ModNone,
-			Handler:  self.handleConfirmationClick,
-		},
 		{
 			ViewName: "confirmation",
 			Key:      gocui.MouseWheelUp,
diff --git a/pkg/gui/style/hyperlink.go b/pkg/gui/style/hyperlink.go
new file mode 100644
index 00000000000..0585e89a957
--- /dev/null
+++ b/pkg/gui/style/hyperlink.go
@@ -0,0 +1,13 @@
+package style
+
+import "fmt"
+
+// Render the given text as an OSC 8 hyperlink
+func PrintHyperlink(text string, link string) string {
+	return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", link, text)
+}
+
+// Render a link where the text is the same as a link
+func PrintSimpleHyperlink(link string) string {
+	return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", link, link)
+}
diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go
index b4a731c5952..9b7ccf34ff9 100644
--- a/pkg/gui/types/common.go
+++ b/pkg/gui/types/common.go
@@ -35,10 +35,6 @@ type IGuiCommon interface {
 	// case would be overkill, although refresh will internally call 'PostRefreshUpdate'
 	PostRefreshUpdate(Context) error
 
-	// a generic click handler that can be used for any view; it handles opening
-	// URLs in the browser when the user clicks on one
-	HandleGenericClick(view *gocui.View) error
-
 	// renders string to a view without resetting its origin
 	SetViewContent(view *gocui.View, content string)
 	// resets cursor and origin of view. Often used before calling SetViewContent
diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go
index 19eb3778301..1ae6251e192 100644
--- a/pkg/gui/view_helpers.go
+++ b/pkg/gui/view_helpers.go
@@ -1,7 +1,6 @@
 package gui
 
 import (
-	"regexp"
 	"time"
 
 	"github.com/jesseduffield/gocui"
@@ -149,28 +148,3 @@ func (gui *Gui) postRefreshUpdate(c types.Context) error {
 
 	return nil
 }
-
-// handleGenericClick is a generic click handler that can be used for any view.
-// It handles opening URLs in the browser when the user clicks on one.
-func (gui *Gui) handleGenericClick(view *gocui.View) error {
-	cx, cy := view.Cursor()
-	word, err := view.Word(cx, cy)
-	if err != nil {
-		return nil
-	}
-
-	// Allow URLs to be wrapped in angle brackets, and the closing bracket to
-	// be followed by punctuation:
-	re := regexp.MustCompile(`^<?(https://.+?)(>[,.;!]*)?$`)
-	matches := re.FindStringSubmatch(word)
-	if matches == nil {
-		return nil
-	}
-
-	// Ignore errors (opening the link via the OS can fail if the
-	// `os.openLink` config key references a command that doesn't exist, or
-	// that errors when called.)
-	_ = gui.c.OS().OpenLink(matches[1])
-
-	return nil
-}
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index f4520a3ee0d..b1eff335e42 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -784,7 +784,8 @@ type TranslationSet struct {
 	MarkAsBaseCommit                         string
 	MarkAsBaseCommitTooltip                  string
 	MarkedCommitMarker                       string
-	PleaseGoToURL                            string
+	FailedToOpenURL                          string
+	InvalidLazygitEditURL                    string
 	NoCopiedCommits                          string
 	DisabledMenuItemPrefix                   string
 	QuickStartInteractiveRebase              string
@@ -1770,7 +1771,8 @@ func EnglishTranslationSet() *TranslationSet {
 		MarkAsBaseCommit:                         "Mark as base commit for rebase",
 		MarkAsBaseCommitTooltip:                  "Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command.",
 		MarkedCommitMarker:                       "↑↑↑ Will rebase from here ↑↑↑",
-		PleaseGoToURL:                            "Please go to {{.url}}",
+		FailedToOpenURL:                          "Failed to open URL %s\n\nError: %v",
+		InvalidLazygitEditURL:                    "Invalid lazygit-edit URL format: %s",
 		DisabledMenuItemPrefix:                   "Disabled: ",
 		NoCopiedCommits:                          "No copied commits",
 		QuickStartInteractiveRebase:              "Start interactive rebase",
diff --git a/pkg/utils/color.go b/pkg/utils/color.go
index a4ad578e055..05e0aa9bc03 100644
--- a/pkg/utils/color.go
+++ b/pkg/utils/color.go
@@ -25,7 +25,9 @@ func Decolorise(str string) string {
 	}
 
 	re := regexp.MustCompile(`\x1B\[([0-9]{1,3}(;[0-9]{1,3})*)?[mGK]`)
+	linkRe := regexp.MustCompile(`\x1B]8;[^;]*;(.*?)(\x1B.|\x07)`)
 	ret := re.ReplaceAllString(str, "")
+	ret = linkRe.ReplaceAllString(ret, "")
 
 	decoloriseMutex.Lock()
 	decoloriseCache[str] = ret
diff --git a/vendor/github.com/jesseduffield/gocui/escape.go b/vendor/github.com/jesseduffield/gocui/escape.go
index b52c2149525..f559bbb2589 100644
--- a/vendor/github.com/jesseduffield/gocui/escape.go
+++ b/vendor/github.com/jesseduffield/gocui/escape.go
@@ -17,6 +17,7 @@ type escapeInterpreter struct {
 	curFgColor, curBgColor Attribute
 	mode                   OutputMode
 	instruction            instruction
+	hyperlink              string
 }
 
 type (
@@ -40,7 +41,11 @@ const (
 	stateCSI
 	stateParams
 	stateOSC
-	stateOSCEscape
+	stateOSCWaitForParams
+	stateOSCParams
+	stateOSCHyperlink
+	stateOSCEndEscape
+	stateOSCSkipUnknown
 
 	bold      fontEffect = 1
 	faint     fontEffect = 2
@@ -60,6 +65,7 @@ var (
 	errNotCSI        = errors.New("Not a CSI escape sequence")
 	errCSIParseError = errors.New("CSI escape sequence parsing error")
 	errCSITooLong    = errors.New("CSI escape sequence is too long")
+	errOSCParseError = errors.New("OSC escape sequence parsing error")
 )
 
 // runes in case of error will output the non-parsed runes as a string.
@@ -78,6 +84,7 @@ func (ei *escapeInterpreter) runes() []rune {
 			ret = append(ret, ';')
 		}
 		return append(ret, ei.curch)
+	default:
 	}
 	return nil
 }
@@ -191,15 +198,47 @@ func (ei *escapeInterpreter) parseOne(ch rune) (isEscape bool, err error) {
 			return false, errCSIParseError
 		}
 	case stateOSC:
+		if ch == '8' {
+			ei.state = stateOSCWaitForParams
+			ei.hyperlink = ""
+			return true, nil
+		}
+
+		ei.state = stateOSCSkipUnknown
+		return true, nil
+	case stateOSCWaitForParams:
+		if ch != ';' {
+			return true, errOSCParseError
+		}
+
+		ei.state = stateOSCParams
+		return true, nil
+	case stateOSCParams:
+		if ch == ';' {
+			ei.state = stateOSCHyperlink
+		}
+		return true, nil
+	case stateOSCHyperlink:
 		switch ch {
+		case 0x07:
+			ei.state = stateNone
 		case 0x1b:
-			ei.state = stateOSCEscape
-			return true, nil
+			ei.state = stateOSCEndEscape
+		default:
+			ei.hyperlink += string(ch)
 		}
 		return true, nil
-	case stateOSCEscape:
+	case stateOSCEndEscape:
 		ei.state = stateNone
 		return true, nil
+	case stateOSCSkipUnknown:
+		switch ch {
+		case 0x07:
+			ei.state = stateNone
+		case 0x1b:
+			ei.state = stateOSCEndEscape
+		}
+		return true, nil
 	}
 	return false, nil
 }
@@ -267,58 +306,48 @@ func (ei *escapeInterpreter) outputCSI() error {
 
 func (ei *escapeInterpreter) csiColor(param []string) (color Attribute, skip int, err error) {
 	if len(param) < 2 {
-		err = errCSIParseError
-		return
+		return 0, 0, errCSIParseError
 	}
 
 	switch param[1] {
 	case "2":
 		// 24-bit color
 		if ei.mode < OutputTrue {
-			err = errCSIParseError
-			return
+			return 0, 0, errCSIParseError
 		}
 		if len(param) < 5 {
-			err = errCSIParseError
-			return
+			return 0, 0, errCSIParseError
 		}
 		var red, green, blue int
 		red, err = strconv.Atoi(param[2])
 		if err != nil {
-			err = errCSIParseError
-			return
+			return 0, 0, errCSIParseError
 		}
 		green, err = strconv.Atoi(param[3])
 		if err != nil {
-			err = errCSIParseError
-			return
+			return 0, 0, errCSIParseError
 		}
 		blue, err = strconv.Atoi(param[4])
 		if err != nil {
-			err = errCSIParseError
-			return
+			return 0, 0, errCSIParseError
 		}
 		return NewRGBColor(int32(red), int32(green), int32(blue)), 5, nil
 	case "5":
 		// 8-bit color
 		if ei.mode < Output256 {
-			err = errCSIParseError
-			return
+			return 0, 0, errCSIParseError
 		}
 		if len(param) < 3 {
-			err = errCSIParseError
-			return
+			return 0, 0, errCSIParseError
 		}
 		var hex int
 		hex, err = strconv.Atoi(param[2])
 		if err != nil {
-			err = errCSIParseError
-			return
+			return 0, 0, errCSIParseError
 		}
 		return Get256Color(int32(hex)), 3, nil
 	default:
-		err = errCSIParseError
-		return
+		return 0, 0, errCSIParseError
 	}
 }
 
diff --git a/vendor/github.com/jesseduffield/gocui/gui.go b/vendor/github.com/jesseduffield/gocui/gui.go
index c1ee93ce407..9d848d93ded 100644
--- a/vendor/github.com/jesseduffield/gocui/gui.go
+++ b/vendor/github.com/jesseduffield/gocui/gui.go
@@ -130,6 +130,7 @@ type Gui struct {
 	managers          []Manager
 	keybindings       []*keybinding
 	focusHandler      func(bool) error
+	openHyperlink     func(string) error
 	maxX, maxY        int
 	outputMode        OutputMode
 	stop              chan struct{}
@@ -624,6 +625,10 @@ func (g *Gui) SetFocusHandler(handler func(bool) error) {
 	g.focusHandler = handler
 }
 
+func (g *Gui) SetOpenHyperlinkFunc(openHyperlinkFunc func(string) error) {
+	g.openHyperlink = openHyperlinkFunc
+}
+
 // getKey takes an empty interface with a key and returns the corresponding
 // typed Key or rune.
 func getKey(key interface{}) (Key, rune, error) {
@@ -1302,7 +1307,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
 	switch ev.Type {
 	case eventKey:
 
-		_, err := g.execKeybindings(g.currentView, ev)
+		err := g.execKeybindings(g.currentView, ev)
 		if err != nil {
 			return err
 		}
@@ -1367,6 +1372,14 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
 			}
 		}
 
+		if ev.Key == MouseLeft && !v.Editable && g.openHyperlink != nil {
+			if newY >= 0 && newY <= len(v.viewLines)-1 && newX >= 0 && newX <= len(v.viewLines[newY].line)-1 {
+				if link := v.viewLines[newY].line[newX].hyperlink; link != "" {
+					return g.openHyperlink(link)
+				}
+			}
+		}
+
 		if IsMouseKey(ev.Key) {
 			opts := ViewMouseBindingOpts{X: newX, Y: newY}
 			matched, err := g.execMouseKeybindings(v, ev, opts)
@@ -1378,9 +1391,11 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
 			}
 		}
 
-		if _, err := g.execKeybindings(v, ev); err != nil {
+		if err := g.execKeybindings(v, ev); err != nil {
 			return err
 		}
+
+	default:
 	}
 
 	return nil
@@ -1440,25 +1455,25 @@ func IsMouseScrollKey(key interface{}) bool {
 }
 
 // execKeybindings executes the keybinding handlers that match the passed view
-// and event. The value of matched is true if there is a match and no errors.
-func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) (matched bool, err error) {
+// and event.
+func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
 	var globalKb *keybinding
 	var matchingParentViewKb *keybinding
 
 	// if we're searching, and we've hit n/N/Esc, we ignore the default keybinding
 	if v != nil && v.IsSearching() && ev.Mod == ModNone {
 		if eventMatchesKey(ev, g.NextSearchMatchKey) {
-			return true, v.gotoNextMatch()
+			return v.gotoNextMatch()
 		} else if eventMatchesKey(ev, g.PrevSearchMatchKey) {
-			return true, v.gotoPreviousMatch()
+			return v.gotoPreviousMatch()
 		} else if eventMatchesKey(ev, g.SearchEscapeKey) {
 			v.searcher.clearSearch()
 			if g.OnSearchEscape != nil {
 				if err := g.OnSearchEscape(); err != nil {
-					return true, err
+					return err
 				}
 			}
-			return true, nil
+			return nil
 		}
 	}
 
@@ -1486,26 +1501,26 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) (matched bool, err error)
 	if g.currentView != nil && g.currentView.Editable && g.currentView.Editor != nil {
 		matched := g.currentView.Editor.Edit(g.currentView, ev.Key, ev.Ch, ev.Mod)
 		if matched {
-			return true, nil
+			return nil
 		}
 	}
 
 	if globalKb != nil {
 		return g.execKeybinding(v, globalKb)
 	}
-	return false, nil
+	return nil
 }
 
 // execKeybinding executes a given keybinding
-func (g *Gui) execKeybinding(v *View, kb *keybinding) (bool, error) {
+func (g *Gui) execKeybinding(v *View, kb *keybinding) error {
 	if g.isBlacklisted(kb.key) {
-		return true, nil
+		return nil
 	}
 
 	if err := kb.handler(g, v); err != nil {
-		return false, err
+		return err
 	}
-	return true, nil
+	return nil
 }
 
 func (g *Gui) onFocus(ev *GocuiEvent) error {
diff --git a/vendor/github.com/jesseduffield/gocui/tcell_driver.go b/vendor/github.com/jesseduffield/gocui/tcell_driver.go
index 96f24390f34..6665432c56a 100644
--- a/vendor/github.com/jesseduffield/gocui/tcell_driver.go
+++ b/vendor/github.com/jesseduffield/gocui/tcell_driver.go
@@ -363,6 +363,7 @@ func (g *Gui) pollEvent() GocuiEvent {
 				mouseKey = MouseRight
 			case tcell.ButtonMiddle:
 				mouseKey = MouseMiddle
+			default:
 			}
 		}
 
@@ -374,11 +375,13 @@ func (g *Gui) pollEvent() GocuiEvent {
 					dragState = NOT_DRAGGING
 				case tcell.ButtonSecondary:
 				case tcell.ButtonMiddle:
+				default:
 				}
 				mouseMod = Modifier(lastMouseMod)
 				lastMouseMod = tcell.ModNone
 				lastMouseKey = tcell.ButtonNone
 			}
+		default:
 		}
 
 		if !wheeling {
diff --git a/vendor/github.com/jesseduffield/gocui/view.go b/vendor/github.com/jesseduffield/gocui/view.go
index e12991ee9ec..0752a5e9c01 100644
--- a/vendor/github.com/jesseduffield/gocui/view.go
+++ b/vendor/github.com/jesseduffield/gocui/view.go
@@ -378,6 +378,7 @@ type viewLine struct {
 type cell struct {
 	chr              rune
 	bgColor, fgColor Attribute
+	hyperlink        string
 }
 
 type lineType []cell
@@ -851,9 +852,10 @@ func (v *View) parseInput(ch rune, x int, _ int) (bool, []cell) {
 			repeatCount = tabStop - (x % tabStop)
 		}
 		c := cell{
-			fgColor: v.ei.curFgColor,
-			bgColor: v.ei.curBgColor,
-			chr:     ch,
+			fgColor:   v.ei.curFgColor,
+			bgColor:   v.ei.curBgColor,
+			hyperlink: v.ei.hyperlink,
+			chr:       ch,
 		}
 		for i := 0; i < repeatCount; i++ {
 			cells = append(cells, c)
@@ -1188,6 +1190,9 @@ func (v *View) draw() error {
 			if bgColor == ColorDefault {
 				bgColor = v.BgColor
 			}
+			if c.hyperlink != "" {
+				fgColor |= AttrUnderline
+			}
 
 			if err := v.setRune(x, y, c.chr, fgColor, bgColor); err != nil {
 				return err
diff --git a/vendor/modules.txt b/vendor/modules.txt
index b271313d79c..e1a96bb7ba7 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -172,7 +172,7 @@ github.com/jesseduffield/go-git/v5/utils/merkletrie/filesystem
 github.com/jesseduffield/go-git/v5/utils/merkletrie/index
 github.com/jesseduffield/go-git/v5/utils/merkletrie/internal/frame
 github.com/jesseduffield/go-git/v5/utils/merkletrie/noder
-# github.com/jesseduffield/gocui v0.3.1-0.20240824081936-a3adeb73f602
+# github.com/jesseduffield/gocui v0.3.1-0.20240824083442-15b7fbca7ae9
 ## explicit; go 1.12
 github.com/jesseduffield/gocui
 # github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10


Test patch:
diff --git a/pkg/gui/controllers/helpers/confirmation_helper_test.go b/pkg/gui/controllers/helpers/confirmation_helper_test.go
index 488c72710ef..76f4329fd3c 100644
--- a/pkg/gui/controllers/helpers/confirmation_helper_test.go
+++ b/pkg/gui/controllers/helpers/confirmation_helper_test.go
@@ -27,27 +27,27 @@ func Test_underlineLinks(t *testing.T) {
 		{
 			name:           "entire string is a link",
 			text:           "https://example.com",
-			expectedResult: "\x1b[4mhttps://example.com\x1b[24m",
+			expectedResult: "\x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\",
 		},
 		{
 			name:           "link preceeded and followed by text",
 			text:           "bla https://example.com xyz",
-			expectedResult: "bla \x1b[4mhttps://example.com\x1b[24m xyz",
+			expectedResult: "bla \x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\ xyz",
 		},
 		{
 			name:           "more than one link",
 			text:           "bla https://link1 blubb https://link2 xyz",
-			expectedResult: "bla \x1b[4mhttps://link1\x1b[24m blubb \x1b[4mhttps://link2\x1b[24m xyz",
+			expectedResult: "bla \x1b]8;;https://link1\x1b\\https://link1\x1b]8;;\x1b\\ blubb \x1b]8;;https://link2\x1b\\https://link2\x1b]8;;\x1b\\ xyz",
 		},
 		{
 			name:           "link in angle brackets",
 			text:           "See <https://example.com> for details",
-			expectedResult: "See <\x1b[4mhttps://example.com\x1b[24m> for details",
+			expectedResult: "See <\x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\> for details",
 		},
 		{
 			name:           "link followed by newline",
 			text:           "URL: https://example.com\nNext line",
-			expectedResult: "URL: \x1b[4mhttps://example.com\x1b[24m\nNext line",
+			expectedResult: "URL: \x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\\nNext line",
 		},
 	}
 
diff --git a/pkg/integration/tests/ui/open_link_failure.go b/pkg/integration/tests/ui/open_link_failure.go
index 0bb45fb59a4..c4b27241ea3 100644
--- a/pkg/integration/tests/ui/open_link_failure.go
+++ b/pkg/integration/tests/ui/open_link_failure.go
@@ -17,8 +17,8 @@ var OpenLinkFailure = NewIntegrationTest(NewIntegrationTestArgs{
 		t.Views().Information().Click(0, 0)
 
 		t.ExpectPopup().Confirmation().
-			Title(Equals("Donate")).
-			Content(Equals("Please go to https://github.com/sponsors/jesseduffield")).
+			Title(Equals("Error")).
+			Content(Equals("Failed to open URL https://github.com/sponsors/jesseduffield\n\nError: exit status 42")).
 			Confirm()
 	},
 })
diff --git a/pkg/utils/color_test.go b/pkg/utils/color_test.go
index 1440f946c25..19770d63e74 100644
--- a/pkg/utils/color_test.go
+++ b/pkg/utils/color_test.go
@@ -2,6 +2,8 @@ package utils
 
 import (
 	"testing"
+
+	"github.com/jesseduffield/lazygit/pkg/gui/style"
 )
 
 func TestDecolorise(t *testing.T) {
@@ -189,6 +191,10 @@ func TestDecolorise(t *testing.T) {
 			input:  "\x1b[38;2;157;205;18mta\x1b[0m",
 			output: "ta",
 		},
+		{
+			input:  "a_" + style.PrintSimpleHyperlink("xyz") + "_b",
+			output: "a_xyz_b",
+		},
 	}
 
 	for _, test := range tests {