Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support hyperlinks from pagers #3825

Merged
merged 9 commits into from
Aug 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/Custom_Pagers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
6 changes: 1 addition & 5 deletions pkg/gui/controllers/helpers/confirmation_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:]
}
Expand Down
10 changes: 5 additions & 5 deletions pkg/gui/controllers/helpers/confirmation_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}

Expand Down
21 changes: 6 additions & 15 deletions pkg/gui/controllers/status_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down
8 changes: 0 additions & 8 deletions pkg/gui/global_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
23 changes: 23 additions & 0 deletions pkg/gui/gui.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"sync"
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 0 additions & 4 deletions pkg/gui/gui_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
27 changes: 2 additions & 25 deletions pkg/gui/information_panel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
6 changes: 0 additions & 6 deletions pkg/gui/keybindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions pkg/gui/style/hyperlink.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 0 additions & 4 deletions pkg/gui/types/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 0 additions & 26 deletions pkg/gui/view_helpers.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package gui

import (
"regexp"
"time"

"github.com/jesseduffield/gocui"
Expand Down Expand Up @@ -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
}
6 changes: 4 additions & 2 deletions pkg/i18n/english.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions pkg/integration/tests/ui/open_link_failure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
})
2 changes: 2 additions & 0 deletions pkg/utils/color.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions pkg/utils/color_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package utils

import (
"testing"

"github.com/jesseduffield/lazygit/pkg/gui/style"
)

func TestDecolorise(t *testing.T) {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading