Skip to content

Commit

Permalink
Support hyperlinks from pagers (#3825)
Browse files Browse the repository at this point in the history
- **PR Description**

Allows to use `delta --hyperlinks` as a pager, which turns line numbers
in the diff into clickable links that take you to the respective file.
For VS Code users, I recommend to combine this with
`--hyperlinks-file-link-format="vscode://file/{path}:{line}"`
so that it jumps to the right line.

In addition, I added a few commits that replaces our old, manual ad-hoc
handling of links in various places (status view, confirmation panels,
information view) with the new hyperlinks feature, which cleans up the
code a bit.

Fixes #3817.
  • Loading branch information
stefanhaller authored Aug 24, 2024
2 parents 61ae5e1 + bbd779b commit c28ecab
Show file tree
Hide file tree
Showing 23 changed files with 163 additions and 147 deletions.
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

0 comments on commit c28ecab

Please sign in to comment.