1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2024-11-24 08:52:21 +02:00

Make URLs in confirmation panels clickable, and underline them (#3446)

- **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.
This commit is contained in:
Stefan Haller 2024-03-29 10:58:55 +01:00 committed by GitHub
commit 2385c1d111
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 171 additions and 31 deletions

View File

@ -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 {
@ -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 {

View File

@ -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)
})
}
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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,

View File

@ -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) {
@ -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 {

View File

@ -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", ""))
}
@ -385,6 +381,9 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
},
}
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone)
defer color.ForceSetColorLevel(oldColorLevel)
os.Setenv("TZ", "UTC")
focusing := false

View File

@ -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")
}
@ -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) {
@ -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) {

View File

@ -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
@ -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) {
@ -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) {
@ -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)
@ -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)

View File

@ -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
@ -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) {
@ -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) {

View File

@ -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

View File

@ -1,6 +1,7 @@
package gui
import (
"regexp"
"time"
"github.com/jesseduffield/gocui"
@ -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
}