diff --git a/go.mod b/go.mod index 6836caeb9c9..95587f286ba 100644 --- a/go.mod +++ b/go.mod @@ -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.20240906064314-bfab49c720d7 + github.com/jesseduffield/gocui v0.3.1-0.20240928100326-393cf89a5d3f 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 diff --git a/go.sum b/go.sum index 77fc93c8b88..12ce5def99d 100644 --- a/go.sum +++ b/go.sum @@ -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.20240906064314-bfab49c720d7 h1:QeLCKRAt4T6sBg5tSrOc4OojCuAcPxUA+4vNMPY4aH4= -github.com/jesseduffield/gocui v0.3.1-0.20240906064314-bfab49c720d7/go.mod h1:XtEbqCbn45keRXEu+OMZkjN5gw6AEob59afsgHjokZ8= +github.com/jesseduffield/gocui v0.3.1-0.20240928100326-393cf89a5d3f h1:ZzsAUDwPFLPITKLcJpMSqt/3rERdI8YRZKr2l0plrls= +github.com/jesseduffield/gocui v0.3.1-0.20240928100326-393cf89a5d3f/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= diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index 3ffdae6a179..89a150fca5e 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -221,7 +221,7 @@ func (self *ConfirmationHelper) CreatePopupPanel(ctx goContext.Context, opts typ confirmationView.RenderTextArea() } else { self.c.ResetViewOrigin(confirmationView) - self.c.SetViewContent(confirmationView, style.AttrBold.Sprint(underlineLinks(opts.Prompt))) + self.c.SetViewContent(confirmationView, style.AttrBold.Sprint(opts.Prompt)) } self.setKeyBindings(cancel, opts) @@ -233,28 +233,6 @@ func (self *ConfirmationHelper) CreatePopupPanel(ctx goContext.Context, opts typ self.c.Context().Push(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.PrintSimpleHyperlink(remaining[linkStart:linkEnd]) - result += remaining[:linkStart] + underlinedLink - remaining = remaining[linkEnd:] - } - return result + remaining -} - func (self *ConfirmationHelper) setKeyBindings(cancel goContext.CancelFunc, opts types.CreatePopupPanelOpts) { var onConfirm func() error if opts.HandleConfirmPrompt != nil { diff --git a/pkg/gui/controllers/helpers/confirmation_helper_test.go b/pkg/gui/controllers/helpers/confirmation_helper_test.go deleted file mode 100644 index de3db935899..00000000000 --- a/pkg/gui/controllers/helpers/confirmation_helper_test.go +++ /dev/null @@ -1,63 +0,0 @@ -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]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\", - }, - { - name: "link preceded and followed by text", - text: "bla https://example.com 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]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 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]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\\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) - }) - } -} diff --git a/pkg/gui/controllers/status_controller.go b/pkg/gui/controllers/status_controller.go index c15e85f8c96..68028302f57 100644 --- a/pkg/gui/controllers/status_controller.go +++ b/pkg/gui/controllers/status_controller.go @@ -208,12 +208,12 @@ func (self *StatusController) showDashboard() { []string{ lazygitTitle(), fmt.Sprintf("Copyright %d Jesse Duffield", time.Now().Year()), - 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 + fmt.Sprintf("Keybindings: %s", fmt.Sprintf(constants.Links.Docs.Keybindings, versionStr)), + fmt.Sprintf("Config Options: %s", fmt.Sprintf(constants.Links.Docs.Config, versionStr)), + fmt.Sprintf("Tutorial: %s", constants.Links.Docs.Tutorial), + fmt.Sprintf("Raise an Issue: %s", constants.Links.Issues), + fmt.Sprintf("Release Notes: %s", constants.Links.Releases), + style.FgMagenta.Sprintf("Become a sponsor: %s", constants.Links.Donate), // caffeine ain't free }, "\n\n") + "\n" self.c.RenderToMainViews(types.RefreshMainOpts{ diff --git a/pkg/gui/views.go b/pkg/gui/views.go index f561eac3753..526696b25b0 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -118,6 +118,7 @@ func (gui *Gui) createAllViews() error { view.Wrap = true view.IgnoreCarriageReturns = true view.UnderlineHyperLinksOnlyOnHover = true + view.AutoRenderHyperLinks = true } gui.Views.Staging.Title = gui.c.Tr.UnstagedChanges @@ -157,6 +158,7 @@ func (gui *Gui) createAllViews() error { gui.Views.Confirmation.Visible = false gui.Views.Confirmation.Editor = gocui.EditorFunc(gui.promptEditor) + gui.Views.Confirmation.AutoRenderHyperLinks = true gui.Views.Suggestions.Visible = false @@ -171,6 +173,7 @@ func (gui *Gui) createAllViews() error { gui.Views.Extras.Title = gui.c.Tr.CommandLog gui.Views.Extras.Autoscroll = true gui.Views.Extras.Wrap = true + gui.Views.Extras.AutoRenderHyperLinks = true gui.Views.Snake.Title = gui.c.Tr.SnakeTitle gui.Views.Snake.FgColor = gocui.ColorGreen diff --git a/vendor/github.com/jesseduffield/gocui/view.go b/vendor/github.com/jesseduffield/gocui/view.go index b84405e4a62..c3e183a87bf 100644 --- a/vendor/github.com/jesseduffield/gocui/view.go +++ b/vendor/github.com/jesseduffield/gocui/view.go @@ -183,6 +183,10 @@ type View struct { // if true, the user can scroll all the way past the last item until it appears at the top of the view CanScrollPastBottom bool + // if true, the view will automatically recognize https: URLs in the content written to it and render + // them as hyperlinks + AutoRenderHyperLinks bool + // if true, the view will underline hyperlinks only when the cursor is on // them; otherwise, they will always be underlined UnderlineHyperLinksOnlyOnHover bool @@ -780,6 +784,7 @@ func (v *View) writeRunes(p []rune) { for _, r := range p { switch r { case '\n': + v.autoRenderHyperlinksInCurrentLine() if c, ok := v.readCell(v.wx+1, v.wy); !ok || c.chr == 0 { v.writeCells(v.wx, v.wy, []cell{{ chr: 0, @@ -793,6 +798,7 @@ func (v *View) writeRunes(p []rune) { v.lines = append(v.lines, nil) } case '\r': + v.autoRenderHyperlinksInCurrentLine() if c, ok := v.readCell(v.wx, v.wy); !ok || c.chr == 0 { v.writeCells(v.wx, v.wy, []cell{{ chr: 0, @@ -829,6 +835,61 @@ func (v *View) writeString(s string) { v.writeRunes([]rune(s)) } +func findSubstring(line []cell, substringToFind []rune) int { + for i := 0; i < len(line)-len(substringToFind); i++ { + for j := 0; j < len(substringToFind); j++ { + if line[i+j].chr != substringToFind[j] { + break + } + if j == len(substringToFind)-1 { + return i + } + } + } + return -1 +} + +func (v *View) autoRenderHyperlinksInCurrentLine() { + if !v.AutoRenderHyperLinks { + return + } + + // We need a heuristic to find the end of a hyperlink. Searching for the + // first character that is not a valid URI character is not quite good + // enough, because in markdown it's common to have a hyperlink followed by a + // ')', so we want to stop there. Hopefully URLs containing ')' are uncommon + // enough that this is not a problem. + lineEndCharacters := map[rune]bool{ + '\000': true, + ' ': true, + '\n': true, + '>': true, + '"': true, + ')': true, + } + line := v.lines[v.wy] + start := 0 + for { + linkStart := findSubstring(line[start:], []rune("https://")) + if linkStart == -1 { + break + } + linkStart += start + link := "" + linkEnd := linkStart + for ; linkEnd < len(line); linkEnd++ { + if _, ok := lineEndCharacters[line[linkEnd].chr]; ok { + break + } + link += string(line[linkEnd].chr) + } + for i := linkStart; i < linkEnd; i++ { + v.lines[v.wy][i].hyperlink = link + } + start = linkEnd + } +} + // parseInput parses char by char the input written to the View. It returns nil // while processing ESC sequences. Otherwise, it returns a cell slice that // contains the processed data. diff --git a/vendor/modules.txt b/vendor/modules.txt index 0405dfd402f..720f145a6bd 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -172,7 +172,7 @@ github.com/jesseduffield/go-git/v5/utils/merkletrie/filesystem github.com/jesseduffield/go-git/v5/utils/merkletrie/index github.com/jesseduffield/go-git/v5/utils/merkletrie/internal/frame github.com/jesseduffield/go-git/v5/utils/merkletrie/noder -# github.com/jesseduffield/gocui v0.3.1-0.20240906064314-bfab49c720d7 +# github.com/jesseduffield/gocui v0.3.1-0.20240928100326-393cf89a5d3f ## explicit; go 1.12 github.com/jesseduffield/gocui # github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10