diff --git a/pkg/gocui/block_events_test.go b/pkg/gocui/block_events_test.go index 15af1452a..53b682d55 100644 --- a/pkg/gocui/block_events_test.go +++ b/pkg/gocui/block_events_test.go @@ -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) { diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 8b6179a2d..9af69f305 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -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) }) { diff --git a/pkg/gocui/paste_test.go b/pkg/gocui/paste_test.go new file mode 100644 index 000000000..7735cde0c --- /dev/null +++ b/pkg/gocui/paste_test.go @@ -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) +} diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index 61a3f0e89..4a3145749 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -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() diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index f16ed14ec..ed9fee232 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -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 diff --git a/pkg/gui/controllers/commit_description_controller.go b/pkg/gui/controllers/commit_description_controller.go index 41ec54103..b58285688 100644 --- a/pkg/gui/controllers/commit_description_controller.go +++ b/pkg/gui/controllers/commit_description_controller.go @@ -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 "", 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 "" - // (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 "", then we're totally out of - // luck. - if self.c.GocuiGui().IsPasting && lo.Contains(self.c.UserConfig().Keybinding.Universal.TogglePanel, "") { - // 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 diff --git a/pkg/gui/controllers/commit_message_controller.go b/pkg/gui/controllers/commit_message_controller.go index 098f42d30..d25a6bd06 100644 --- a/pkg/gui/controllers/commit_message_controller.go +++ b/pkg/gui/controllers/commit_message_controller.go @@ -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 "", 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 "" - // (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 "", then we're totally out of - // luck. - if self.c.GocuiGui().IsPasting && lo.Contains(self.c.UserConfig().Keybinding.Universal.TogglePanel, "") { - // 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 "", 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 - // "" (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 "", then we're totally - // out of luck. - if self.c.GocuiGui().IsPasting && lo.Contains(self.c.UserConfig().Keybinding.Universal.SubmitEditorText, "") { - return self.switchToCommitDescription() - } - return self.c.Helpers().Commits.HandleCommitConfirm() } diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index 3d58e6aec..3a273b532 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -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 }, }) diff --git a/pkg/gui/controllers/helpers/commits_helper_test.go b/pkg/gui/controllers/helpers/commits_helper_test.go index 6197c3916..fee75b4ef 100644 --- a/pkg/gui/controllers/helpers/commits_helper_test.go +++ b/pkg/gui/controllers/helpers/commits_helper_test.go @@ -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) }) } diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index 68d68575e..8551d9f4e 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -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) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 9b2ae40b4..a00589a76 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -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{ diff --git a/pkg/gui/editors.go b/pkg/gui/editors.go index 7c658265b..f99f72176 100644 --- a/pkg/gui/editors.go +++ b/pkg/gui/editors.go @@ -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 { diff --git a/pkg/gui/editors_test.go b/pkg/gui/editors_test.go new file mode 100644 index 000000000..ea26afb81 --- /dev/null +++ b/pkg/gui/editors_test.go @@ -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)) + }) + } +} diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index f66bcd020..a9d257072 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -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() diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 653c23c4b..340872769 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -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 diff --git a/pkg/integration/components/commit_description_panel_driver.go b/pkg/integration/components/commit_description_panel_driver.go index 6281e756b..261759886 100644 --- a/pkg/integration/components/commit_description_panel_driver.go +++ b/pkg/integration/components/commit_description_panel_driver.go @@ -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} diff --git a/pkg/integration/components/commit_message_panel_driver.go b/pkg/integration/components/commit_message_panel_driver.go index f24950f13..2d4e25405 100644 --- a/pkg/integration/components/commit_message_panel_driver.go +++ b/pkg/integration/components/commit_message_panel_driver.go @@ -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} diff --git a/pkg/integration/components/menu_driver.go b/pkg/integration/components/menu_driver.go index e0d6133e6..a71bada76 100644 --- a/pkg/integration/components/menu_driver.go +++ b/pkg/integration/components/menu_driver.go @@ -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) diff --git a/pkg/integration/components/popup.go b/pkg/integration/components/popup.go index 3cdc4f1f2..b82010c62 100644 --- a/pkg/integration/components/popup.go +++ b/pkg/integration/components/popup.go @@ -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() { diff --git a/pkg/integration/components/prompt_driver.go b/pkg/integration/components/prompt_driver.go index 1000af007..7f9aedaa4 100644 --- a/pkg/integration/components/prompt_driver.go +++ b/pkg/integration/components/prompt_driver.go @@ -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) diff --git a/pkg/integration/components/search_driver.go b/pkg/integration/components/search_driver.go index 96a706a65..77b4c878f 100644 --- a/pkg/integration/components/search_driver.go +++ b/pkg/integration/components/search_driver.go @@ -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) diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index e924a956e..375d37d61 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -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) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index 7a69c07ad..199f46af7 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -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}) } diff --git a/pkg/integration/tests/commit/paste_commit_message_with_hard_line_breaks.go b/pkg/integration/tests/commit/paste_commit_message_with_hard_line_breaks.go new file mode 100644 index 000000000..0c5bc5f41 --- /dev/null +++ b/pkg/integration/tests/commit/paste_commit_message_with_hard_line_breaks.go @@ -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.")) + }, +}) diff --git a/pkg/integration/tests/commit/paste_into_commit_description.go b/pkg/integration/tests/commit/paste_into_commit_description.go new file mode 100644 index 000000000..353ca7f2d --- /dev/null +++ b/pkg/integration/tests/commit/paste_into_commit_description.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/commit/paste_multi_line_commit_message.go b/pkg/integration/tests/commit/paste_multi_line_commit_message.go new file mode 100644 index 000000000..ac0d77218 --- /dev/null +++ b/pkg/integration/tests/commit/paste_multi_line_commit_message.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/commit/paste_multi_line_commit_message_with_hard_line_breaks.go b/pkg/integration/tests/commit/paste_multi_line_commit_message_with_hard_line_breaks.go new file mode 100644 index 000000000..99890ce27 --- /dev/null +++ b/pkg/integration/tests/commit/paste_multi_line_commit_message_with_hard_line_breaks.go @@ -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.")) + }, +}) diff --git a/pkg/integration/tests/filter_and_search/paste_into_keybindings_menu.go b/pkg/integration/tests/filter_and_search/paste_into_keybindings_menu.go new file mode 100644 index 000000000..92413c644 --- /dev/null +++ b/pkg/integration/tests/filter_and_search/paste_into_keybindings_menu.go @@ -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`), + ) + }, +}) diff --git a/pkg/integration/tests/filter_and_search/paste_into_menu_filter.go b/pkg/integration/tests/filter_and_search/paste_into_menu_filter.go new file mode 100644 index 000000000..e04c9f36f --- /dev/null +++ b/pkg/integration/tests/filter_and_search/paste_into_menu_filter.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/filter_and_search/paste_into_search_prompt.go b/pkg/integration/tests/filter_and_search/paste_into_search_prompt.go new file mode 100644 index 000000000..1560644c8 --- /dev/null +++ b/pkg/integration/tests/filter_and_search/paste_into_search_prompt.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/misc/paste_into_prompt.go b/pkg/integration/tests/misc/paste_into_prompt.go new file mode 100644 index 000000000..0d1b3076f --- /dev/null +++ b/pkg/integration/tests/misc/paste_into_prompt.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index c6ee9564f..79d40de3e 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -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, diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 1d24fd6ad..9a40192a1 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -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)