Skip to content

Commit

Permalink
Make URLs in confirmation panels clickable, and underline them (#3446)
Browse files Browse the repository at this point in the history
- **PR Description**

This is especially helpful for the breaking changes popup, which has a
link to the release notes, but it could also be useful for other panels
that display some warning or error with a link to more information.
  • Loading branch information
stefanhaller committed Mar 29, 2024
2 parents 1cedfa4 + 5d509ef commit 2385c1d
Show file tree
Hide file tree
Showing 13 changed files with 171 additions and 31 deletions.
28 changes: 27 additions & 1 deletion pkg/gui/controllers/helpers/confirmation_helper.go
Expand Up @@ -215,7 +215,7 @@ func (self *ConfirmationHelper) CreatePopupPanel(ctx goContext.Context, opts typ
confirmationView.RenderTextArea()
} else {
self.c.ResetViewOrigin(confirmationView)
self.c.SetViewContent(confirmationView, style.AttrBold.Sprint(opts.Prompt))
self.c.SetViewContent(confirmationView, style.AttrBold.Sprint(underlineLinks(opts.Prompt)))
}

if err := self.setKeyBindings(cancel, opts); err != nil {
Expand All @@ -228,6 +228,32 @@ func (self *ConfirmationHelper) CreatePopupPanel(ctx goContext.Context, opts typ
return self.c.PushContext(self.c.Contexts().Confirmation)
}

func underlineLinks(text string) string {
result := ""
remaining := text
for {
linkStart := strings.Index(remaining, "https://")
if linkStart == -1 {
break
}

linkEnd := strings.IndexAny(remaining[linkStart:], " \n>")
if linkEnd == -1 {
linkEnd = len(remaining)
} 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"
}
result += remaining[:linkStart] + underlinedLink
remaining = remaining[linkEnd:]
}
return result + remaining
}

func (self *ConfirmationHelper) setKeyBindings(cancel goContext.CancelFunc, opts types.CreatePopupPanelOpts) error {
var onConfirm func() error
if opts.HandleConfirmPrompt != nil {
Expand Down
63 changes: 63 additions & 0 deletions pkg/gui/controllers/helpers/confirmation_helper_test.go
@@ -0,0 +1,63 @@
package helpers

import (
"testing"

"github.com/gookit/color"
"github.com/stretchr/testify/assert"
"github.com/xo/terminfo"
)

func Test_underlineLinks(t *testing.T) {
scenarios := []struct {
name string
text string
expectedResult string
}{
{
name: "empty string",
text: "",
expectedResult: "",
},
{
name: "no links",
text: "abc",
expectedResult: "abc",
},
{
name: "entire string is a link",
text: "https://example.com",
expectedResult: "\x1b[4mhttps://example.com\x1b[24m",
},
{
name: "link preceeded and followed by text",
text: "bla https://example.com xyz",
expectedResult: "bla \x1b[4mhttps://example.com\x1b[24m 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",
},
{
name: "link in angle brackets",
text: "See <https://example.com> for details",
expectedResult: "See <\x1b[4mhttps://example.com\x1b[24m> for details",
},
{
name: "link followed by newline",
text: "URL: https://example.com\nNext line",
expectedResult: "URL: \x1b[4mhttps://example.com\x1b[24m\nNext line",
},
}

oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)

for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := underlineLinks(s.text)
assert.Equal(t, s.expectedResult, result)
})
}
}
13 changes: 1 addition & 12 deletions pkg/gui/controllers/status_controller.go
Expand Up @@ -79,18 +79,7 @@ func (self *StatusController) GetMouseKeybindings(opts types.KeybindingsOpts) []
}

func (self *StatusController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
view := self.c.Views().Main

cx, cy := view.Cursor()
url, err := view.Word(cx, cy)
if err == nil && strings.HasPrefix(url, "https://") {
// 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.)
_ = self.c.OS().OpenLink(url)
}

return nil
return self.c.HandleGenericClick(self.c.Views().Main)
}

func (self *StatusController) GetOnRenderToMain() func() error {
Expand Down
8 changes: 8 additions & 0 deletions pkg/gui/global_handlers.go
Expand Up @@ -109,6 +109,14 @@ 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
4 changes: 4 additions & 0 deletions pkg/gui/gui_common.go
Expand Up @@ -33,6 +33,10 @@ 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
6 changes: 6 additions & 0 deletions pkg/gui/keybindings.go
Expand Up @@ -247,6 +247,12 @@ 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
5 changes: 5 additions & 0 deletions pkg/gui/presentation/branches_test.go
Expand Up @@ -5,12 +5,14 @@ import (
"testing"
"time"

"github.com/gookit/color"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/xo/terminfo"
)

func Test_getBranchDisplayStrings(t *testing.T) {
Expand Down Expand Up @@ -223,6 +225,9 @@ func Test_getBranchDisplayStrings(t *testing.T) {
},
}

oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone)
defer color.ForceSetColorLevel(oldColorLevel)

c := utils.NewDummyCommon()

for i, s := range scenarios {
Expand Down
7 changes: 3 additions & 4 deletions pkg/gui/presentation/commits_test.go
Expand Up @@ -16,10 +16,6 @@ import (
"github.com/xo/terminfo"
)

func init() {
color.ForceSetColorLevel(terminfo.ColorLevelNone)
}

func formatExpected(expected string) string {
return strings.TrimSpace(strings.ReplaceAll(expected, "\t", ""))
}
Expand Down Expand Up @@ -385,6 +381,9 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
},
}

oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone)
defer color.ForceSetColorLevel(oldColorLevel)

os.Setenv("TZ", "UTC")

focusing := false
Expand Down
10 changes: 6 additions & 4 deletions pkg/gui/presentation/files_test.go
Expand Up @@ -13,10 +13,6 @@ import (
"github.com/xo/terminfo"
)

func init() {
color.ForceSetColorLevel(terminfo.ColorLevelNone)
}

func toStringSlice(str string) []string {
return strings.Split(strings.TrimSpace(str), "\n")
}
Expand Down Expand Up @@ -66,6 +62,9 @@ M file1
},
}

oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone)
defer color.ForceSetColorLevel(oldColorLevel)

for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
Expand Down Expand Up @@ -125,6 +124,9 @@ M file1
},
}

oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone)
defer color.ForceSetColorLevel(oldColorLevel)

for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
Expand Down
17 changes: 12 additions & 5 deletions pkg/gui/presentation/graph/graph_test.go
Expand Up @@ -15,11 +15,6 @@ import (
"github.com/xo/terminfo"
)

func init() {
// on CI we've got no color capability so we're forcing it here
color.ForceSetColorLevel(terminfo.ColorLevelMillions)
}

func TestRenderCommitGraph(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -218,6 +213,9 @@ func TestRenderCommitGraph(t *testing.T) {
},
}

oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)

for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
Expand Down Expand Up @@ -452,6 +450,9 @@ func TestRenderPipeSet(t *testing.T) {
},
}

oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)

for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
Expand Down Expand Up @@ -523,6 +524,9 @@ func TestGetNextPipes(t *testing.T) {
},
}

oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)

for _, test := range tests {
getStyle := func(c *models.Commit) style.TextStyle { return style.FgDefault }
pipes := getNextPipes(test.prevPipes, test.commit, getStyle)
Expand All @@ -538,6 +542,9 @@ func TestGetNextPipes(t *testing.T) {
}

func BenchmarkRenderCommitGraph(b *testing.B) {
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)

commits := generateCommits(50)
getStyle := func(commit *models.Commit) style.TextStyle {
return authors.AuthorStyle(commit.AuthorName)
Expand Down
11 changes: 6 additions & 5 deletions pkg/gui/style/style_test.go
Expand Up @@ -10,11 +10,6 @@ import (
"github.com/xo/terminfo"
)

func init() {
// on CI we've got no color capability so we're forcing it here
color.ForceSetColorLevel(terminfo.ColorLevelMillions)
}

func TestMerge(t *testing.T) {
type scenario struct {
name string
Expand Down Expand Up @@ -162,6 +157,9 @@ func TestMerge(t *testing.T) {
},
}

oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)

for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
Expand Down Expand Up @@ -210,6 +208,9 @@ func TestTemplateFuncMapAddColors(t *testing.T) {
},
}

oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)

for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
Expand Down
4 changes: 4 additions & 0 deletions pkg/gui/types/common.go
Expand Up @@ -35,6 +35,10 @@ 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: 26 additions & 0 deletions pkg/gui/view_helpers.go
@@ -1,6 +1,7 @@
package gui

import (
"regexp"
"time"

"github.com/jesseduffield/gocui"
Expand Down Expand Up @@ -148,3 +149,28 @@ 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
}

0 comments on commit 2385c1d

Please sign in to comment.