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:
commit
2385c1d111
@ -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 {
|
||||
|
63
pkg/gui/controllers/helpers/confirmation_helper_test.go
Normal file
63
pkg/gui/controllers/helpers/confirmation_helper_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user