Improve pasting commit messages (#6210)

When pasting a commit message into the commit message editor (either via
the `ctrl+o` menu or with the terminal's Command-V), we now remove the
hard line breaks so that the text reflows again when you edit it, like
we already do when rewording a message.

When pasting with Command-V, we now avoid the extra blank line at the
beginning of the description; this wasn't really a problem because it
got stripped when committing, but it looked ugly.

Along the way, fix a few minor issues when pasting text into various
views, e.g. the search prompt or a "New branch name" text field.
This commit is contained in:
Stefan Haller
2026-11-12 10:45:04 +01:00
committed by GitHub
33 changed files with 693 additions and 104 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ func TestBlockingEvents_PastedKeysReplayedAsPasted(t *testing.T) {
g.EndBlockingEvents()
runQueuedWork(t, g)
assert.Empty(t, *fired)
assert.False(t, g.IsPasting)
assert.False(t, g.isPasting)
}
func TestBlockingEvents_NestsWithCounter(t *testing.T) {
+54 -7
View File
@@ -119,6 +119,7 @@ type replayedEvents struct {
Resizes chan *TcellResizeEventWrapper
MouseEvents chan *TcellMouseEventWrapper
FocusEvents chan *TcellFocusEventWrapper
PasteEvents chan *TcellPasteEventWrapper
}
type RecordingConfig struct {
@@ -187,7 +188,13 @@ type Gui struct {
// If Mouse is true then mouse events will be enabled.
Mouse bool
IsPasting bool
// Whether a paste is in progress, between the terminal's events for its
// start and end
isPasting bool
// The text pasted so far into pasteView, a view with an OnPaste handler
pasteBuffer strings.Builder
pasteView *View
// If InputEsc is true, when ESC sequence is in the buffer and it doesn't
// match any known sequence, ESC means KeyEsc.
@@ -305,6 +312,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
Resizes: make(chan *TcellResizeEventWrapper),
MouseEvents: make(chan *TcellMouseEventWrapper),
FocusEvents: make(chan *TcellFocusEventWrapper),
PasteEvents: make(chan *TcellPasteEventWrapper),
}
}
@@ -384,6 +392,13 @@ func (g *Gui) ReplayFocusEvent(ev *TcellFocusEventWrapper) {
g.replayedEvents.FocusEvents <- ev
}
// ReplayPasteEvent is like ReplayKeyEvent, but for the events that mark the
// start and the end of a paste.
func (g *Gui) ReplayPasteEvent(ev *TcellPasteEventWrapper) {
ev.task = g.NewTask()
g.replayedEvents.PasteEvents <- ev
}
// Busy reports whether any foreground work is in flight, ignoring the event
// currently being processed on the main goroutine (see currentTask). Background
// routines (auto-fetch etc.) don't count. It's used to decide whether it's safe
@@ -1275,7 +1290,10 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error {
case eventFocus:
return g.onFocus(ev)
case eventPaste:
g.IsPasting = ev.Start
g.isPasting = ev.Start
if !ev.Start {
return g.deliverPaste()
}
return nil
default:
return nil
@@ -1749,6 +1767,32 @@ func (g *Gui) draw(v *View) {
}
}
func (g *Gui) collectPastedKey(v *View, key Key) {
if g.pasteView == nil {
g.pasteView = v
}
switch {
case key.IsPrintable():
g.pasteBuffer.WriteString(key.Str())
case key.Equals(NewKeyName(KeyEnter)):
g.pasteBuffer.WriteString("\n")
case key.Equals(NewKeyName(KeyTab)):
g.pasteBuffer.WriteString("\t")
}
}
func (g *Gui) deliverPaste() error {
v, text := g.pasteView, g.pasteBuffer.String()
g.pasteView = nil
g.pasteBuffer.Reset()
if v == nil || text == "" {
return nil
}
return v.OnPaste(text)
}
// onKey manages key-press events. A keybinding handler is called when
// a key-press or mouse event satisfies a configured keybinding. Furthermore,
// currentView's internal buffer is modified if currentView.Editable is true.
@@ -1763,10 +1807,17 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
// in bracketed paste mode. Whatever I'm missing here, converting '\r'
// back to '\n' fixes pasting multi-line text from Ghostty, and doesn't
// seem harmful for other terminal emulators.
if g.IsPasting && ev.Key.Equals(NewKeyStrMod("j", ModCtrl)) {
if g.isPasting && ev.Key.Equals(NewKeyStrMod("j", ModCtrl)) {
ev.Key = NewKeyName(KeyEnter)
}
if g.isPasting {
if g.currentView != nil && g.currentView.OnPaste != nil {
g.collectPastedKey(g.currentView, ev.Key)
}
return nil
}
err := g.execKeybindings(g.currentView, ev)
if err != nil {
return err
@@ -2040,10 +2091,6 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
var globalKb *keybinding
var matchingParentViewKb *keybinding
if g.IsPasting && v != nil && !v.Editable {
return nil
}
// if we're searching, and we've hit n/N/Esc, we ignore the default keybinding
if v != nil && v.IsSearching() {
if lo.SomeBy(g.NextSearchMatchKeys, func(k Key) bool { return ev.Key.Equals(k) }) {
+77
View File
@@ -0,0 +1,77 @@
package gocui
import (
"testing"
"github.com/gdamore/tcell/v3"
"github.com/stretchr/testify/assert"
)
func TestPasteIntoViewWithPasteHandler(t *testing.T) {
g := newTestGui(t)
v, _ := g.SetView("editor", 0, 0, 40, 10, 0)
v.Editable = true
_, err := g.SetCurrentView("editor")
assert.NoError(t, err)
pasted := []string{}
v.OnPaste = func(text string) error {
pasted = append(pasted, text)
return nil
}
escPressed := false
g.SetKeybinding("editor", NewKeyName(KeyEsc), func(*Gui, *View) error {
escPressed = true
return nil
})
for _, tev := range []tcell.Event{
tcell.NewEventPaste(true),
tcell.NewEventKey(tcell.KeyRune, "a", tcell.ModNone),
tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone),
// Ghostty pastes newlines as ctrl-j
tcell.NewEventKey(tcell.KeyRune, "j", tcell.ModCtrl),
tcell.NewEventKey(tcell.KeyTab, "", tcell.ModNone),
tcell.NewEventKey(tcell.KeyEsc, "", tcell.ModNone),
tcell.NewEventKey(tcell.KeyRune, "a", tcell.ModCtrl),
tcell.NewEventKey(tcell.KeyRune, "b", tcell.ModNone),
tcell.NewEventPaste(false),
} {
ev := gocuiEventFromTcellEvent(tev)
assert.NoError(t, g.handleEvent(&ev))
}
assert.Equal(t, []string{"a\n\n\tb"}, pasted)
assert.False(t, escPressed)
assert.Equal(t, "", v.TextArea.GetContent())
assert.False(t, g.isPasting)
}
func TestPasteIntoViewWithoutPasteHandler(t *testing.T) {
g := newTestGui(t)
v, _ := g.SetView("editor", 0, 0, 40, 10, 0)
v.Editable = true
v.Editor = DefaultEditor
_, err := g.SetCurrentView("editor")
assert.NoError(t, err)
enterPressed := false
g.SetKeybinding("editor", NewKeyName(KeyEnter), func(*Gui, *View) error {
enterPressed = true
return nil
})
for _, tev := range []tcell.Event{
tcell.NewEventPaste(true),
tcell.NewEventKey(tcell.KeyRune, "a", tcell.ModNone),
tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone),
tcell.NewEventKey(tcell.KeyRune, "b", tcell.ModNone),
tcell.NewEventPaste(false),
} {
ev := gocuiEventFromTcellEvent(tev)
assert.NoError(t, g.handleEvent(&ev))
}
assert.Equal(t, "", v.TextArea.GetContent())
assert.False(t, enterPressed)
}
+19
View File
@@ -302,6 +302,22 @@ func (wrapper TcellFocusEventWrapper) toTcellEvent() tcell.Event {
return tcell.NewEventFocus(wrapper.Focused)
}
type TcellPasteEventWrapper struct {
Start bool
task Task // see GocuiEvent.task
}
func NewTcellPasteEventWrapper(event *tcell.EventPaste) *TcellPasteEventWrapper {
return &TcellPasteEventWrapper{
Start: event.Start(),
}
}
func (wrapper TcellPasteEventWrapper) toTcellEvent() tcell.Event {
return tcell.NewEventPaste(wrapper.Start)
}
// pollEvent get tcell.Event and transform it into gocuiEvent
func (g *Gui) pollEvent() GocuiEvent {
var tev tcell.Event
@@ -319,6 +335,9 @@ func (g *Gui) pollEvent() GocuiEvent {
case ev := <-g.replayedEvents.FocusEvents:
tev = ev.toTcellEvent()
task = ev.task
case ev := <-g.replayedEvents.PasteEvents:
tev = ev.toTcellEvent()
task = ev.task
}
} else {
tev = <-Screen.EventQ()
+7
View File
@@ -163,6 +163,13 @@ type View struct {
// default.
Editor Editor
// OnPaste, if set, receives pasted text in one piece when the paste ends.
// The text consists of the pasted printable characters, newlines and tabs;
// other keys in the paste, such as Esc or control characters, are dropped.
// Pasting into a view without an OnPaste handler does nothing, so that
// pasted text never runs keybindings.
OnPaste func(text string) error
// Overwrite enables or disables the overwrite mode of the view.
Overwrite bool
@@ -4,7 +4,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
type CommitDescriptionController struct {
@@ -27,7 +26,7 @@ func (self *CommitDescriptionController) GetKeybindings(opts types.KeybindingsOp
bindings := []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.TogglePanel),
Handler: self.handleTogglePanel,
Handler: self.switchToCommitMessage,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Return),
@@ -86,32 +85,6 @@ func (self *CommitDescriptionController) switchToCommitMessage() error {
return nil
}
func (self *CommitDescriptionController) handleTogglePanel() error {
// The default keybinding for this action is "<tab>", which means that we
// also get here when pasting multi-line text that contains tabs. In that
// case we don't want to toggle the panel, but insert the tab as a character
// (somehow, see below).
//
// Only do this if the TogglePanel command is actually mapped to "<tab>"
// (the default). If it's not, we can only hope that it's mapped to some
// ctrl key or fn key, which is unlikely to occur in pasted text. And if
// they mapped some *other* command to "<tab>", then we're totally out of
// luck.
if self.c.GocuiGui().IsPasting && lo.Contains(self.c.UserConfig().Keybinding.Universal.TogglePanel, "<tab>") {
// Handling tabs in pasted commit messages is not optimal, but hopefully
// good enough for now. We simply insert 4 spaces without worrying about
// column alignment. This works well enough for leading indentation,
// which is common in pasted code snippets.
view := self.Context().GetView()
for range 4 {
view.Editor.Edit(view, gocui.NewKeyRune(' '))
}
return nil
}
return self.switchToCommitMessage()
}
func (self *CommitDescriptionController) close() error {
self.c.Helpers().Commits.CloseCommitMessagePanel()
return nil
@@ -6,9 +6,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type CommitMessageController struct {
@@ -49,7 +47,7 @@ func (self *CommitMessageController) GetKeybindings(opts types.KeybindingsOpts)
},
{
Keys: opts.GetKeys(opts.Config.Universal.TogglePanel),
Handler: self.handleTogglePanel,
Handler: self.switchToCommitDescription,
},
{
Keys: opts.GetKeys(opts.Config.CommitMessage.CommitMenu),
@@ -113,32 +111,6 @@ func (self *CommitMessageController) switchToCommitDescription() error {
return nil
}
func (self *CommitMessageController) handleTogglePanel() error {
// The default keybinding for this action is "<tab>", which means that we
// also get here when pasting multi-line text that contains tabs. In that
// case we don't want to toggle the panel, but insert the tab as a character
// (somehow, see below).
//
// Only do this if the TogglePanel command is actually mapped to "<tab>"
// (the default). If it's not, we can only hope that it's mapped to some
// ctrl key or fn key, which is unlikely to occur in pasted text. And if
// they mapped some *other* command to "<tab>", then we're totally out of
// luck.
if self.c.GocuiGui().IsPasting && lo.Contains(self.c.UserConfig().Keybinding.Universal.TogglePanel, "<tab>") {
// It is unlikely that a pasted commit message contains a tab in the
// subject line, so it shouldn't matter too much how we handle it.
// Simply insert 4 spaces instead; all that matters is that we don't
// switch to the description panel.
view := self.context().GetView()
for range 4 {
view.Editor.Edit(view, gocui.NewKeyRune(' '))
}
return nil
}
return self.switchToCommitDescription()
}
func (self *CommitMessageController) handleCommitIndexChange(value int) error {
currentIndex := self.context().GetSelectedIndex()
newIndex := currentIndex + value
@@ -166,28 +138,12 @@ func (self *CommitMessageController) setCommitMessageAtIndex(index int) (bool, e
}
return false, errors.New(self.c.Tr.CommitWithoutMessageErr)
}
if self.c.UserConfig().Git.Commit.AutoWrapCommitMessage {
commitMessage = helpers.TryRemoveHardLineBreaks(commitMessage, self.c.UserConfig().Git.Commit.AutoWrapWidth)
}
commitMessage = self.c.Helpers().Commits.TryRemoveHardLineBreaksIfAutoWrapping(commitMessage)
self.c.Helpers().Commits.SetMessageAndDescriptionInView(commitMessage)
return true, nil
}
func (self *CommitMessageController) confirm() error {
// The default keybinding for this action is "<enter>", which means that we
// also get here when pasting multi-line text that contains newlines. In
// that case we don't want to confirm the commit, but switch to the
// description panel instead so that the rest of the pasted text goes there.
//
// Only do this if the SubmitEditorText command is actually mapped to
// "<enter>" (the default). If it's not, we can only hope that it's mapped
// to some ctrl key or fn key, which is unlikely to occur in pasted text.
// And if they mapped some *other* command to "<enter>", then we're totally
// out of luck.
if self.c.GocuiGui().IsPasting && lo.Contains(self.c.UserConfig().Keybinding.Universal.SubmitEditorText, "<enter>") {
return self.switchToCommitDescription()
}
return self.c.Helpers().Commits.HandleCommitConfirm()
}
+53 -2
View File
@@ -89,7 +89,18 @@ func (self *CommitsHelper) JoinCommitMessageAndUnwrappedDescription() string {
return self.getCommitSummary() + "\n" + self.getUnwrappedCommitDescription()
}
func TryRemoveHardLineBreaks(message string, autoWrapWidth int) string {
// TryRemoveHardLineBreaksIfAutoWrapping replaces the hard line breaks of a
// message with spaces wherever auto-wrapping would break the line at the same
// place, so that the paragraphs reflow when the user edits them in the panel.
func (self *CommitsHelper) TryRemoveHardLineBreaksIfAutoWrapping(message string) string {
commitConfig := self.c.UserConfig().Git.Commit
if !commitConfig.AutoWrapCommitMessage {
return message
}
return tryRemoveHardLineBreaks(message, commitConfig.AutoWrapWidth)
}
func tryRemoveHardLineBreaks(message string, autoWrapWidth int) string {
lastHardLineStart := 0
result := message
for i, b := range message {
@@ -309,6 +320,46 @@ func (self *CommitsHelper) copyCommitMessageToClipboard() error {
return nil
}
// HandlePasteIntoSummary inserts text that the user pasted into the summary
// with the terminal's paste command. If the text has several lines, the rest
// of them goes into the description.
func (self *CommitsHelper) HandlePasteIntoSummary(text string) {
summary, rest, hasMoreLines := strings.Cut(text, "\n")
view := self.c.Views().CommitMessage
view.TextArea.TypeString(replaceTabsInPastedText(summary))
view.RenderTextArea()
self.c.Contexts().CommitMessage.RenderSubtitle()
if hasMoreLines {
self.c.Context().Replace(self.c.Contexts().CommitDescription)
self.HandlePasteIntoDescription(rest)
}
}
// HandlePasteIntoDescription inserts text that the user pasted into the
// description with the terminal's paste command.
func (self *CommitsHelper) HandlePasteIntoDescription(text string) {
view := self.c.Views().CommitDescription
if view.TextArea.GetUnwrappedContent() == "" {
// This drops the blank line between subject and body when the user
// pastes a whole commit message into the summary
text = strings.TrimLeft(text, "\n")
}
// Remove the hard line breaks of the pasted text on its own, even if it
// goes into the middle of a line. Its line breaks come from the layout of
// the text that it was copied from, not from the line that it goes into.
text = self.TryRemoveHardLineBreaksIfAutoWrapping(replaceTabsInPastedText(text))
view.TextArea.TypeString(text)
view.RenderTextArea()
}
// Replace each tab with four spaces, without aligning to tab stops. This works
// well enough for leading indentation, which is common in pasted code snippets.
func replaceTabsInPastedText(text string) string {
return strings.ReplaceAll(text, "\t", " ")
}
func stripTrailingWhitespace(message string) string {
lines := strings.Split(message, "\n")
for i, line := range lines {
@@ -331,7 +382,7 @@ func (self *CommitsHelper) pasteCommitMessageFromClipboard() error {
Title: self.c.Tr.PasteCommitMessageFromClipboard,
Prompt: self.c.Tr.SurePasteCommitMessage,
HandleConfirm: func() error {
self.SetMessageAndDescriptionInView(message)
self.SetMessageAndDescriptionInView(self.TryRemoveHardLineBreaksIfAutoWrapping(message))
return nil
},
})
@@ -34,7 +34,7 @@ func TestTryRemoveHardLineBreaks(t *testing.T) {
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
actualResult := TryRemoveHardLineBreaks(s.message, s.autoWrapWidth)
actualResult := tryRemoveHardLineBreaks(s.message, s.autoWrapWidth)
assert.Equal(t, s.expectedResult, actualResult)
})
}
@@ -53,14 +53,6 @@ func (self *ConfirmationHelper) wrappedPromptConfirmationFunction(
preserveWhitespace bool,
) func() error {
return func() error {
if self.c.GocuiGui().IsPasting {
// The user is pasting multi-line text into a prompt; we don't want to handle the
// line feeds as "confirm" keybindings. Simply ignoring them is the best we can do; this
// will cause the entire pasted text to appear as a single line in the prompt. Hopefully
// the user knows that ctrl-u allows them to delete it again...
return nil
}
response := getResponse()
if !preserveWhitespace {
response = strings.TrimSpace(response)
@@ -884,9 +884,7 @@ func (self *LocalCommitsController) reword(commit *models.Commit) error {
if err != nil {
return err
}
if self.c.UserConfig().Git.Commit.AutoWrapCommitMessage {
commitMessage = helpers.TryRemoveHardLineBreaks(commitMessage, self.c.UserConfig().Git.Commit.AutoWrapWidth)
}
commitMessage = self.c.Helpers().Commits.TryRemoveHardLineBreaksIfAutoWrapping(commitMessage)
self.c.Helpers().Commits.OpenCommitMessagePanel(
&helpers.OpenCommitMessagePanelOpts{
CommitIndex: commitIdx,
@@ -1579,9 +1577,7 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc
if err != nil {
return err
}
if self.c.UserConfig().Git.Commit.AutoWrapCommitMessage {
commitMessage = helpers.TryRemoveHardLineBreaks(commitMessage, self.c.UserConfig().Git.Commit.AutoWrapWidth)
}
commitMessage = self.c.Helpers().Commits.TryRemoveHardLineBreaksIfAutoWrapping(commitMessage)
originalSubject, _, _ := strings.Cut(commitMessage, "\n")
self.c.Helpers().Commits.OpenCommitMessagePanel(
&helpers.OpenCommitMessagePanelOpts{
+54 -3
View File
@@ -1,6 +1,8 @@
package gui
import (
"strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
)
@@ -33,7 +35,19 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool {
matched := gui.handleEditorKeypress(v, key, false)
v.RenderTextArea()
gui.onPromptContentChanged(v)
return matched
}
func (gui *Gui) pasteIntoPrompt(text string) {
v := gui.Views.Prompt
if typePastedTextIntoSingleLineView(v, text) {
gui.onPromptContentChanged(v)
}
}
func (gui *Gui) onPromptContentChanged(v *gocui.View) {
suggestionsContext := gui.State.Contexts.Suggestions
// Capture the suggestions function and the input here, on the UI thread; the
// main thread rewrites State.FindSuggestions when it (re)creates a prompt
@@ -45,8 +59,6 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool {
return func() { suggestionsContext.SetSuggestions(suggestions) }
})
}
return matched
}
func (gui *Gui) menuFilterEditor(v *gocui.View, key gocui.Key) bool {
@@ -68,11 +80,50 @@ func (gui *Gui) menuFilterEditor(v *gocui.View, key gocui.Key) bool {
return true
}
gui.onMenuFilterContentChanged(content)
return true
}
func (gui *Gui) pasteIntoMenuFilter(text string) {
v := gui.Views.MenuFilter
if typePastedTextIntoSingleLineView(v, text) {
gui.onMenuFilterContentChanged(v.TextArea.GetContent())
}
}
// typePastedTextIntoSingleLineView returns false if nothing is left of the text
// to type.
func typePastedTextIntoSingleLineView(v *gocui.View, text string) bool {
text = pastedTextAsSingleLine(text)
if text == "" {
return false
}
v.TextArea.TypeString(text)
v.RenderTextArea()
return true
}
// Views that take a single line of text get pasted text without the newlines at
// its start and end, which are often part of a copied line, and with spaces in
// place of the newlines and tabs inside it.
func pastedTextAsSingleLine(text string) string {
text = strings.Trim(text, "\n")
return strings.NewReplacer("\n", " ", "\t", " ").Replace(text)
}
func (gui *Gui) onMenuFilterContentChanged(content string) {
menuContext := gui.State.Contexts.Menu
menuContext.SetFilterStarted(true)
gui.helpers.Search.ApplyFilter(menuContext, content)
}
return true
func (gui *Gui) pasteIntoSearch(text string) {
v := gui.Views.Search
if typePastedTextIntoSingleLineView(v, text) {
gui.helpers.Search.OnPromptContentChanged(v.TextArea.GetContent())
}
}
func (gui *Gui) searchEditor(v *gocui.View, key gocui.Key) bool {
+27
View File
@@ -0,0 +1,27 @@
package gui
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPastedTextAsSingleLine(t *testing.T) {
scenarios := []struct {
name string
text string
expected string
}{
{"single line", "foo bar", "foo bar"},
{"newlines at start and end", "\n\nfoo\n", "foo"},
{"newlines inside", "foo\nbar\n\nbaz", "foo bar baz"},
{"tabs", "\tfoo\tbar", " foo bar"},
{"only newlines", "\n\n", ""},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, pastedTextAsSingleLine(s.text))
})
}
}
+25
View File
@@ -138,6 +138,31 @@ func (self *GuiDriver) FocusIn() {
self.waitTillIdle()
}
// Paste sends the events that a terminal with bracketed paste mode sends: the
// start of the paste, a key press for each character, and the end of the paste.
func (self *GuiDriver) Paste(text string) {
self.CheckAllToastsAcknowledged()
self.gui.g.ReplayPasteEvent(gocui.NewTcellPasteEventWrapper(tcell.NewEventPaste(true)))
for _, char := range text {
self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper(keyEventForPastedCharacter(char)))
}
self.gui.g.ReplayPasteEvent(gocui.NewTcellPasteEventWrapper(tcell.NewEventPaste(false)))
self.waitTillIdle()
}
func keyEventForPastedCharacter(char rune) *tcell.EventKey {
switch char {
case '\n':
return tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone)
case '\t':
return tcell.NewEventKey(tcell.KeyTab, "", tcell.ModNone)
default:
return tcell.NewEventKey(tcell.KeyRune, string(char), tcell.ModNone)
}
}
func (self *GuiDriver) FocusInAndClick(x, y int) {
self.CheckAllToastsAcknowledged()
+20
View File
@@ -101,6 +101,10 @@ func (gui *Gui) createAllViews() error {
gui.Views.Search.Editable = true
gui.Views.Search.Frame = false
gui.Views.Search.Editor = gocui.EditorFunc(gui.searchEditor)
gui.Views.Search.OnPaste = func(text string) error {
gui.pasteIntoSearch(text)
return nil
}
for _, view := range []*gocui.View{gui.Views.Main, gui.Views.Secondary, gui.Views.MergeConflicts} {
view.Wrap = true
@@ -119,10 +123,18 @@ func (gui *Gui) createAllViews() error {
gui.Views.CommitMessage.Visible = false
gui.Views.CommitMessage.Editable = true
gui.Views.CommitMessage.Editor = gocui.EditorFunc(gui.commitMessageEditor)
gui.Views.CommitMessage.OnPaste = func(text string) error {
gui.helpers.Commits.HandlePasteIntoSummary(text)
return nil
}
gui.Views.CommitDescription.Visible = false
gui.Views.CommitDescription.Editable = true
gui.Views.CommitDescription.Editor = gocui.EditorFunc(gui.commitDescriptionEditor)
gui.Views.CommitDescription.OnPaste = func(text string) error {
gui.helpers.Commits.HandlePasteIntoDescription(text)
return nil
}
gui.Views.Confirmation.Visible = false
gui.Views.Confirmation.Wrap = true
@@ -132,6 +144,10 @@ func (gui *Gui) createAllViews() error {
gui.Views.Prompt.Wrap = false // We don't want wrapping in one-line prompts
gui.Views.Prompt.Editable = true
gui.Views.Prompt.Editor = gocui.EditorFunc(gui.promptEditor)
gui.Views.Prompt.OnPaste = func(text string) error {
gui.pasteIntoPrompt(text)
return nil
}
gui.Views.Suggestions.Visible = false
@@ -142,6 +158,10 @@ func (gui *Gui) createAllViews() error {
gui.Views.MenuFilter.Frame = false
gui.Views.MenuFilter.Editable = true
gui.Views.MenuFilter.Editor = gocui.EditorFunc(gui.menuFilterEditor)
gui.Views.MenuFilter.OnPaste = func(text string) error {
gui.pasteIntoMenuFilter(text)
return nil
}
// The filter row belongs to the menu: it shares the menu's focus, and keys
// that the input field doesn't take are the menu's to handle.
gui.Views.MenuFilterFrame.ParentView = gui.Views.Menu
@@ -21,6 +21,14 @@ func (self *CommitDescriptionPanelDriver) Type(value string) *CommitDescriptionP
return self
}
// Paste the text the way a terminal does it, as opposed to pasting it with the
// commit menu
func (self *CommitDescriptionPanelDriver) Paste(text string) *CommitDescriptionPanelDriver {
self.t.paste(text)
return self
}
func (self *CommitDescriptionPanelDriver) SwitchToSummary() *CommitMessagePanelDriver {
self.getViewDriver().PressTab()
return &CommitMessagePanelDriver{t: self.t}
@@ -33,6 +33,14 @@ func (self *CommitMessagePanelDriver) Type(value string) *CommitMessagePanelDriv
return self
}
// Paste the text the way a terminal does it, as opposed to pasting it with the
// commit menu
func (self *CommitMessagePanelDriver) Paste(text string) *CommitMessagePanelDriver {
self.t.paste(text)
return self
}
func (self *CommitMessagePanelDriver) SwitchToDescription() *CommitDescriptionPanelDriver {
self.getViewDriver().PressTab()
return &CommitDescriptionPanelDriver{t: self.t}
@@ -66,6 +66,15 @@ func (self *MenuDriver) Filter(text string) *MenuDriver {
return self
}
// pastes the text the way a terminal does it. Only for menus that filter as you
// type.
func (self *MenuDriver) Paste(text string) *MenuDriver {
self.getViewDriver().IsFocused()
self.t.paste(text)
return self
}
func (self *MenuDriver) LineCount(matcher *IntMatcher) *MenuDriver {
self.getViewDriver().LineCount(matcher)
+2 -2
View File
@@ -67,10 +67,10 @@ func (self *Popup) CommitMessagePanel() *CommitMessagePanelDriver {
return &CommitMessagePanelDriver{t: self.t}
}
func (self *Popup) CommitDescriptionPanel() *CommitMessagePanelDriver {
func (self *Popup) CommitDescriptionPanel() *CommitDescriptionPanelDriver {
self.inCommitDescriptionPanel()
return &CommitMessagePanelDriver{t: self.t}
return &CommitDescriptionPanelDriver{t: self.t}
}
func (self *Popup) inCommitMessagePanel() {
@@ -31,6 +31,13 @@ func (self *PromptDriver) Type(value string) *PromptDriver {
return self
}
// pastes the text the way a terminal does it
func (self *PromptDriver) Paste(text string) *PromptDriver {
self.t.paste(text)
return self
}
func (self *PromptDriver) Clear() *PromptDriver {
self.t.press(ClearKey)
@@ -24,6 +24,13 @@ func (self *SearchDriver) Type(value string) *SearchDriver {
return self
}
// pastes the text the way a terminal does it
func (self *SearchDriver) Paste(text string) *SearchDriver {
self.t.paste(text)
return self
}
func (self *SearchDriver) Clear() *SearchDriver {
self.t.press(ClearKey)
@@ -136,6 +136,12 @@ func (self *TestDriver) FocusIn() {
self.Wait(self.inputDelay)
}
func (self *TestDriver) paste(text string) {
self.SetCaption(fmt.Sprintf("Pasting %q", text))
self.gui.Paste(text)
self.Wait(self.inputDelay)
}
func (self *TestDriver) focusInAndClick(x, y int) {
self.SetCaption(fmt.Sprintf("Focusing window and clicking %d, %d", x, y))
self.gui.FocusInAndClick(x, y)
+3
View File
@@ -84,6 +84,9 @@ func (self *fakeGuiDriver) OnUIThreadAndWait(f func()) {
func (self *fakeGuiDriver) FocusIn() {
}
func (self *fakeGuiDriver) Paste(text string) {
}
func (self *fakeGuiDriver) FocusInAndClick(x, y int) {
self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y})
}
@@ -0,0 +1,52 @@
package commit
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var PasteCommitMessageWithHardLineBreaks = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Paste a commit message whose body was wrapped at the auto-wrap width, and check that the body reflows when editing it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > ../clipboard"
config.GetUserConfig().OS.ReadFromClipboardCmd = "cat ../clipboard"
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("subject\n\n" +
"The body of this commit was wrapped at the auto-wrap width when it was\n" +
"committed, so it contains a hard line break.")
shell.CreateFileAndAdd("file", "file content")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Press(keys.Commits.CopyCommitAttributeToClipboard)
t.ExpectPopup().Menu().Title(Equals("Copy to clipboard")).
Select(Contains("Commit message (subject and body)")).Confirm()
t.ExpectToast(Equals("Commit message copied to clipboard"))
t.Views().Files().
Focus().
Press(keys.Files.CommitChanges)
t.ExpectPopup().CommitMessagePanel().
OpenCommitMenu()
t.ExpectPopup().Menu().Title(Equals("Commit Menu")).
Select(Contains("Paste commit message from clipboard")).
Confirm()
t.ExpectPopup().CommitMessagePanel().
Content(Equals("subject")).
SwitchToDescription().
GoToBeginning().
Type("Now ").
Content(Equals(
"Now The body of this commit was wrapped at the auto-wrap width when it \n" +
"was committed, so it contains a hard line break."))
},
})
@@ -0,0 +1,31 @@
package commit
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var PasteIntoCommitDescription = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Paste text into the commit description with the terminal's paste command",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file", "file content")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
Press(keys.Files.CommitChanges)
t.ExpectPopup().CommitMessagePanel().
Type("subject").
SwitchToDescription().
Type("last line").
GoToBeginning().
Paste("first line\n\tindented line\n").
Content(Equals("first line\n indented line\nlast line"))
t.ExpectPopup().CommitDescriptionPanel()
},
})
@@ -0,0 +1,30 @@
package commit
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var PasteMultiLineCommitMessage = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Paste a multi-line commit message into the summary with the terminal's paste command",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file", "file content")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
Press(keys.Files.CommitChanges)
t.ExpectPopup().CommitMessagePanel().
Paste("subject\n\nbody line 1\nbody line 2").
Content(Equals("subject"))
t.Views().CommitDescription().
IsFocused().
Content(Equals(
"body line 1\nbody line 2"))
},
})
@@ -0,0 +1,34 @@
package commit
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var PasteMultiLineCommitMessageWithHardLineBreaks = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Paste a commit message whose body was wrapped at the auto-wrap width into the summary with the terminal's paste command, and check that the body reflows when editing it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file", "file content")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
Press(keys.Files.CommitChanges)
t.ExpectPopup().CommitMessagePanel().
Paste("subject\n\n" +
"The body of this commit was wrapped at the auto-wrap width when it was\n" +
"committed, so it contains a hard line break.").
Content(Equals("subject"))
t.ExpectPopup().CommitDescriptionPanel().
GoToBeginning().
Type("Now ").
Content(Equals(
"Now The body of this commit was wrapped at the auto-wrap width when it \n" +
"was committed, so it contains a hard line break."))
},
})
@@ -0,0 +1,33 @@
package filter_and_search
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var PasteIntoKeybindingsMenu = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Pasting text that ends with a newline into the keybindings menu filters the menu without running the selected item",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFile("myfile", "myfile")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Contains(`??`).Contains(`myfile`).IsSelected(),
).
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Paste("stage all\n")
t.Views().Files().
Lines(
Contains(`??`).Contains(`myfile`),
)
},
})
@@ -0,0 +1,32 @@
package filter_and_search
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var PasteIntoMenuFilter = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Paste text into the filter of a menu that filters as you type",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFile("myfile", "myfile")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Paste("\nIgnore\n").
Lines(
Contains(`─── Local`),
Contains(`Ignore`).IsSelected(),
)
t.Views().MenuFilter().
Content(Equals("Ignore"))
},
})
@@ -0,0 +1,49 @@
package filter_and_search
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var PasteIntoSearchPrompt = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Paste multi-line text into the search prompt",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
shell.EmptyCommit("two")
shell.EmptyCommit("three")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("three").IsSelected(),
Contains("two"),
Contains("one"),
).
Press(keys.Universal.StartSearch)
t.ExpectSearch().
Paste("\ntwo\n")
t.Views().Search().
IsFocused().
Content(Equals("two"))
t.ExpectSearch().
Confirm()
t.Views().Search().
IsVisible().
Content(Contains("matches for 'two' (1 of 1)"))
t.Views().Commits().
Lines(
Contains("three"),
Contains("two").IsSelected(),
Contains("one"),
)
},
})
@@ -0,0 +1,29 @@
package misc
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var PasteIntoPrompt = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Paste multi-line text into a prompt",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("initial commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Press(keys.Universal.New)
t.ExpectPopup().Prompt().
Title(Contains("New branch name")).
Paste("\nmy\tnew\nbranch\n")
t.Views().Prompt().
IsFocused().
Content(Equals("my new branch"))
},
})
+8
View File
@@ -185,6 +185,10 @@ var tests = []*components.IntegrationTest{
commit.NewBranch,
commit.PasteCommitMessage,
commit.PasteCommitMessageOverExisting,
commit.PasteCommitMessageWithHardLineBreaks,
commit.PasteIntoCommitDescription,
commit.PasteMultiLineCommitMessage,
commit.PasteMultiLineCommitMessageWithHardLineBreaks,
commit.PreserveCommitMessage,
commit.PreserveCommitMessageWhitespace,
commit.ResetAuthor,
@@ -333,6 +337,9 @@ var tests = []*components.IntegrationTest{
filter_and_search.NestedFilter,
filter_and_search.NestedFilterTransient,
filter_and_search.NewSearch,
filter_and_search.PasteIntoKeybindingsMenu,
filter_and_search.PasteIntoMenuFilter,
filter_and_search.PasteIntoSearchPrompt,
filter_and_search.RerenderTheSearchedMainView,
filter_and_search.SearchALongDiff,
filter_and_search.SearchStatusAfterARerender,
@@ -574,6 +581,7 @@ var tests = []*components.IntegrationTest{
misc.DirenvUnloadsOnBlockedEnvrc,
misc.FilterRecentRepos,
misc.InitialOpen,
misc.PasteIntoPrompt,
misc.RecentReposBranchColumn,
misc.RecentReposColumnWidths,
misc.RecentReposOnLaunch,
+2
View File
@@ -47,6 +47,8 @@ type GuiDriver interface {
// Simulate the terminal window regaining focus (which triggers a reload of
// changed config files)
FocusIn()
// Simulate pasting text in a terminal that supports bracketed paste
Paste(string)
// Simulate a terminal dispatching focus-in immediately followed by a click,
// without waiting for the focus refresh to finish in between.
FocusInAndClick(int, int)