From 8b8e3c05aafe647b0f2cf5458100f7dfff7f7aa6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 10:21:54 +0100 Subject: [PATCH 01/19] Check the auto-wrap config inside the helper that removes hard line breaks All three callers of TryRemoveHardLineBreaks check the AutoWrapCommitMessage config first and pass the AutoWrapWidth config. We are about to add a fourth caller, so move both into a CommitsHelper method. This also makes it impossible to call it with the wrong width. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/controllers/commit_message_controller.go | 5 +---- pkg/gui/controllers/helpers/commits_helper.go | 13 ++++++++++++- pkg/gui/controllers/helpers/commits_helper_test.go | 2 +- pkg/gui/controllers/local_commits_controller.go | 8 ++------ 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/pkg/gui/controllers/commit_message_controller.go b/pkg/gui/controllers/commit_message_controller.go index 098f42d30..dde9b5647 100644 --- a/pkg/gui/controllers/commit_message_controller.go +++ b/pkg/gui/controllers/commit_message_controller.go @@ -6,7 +6,6 @@ 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" ) @@ -166,9 +165,7 @@ 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 } diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index 3d58e6aec..b995e002d 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 { 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/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{ From 1c7ccd58334bcace0bd97f3710182135d0a5ceeb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 10:22:47 +0100 Subject: [PATCH 02/19] Add a test for pasting a commit message that has hard line breaks If you copy a commit's message with `y` in the commits panel and paste it into the commit message panel with the commit menu, the body keeps the hard line breaks that git committed. This happens even where auto-wrapping would break the line at the same place. If you then edit a paragraph of the body, it doesn't reflow. Co-Authored-By: Claude Opus 5.5 --- ...te_commit_message_with_hard_line_breaks.go | 57 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 58 insertions(+) create mode 100644 pkg/integration/tests/commit/paste_commit_message_with_hard_line_breaks.go 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..2fa53d3f4 --- /dev/null +++ b/pkg/integration/tests/commit/paste_commit_message_with_hard_line_breaks.go @@ -0,0 +1,57 @@ +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( + /* EXPECTED: + "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.")) + ACTUAL: */ + "Now The body of this commit was wrapped at the auto-wrap width when it \n" + + "was\n" + + "committed, so it contains a hard line break.")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index c6ee9564f..26edb3c13 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -185,6 +185,7 @@ var tests = []*components.IntegrationTest{ commit.NewBranch, commit.PasteCommitMessage, commit.PasteCommitMessageOverExisting, + commit.PasteCommitMessageWithHardLineBreaks, commit.PreserveCommitMessage, commit.PreserveCommitMessageWhitespace, commit.ResetAuthor, From b65298c3b1b827698618649d1baa99663d9f36fe Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 10:23:17 +0100 Subject: [PATCH 03/19] Remove hard line breaks when pasting a commit message from the clipboard Do the same as when rewording a commit, so that the paragraphs of the body reflow when you edit them. This also matters for the "Copy commit message to clipboard" entry of the commit menu. It copies the message with the line breaks that auto-wrapping inserted. Without this change, pasting the copied message back into the panel would turn these into hard line breaks. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/controllers/helpers/commits_helper.go | 2 +- .../commit/paste_commit_message_with_hard_line_breaks.go | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index b995e002d..186a48337 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -342,7 +342,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/integration/tests/commit/paste_commit_message_with_hard_line_breaks.go b/pkg/integration/tests/commit/paste_commit_message_with_hard_line_breaks.go index 2fa53d3f4..0c5bc5f41 100644 --- 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 @@ -46,12 +46,7 @@ var PasteCommitMessageWithHardLineBreaks = NewIntegrationTest(NewIntegrationTest GoToBeginning(). Type("Now "). Content(Equals( - /* EXPECTED: "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.")) - ACTUAL: */ - "Now The body of this commit was wrapped at the auto-wrap width when it \n" + - "was\n" + - "committed, so it contains a hard line break.")) }, }) From dec9e49162aeeff292dd53f6dc09375d7623914a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 12 Nov 2026 08:55:05 +0100 Subject: [PATCH 04/19] Add support for pasting text in integration tests The commit message panel handles pasted text differently from typed text (see ba6cfc1f85b1), but integration tests couldn't simulate a paste, so this had no tests. Let tests replay the events that a terminal sends for a bracketed paste: the start of the paste, a key press for each character, and the end of the paste. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/gui.go | 9 +++++++ pkg/gocui/tcell_driver.go | 19 ++++++++++++++ pkg/gui/gui_driver.go | 25 +++++++++++++++++++ .../commit_description_panel_driver.go | 8 ++++++ .../components/commit_message_panel_driver.go | 8 ++++++ pkg/integration/components/test_driver.go | 6 +++++ pkg/integration/components/test_test.go | 3 +++ pkg/integration/types/types.go | 2 ++ 8 files changed, 80 insertions(+) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 8b6179a2d..a29eb3192 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 { @@ -305,6 +306,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 +386,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 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/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/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/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/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) From ad52544df868c585cd00c4b17a8c83b75e2a20a6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 12 Nov 2026 08:55:14 +0100 Subject: [PATCH 05/19] Add a test for pasting text into the commit description It checks that the text goes in at the cursor, and that a tab turns into spaces instead of switching to the summary. Co-Authored-By: Claude Opus 5.5 --- .../commit/paste_into_commit_description.go | 31 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 32 insertions(+) create mode 100644 pkg/integration/tests/commit/paste_into_commit_description.go 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/test_list.go b/pkg/integration/tests/test_list.go index 26edb3c13..4acb7f181 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -186,6 +186,7 @@ var tests = []*components.IntegrationTest{ commit.PasteCommitMessage, commit.PasteCommitMessageOverExisting, commit.PasteCommitMessageWithHardLineBreaks, + commit.PasteIntoCommitDescription, commit.PreserveCommitMessage, commit.PreserveCommitMessageWhitespace, commit.ResetAuthor, From 9d2058fbc4a8484a3358e68a10135d00c713f78f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 10:25:49 +0100 Subject: [PATCH 06/19] Add a test for pasting a multi-line commit message into the summary If you paste a commit message into the summary with the terminal's paste command, the description starts with an empty line. The first newline of the pasted text moves to the description, and the second one (the blank line between subject and body) is typed into the description. Git strips the empty line when committing, but it looks broken in the panel. Co-Authored-By: Claude Opus 5.5 --- .../commit/paste_multi_line_commit_message.go | 33 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 34 insertions(+) create mode 100644 pkg/integration/tests/commit/paste_multi_line_commit_message.go 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..cdee917a9 --- /dev/null +++ b/pkg/integration/tests/commit/paste_multi_line_commit_message.go @@ -0,0 +1,33 @@ +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( + /* EXPECTED: + "body line 1\nbody line 2")) + ACTUAL: */ + "\nbody line 1\nbody line 2")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 4acb7f181..4a22b66f2 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -187,6 +187,7 @@ var tests = []*components.IntegrationTest{ commit.PasteCommitMessageOverExisting, commit.PasteCommitMessageWithHardLineBreaks, commit.PasteIntoCommitDescription, + commit.PasteMultiLineCommitMessage, commit.PreserveCommitMessage, commit.PreserveCommitMessageWhitespace, commit.ResetAuthor, From 0111d9bd614159043995e4c6627d52ec83fa36f4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 11:22:06 +0100 Subject: [PATCH 07/19] Let views receive pasted text in one piece Editable views receive pasted text as one key press per character, so a newline or a tab in the text triggers the keybinding for Enter or Tab. The commit message panel works around this by checking IsPasting in these keybindings. This is fragile, and it gives the panel no way to look at the pasted text as a whole. Add an OnPaste handler to View. For a view that has one, collect the pasted characters, newlines and tabs while the paste lasts, and pass them to the handler when it ends. Drop all other keys of the paste. They are unlikely to occur in pasted text, and dispatching keys like Esc in the middle of a paste would run them as commands. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/gui.go | 38 +++++++++++++++++++++++ pkg/gocui/paste_test.go | 69 +++++++++++++++++++++++++++++++++++++++++ pkg/gocui/view.go | 6 ++++ 3 files changed, 113 insertions(+) create mode 100644 pkg/gocui/paste_test.go diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index a29eb3192..8c811c5d1 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -190,6 +190,10 @@ type Gui struct { 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. InputEsc bool @@ -1285,6 +1289,9 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error { return g.onFocus(ev) case eventPaste: g.IsPasting = ev.Start + if !ev.Start { + return g.deliverPaste() + } return nil default: return nil @@ -1758,6 +1765,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. @@ -1776,6 +1809,11 @@ func (g *Gui) onKey(ev *GocuiEvent) error { ev.Key = NewKeyName(KeyEnter) } + if g.IsPasting && 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 diff --git a/pkg/gocui/paste_test.go b/pkg/gocui/paste_test.go new file mode 100644 index 000000000..473ca574b --- /dev/null +++ b/pkg/gocui/paste_test.go @@ -0,0 +1,69 @@ +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) + + for _, tev := range []tcell.Event{ + tcell.NewEventPaste(true), + tcell.NewEventKey(tcell.KeyRune, "a", tcell.ModNone), + tcell.NewEventKey(tcell.KeyRune, "b", tcell.ModNone), + tcell.NewEventPaste(false), + } { + ev := gocuiEventFromTcellEvent(tev) + assert.NoError(t, g.handleEvent(&ev)) + } + + assert.Equal(t, "ab", v.TextArea.GetContent()) +} diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index f16ed14ec..ae4715a9f 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -163,6 +163,12 @@ type View struct { // default. Editor Editor + // OnPaste, if set, receives pasted text in one piece when the paste ends, + // instead of one key press per character. The text consists of the pasted + // printable characters, newlines and tabs; other keys in the paste, such as + // Esc or control characters, are dropped. + OnPaste func(text string) error + // Overwrite enables or disables the overwrite mode of the view. Overwrite bool From f2a63e2de09e5ed105e2cfd7ed13960fae40ae46 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 11:23:33 +0100 Subject: [PATCH 08/19] Handle text pasted into the commit message panel in one piece Use the new OnPaste handler for the summary and the description. This replaces the IsPasting checks in the keybindings for confirming and for switching between summary and description. These checks only worked for the default keys of those keybindings. If a user had bound Enter or Tab to something else, pasting a newline or a tab ran that command. Otherwise the behavior stays the same, except that the panel now drops Esc and control characters in pasted text. Before, it executed them; for example, a pasted Esc closed the panel. Co-Authored-By: Claude Opus 5.5 --- .../commit_description_controller.go | 29 +------------ .../controllers/commit_message_controller.go | 43 +------------------ pkg/gui/controllers/helpers/commits_helper.go | 31 +++++++++++++ pkg/gui/views.go | 8 ++++ 4 files changed, 41 insertions(+), 70 deletions(-) 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 dde9b5647..d25a6bd06 100644 --- a/pkg/gui/controllers/commit_message_controller.go +++ b/pkg/gui/controllers/commit_message_controller.go @@ -7,7 +7,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" - "github.com/samber/lo" ) type CommitMessageController struct { @@ -48,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), @@ -112,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 @@ -171,20 +144,6 @@ func (self *CommitMessageController) setCommitMessageAtIndex(index int) (bool, e } 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 186a48337..5f3b54ca6 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -320,6 +320,37 @@ 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 + view.TextArea.TypeString(replaceTabsInPastedText(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 { diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 653c23c4b..6bc414da6 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -119,10 +119,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 From 107f0c1352cba440c81f1b9478ad639aca6b9de9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 11:24:04 +0100 Subject: [PATCH 09/19] Don't start the description with an empty line when pasting a message If you paste text into an empty description, drop the newlines at its start. This happens when you paste a whole commit message into the summary with the terminal's paste command. The part after the first line goes into the description, and it starts with the blank line between subject and body. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/controllers/helpers/commits_helper.go | 5 +++++ .../tests/commit/paste_multi_line_commit_message.go | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index 5f3b54ca6..adbb98f52 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -341,6 +341,11 @@ func (self *CommitsHelper) HandlePasteIntoSummary(text string) { // 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") + } view.TextArea.TypeString(replaceTabsInPastedText(text)) view.RenderTextArea() } diff --git a/pkg/integration/tests/commit/paste_multi_line_commit_message.go b/pkg/integration/tests/commit/paste_multi_line_commit_message.go index cdee917a9..ac0d77218 100644 --- a/pkg/integration/tests/commit/paste_multi_line_commit_message.go +++ b/pkg/integration/tests/commit/paste_multi_line_commit_message.go @@ -25,9 +25,6 @@ var PasteMultiLineCommitMessage = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().CommitDescription(). IsFocused(). Content(Equals( - /* EXPECTED: "body line 1\nbody line 2")) - ACTUAL: */ - "\nbody line 1\nbody line 2")) }, }) From fb21a990353804df298bf4cc247b4b905d5d1378 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 11:24:28 +0100 Subject: [PATCH 10/19] Return a description driver from Popup.CommitDescriptionPanel It returned a driver for the summary. This made it impossible to use the description's driver methods after a test step that moves the focus to the description, such as pasting a multi-line text into the summary. Co-Authored-By: Claude Opus 5.5 --- pkg/integration/components/popup.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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() { From ce988017f181fa8a37ba054bb63a307cdfed2415 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 11:24:45 +0100 Subject: [PATCH 11/19] Add a test for pasting a wrapped commit message into the summary If you paste a commit message into the summary with the terminal's paste command, the body keeps its hard line breaks. This happens even where auto-wrapping would break the line at the same place. If you then edit a paragraph of the body, it doesn't reflow. Pasting with the commit menu already removes these line breaks. Co-Authored-By: Claude Opus 5.5 --- ...ne_commit_message_with_hard_line_breaks.go | 39 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 40 insertions(+) create mode 100644 pkg/integration/tests/commit/paste_multi_line_commit_message_with_hard_line_breaks.go 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..1c566cb7c --- /dev/null +++ b/pkg/integration/tests/commit/paste_multi_line_commit_message_with_hard_line_breaks.go @@ -0,0 +1,39 @@ +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( + /* EXPECTED: + "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.")) + ACTUAL: */ + "Now The body of this commit was wrapped at the auto-wrap width when it \n" + + "was\n" + + "committed, so it contains a hard line break.")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 4a22b66f2..e5949856a 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -188,6 +188,7 @@ var tests = []*components.IntegrationTest{ commit.PasteCommitMessageWithHardLineBreaks, commit.PasteIntoCommitDescription, commit.PasteMultiLineCommitMessage, + commit.PasteMultiLineCommitMessageWithHardLineBreaks, commit.PreserveCommitMessage, commit.PreserveCommitMessageWhitespace, commit.ResetAuthor, From ebbd2126db66865fab56be6fa5aaf868cddfd070 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 11 Nov 2026 11:25:32 +0100 Subject: [PATCH 12/19] Remove hard line breaks from text pasted into the commit description Do the same as when pasting a commit message with the commit menu, so that the paragraphs of the body reflow when you edit them. This also covers a commit message pasted into the summary, since everything after its first line goes into the description. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/controllers/helpers/commits_helper.go | 6 +++++- ...paste_multi_line_commit_message_with_hard_line_breaks.go | 5 ----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index adbb98f52..3a273b532 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -346,7 +346,11 @@ func (self *CommitsHelper) HandlePasteIntoDescription(text string) { // pastes a whole commit message into the summary text = strings.TrimLeft(text, "\n") } - view.TextArea.TypeString(replaceTabsInPastedText(text)) + // 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() } 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 index 1c566cb7c..99890ce27 100644 --- 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 @@ -28,12 +28,7 @@ var PasteMultiLineCommitMessageWithHardLineBreaks = NewIntegrationTest(NewIntegr GoToBeginning(). Type("Now "). Content(Equals( - /* EXPECTED: "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.")) - ACTUAL: */ - "Now The body of this commit was wrapped at the auto-wrap width when it \n" + - "was\n" + - "committed, so it contains a hard line break.")) }, }) From f3bc6fc85e1526d27a58229cf06a3d5029730f23 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 12 Nov 2026 09:10:17 +0100 Subject: [PATCH 13/19] Extract what the prompt and menu filter editors do after an edit Handlers for pasted text are going to need the same steps. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/editors.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pkg/gui/editors.go b/pkg/gui/editors.go index 7c658265b..35d7adaac 100644 --- a/pkg/gui/editors.go +++ b/pkg/gui/editors.go @@ -33,7 +33,12 @@ 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) 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 +50,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 +71,15 @@ func (gui *Gui) menuFilterEditor(v *gocui.View, key gocui.Key) bool { return true } + gui.onMenuFilterContentChanged(content) + + return true +} + +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) searchEditor(v *gocui.View, key gocui.Key) bool { From f6ceabdebe0bb2f0399fd7ac539f419c6304bfda Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 12 Nov 2026 09:11:43 +0100 Subject: [PATCH 14/19] Add a test for pasting text into the keybindings menu The keybindings menu filters as you type. If you paste text that ends with a newline into it, the text filters the menu, and the newline then runs the selected item as if you had pressed Enter. The same happens in the other menus that filter as you type. Co-Authored-By: Claude Opus 5.5 --- pkg/integration/components/menu_driver.go | 9 +++++ .../paste_into_keybindings_menu.go | 36 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 46 insertions(+) create mode 100644 pkg/integration/tests/filter_and_search/paste_into_keybindings_menu.go 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/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..d87f1cb54 --- /dev/null +++ b/pkg/integration/tests/filter_and_search/paste_into_keybindings_menu.go @@ -0,0 +1,36 @@ +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( + /* EXPECTED: + Contains(`??`).Contains(`myfile`), + ACTUAL: */ + Contains(`A `).Contains(`myfile`), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index e5949856a..aeded3da4 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -337,6 +337,7 @@ var tests = []*components.IntegrationTest{ filter_and_search.NestedFilter, filter_and_search.NestedFilterTransient, filter_and_search.NewSearch, + filter_and_search.PasteIntoKeybindingsMenu, filter_and_search.RerenderTheSearchedMainView, filter_and_search.SearchALongDiff, filter_and_search.SearchStatusAfterARerender, From e68f195e333086deb9f5407dd79864314ae34598 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 12 Nov 2026 09:12:31 +0100 Subject: [PATCH 15/19] Let a menu's filter receive pasted text in one piece The filter takes a single line of text. Drop the newlines at the start and end of the pasted text, and replace the remaining newlines and tabs with spaces. A pasted newline therefore no longer runs the selected menu item. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/editors.go | 30 +++++++++++++++++ pkg/gui/editors_test.go | 27 ++++++++++++++++ pkg/gui/views.go | 4 +++ .../paste_into_keybindings_menu.go | 3 -- .../paste_into_menu_filter.go | 32 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 pkg/gui/editors_test.go create mode 100644 pkg/integration/tests/filter_and_search/paste_into_menu_filter.go diff --git a/pkg/gui/editors.go b/pkg/gui/editors.go index 35d7adaac..6e4f3591b 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" ) @@ -76,6 +78,34 @@ func (gui *Gui) menuFilterEditor(v *gocui.View, key gocui.Key) bool { 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) 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/views.go b/pkg/gui/views.go index 6bc414da6..eb93d6db7 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -150,6 +150,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/tests/filter_and_search/paste_into_keybindings_menu.go b/pkg/integration/tests/filter_and_search/paste_into_keybindings_menu.go index d87f1cb54..92413c644 100644 --- a/pkg/integration/tests/filter_and_search/paste_into_keybindings_menu.go +++ b/pkg/integration/tests/filter_and_search/paste_into_keybindings_menu.go @@ -27,10 +27,7 @@ var PasteIntoKeybindingsMenu = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files(). Lines( - /* EXPECTED: Contains(`??`).Contains(`myfile`), - ACTUAL: */ - Contains(`A `).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/test_list.go b/pkg/integration/tests/test_list.go index aeded3da4..cb53307b2 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -338,6 +338,7 @@ var tests = []*components.IntegrationTest{ filter_and_search.NestedFilterTransient, filter_and_search.NewSearch, filter_and_search.PasteIntoKeybindingsMenu, + filter_and_search.PasteIntoMenuFilter, filter_and_search.RerenderTheSearchedMainView, filter_and_search.SearchALongDiff, filter_and_search.SearchStatusAfterARerender, From 417dea6381f17c2fae532d189963575a20063bf6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 12 Nov 2026 09:13:49 +0100 Subject: [PATCH 16/19] Let prompts receive pasted text in one piece Like a menu's filter, a prompt takes a single line of text, so treat pasted text the same way. Before, the prompt ignored pasted newlines. This joined the lines without a space. It also ran the keybindings for other keys in the text, so a pasted Esc closed the prompt, and a pasted tab switched to the suggestions. Co-Authored-By: Claude Opus 5.5 --- .../helpers/confirmation_helper.go | 8 ----- pkg/gui/editors.go | 7 +++++ pkg/gui/views.go | 4 +++ pkg/integration/components/prompt_driver.go | 7 +++++ .../tests/misc/paste_into_prompt.go | 29 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 pkg/integration/tests/misc/paste_into_prompt.go 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/editors.go b/pkg/gui/editors.go index 6e4f3591b..53ec9c6b2 100644 --- a/pkg/gui/editors.go +++ b/pkg/gui/editors.go @@ -40,6 +40,13 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool { 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 diff --git a/pkg/gui/views.go b/pkg/gui/views.go index eb93d6db7..c7c648f94 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -140,6 +140,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 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/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 cb53307b2..7b8c23905 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -580,6 +580,7 @@ var tests = []*components.IntegrationTest{ misc.DirenvUnloadsOnBlockedEnvrc, misc.FilterRecentRepos, misc.InitialOpen, + misc.PasteIntoPrompt, misc.RecentReposBranchColumn, misc.RecentReposColumnWidths, misc.RecentReposOnLaunch, From 9765b9679017a0da319b275699636964e45c4c90 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 12 Nov 2026 09:14:27 +0100 Subject: [PATCH 17/19] Let the search prompt receive pasted text in one piece Treat pasted text like in the other views that take a single line of text. Before, a pasted newline confirmed the search, and the rest of the pasted text was lost. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/editors.go | 7 +++ pkg/gui/views.go | 4 ++ pkg/integration/components/search_driver.go | 7 +++ .../paste_into_search_prompt.go | 49 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 5 files changed, 68 insertions(+) create mode 100644 pkg/integration/tests/filter_and_search/paste_into_search_prompt.go diff --git a/pkg/gui/editors.go b/pkg/gui/editors.go index 53ec9c6b2..f99f72176 100644 --- a/pkg/gui/editors.go +++ b/pkg/gui/editors.go @@ -119,6 +119,13 @@ func (gui *Gui) onMenuFilterContentChanged(content string) { gui.helpers.Search.ApplyFilter(menuContext, content) } +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 { matched := gui.handleEditorKeypress(v, key, false) v.RenderTextArea() diff --git a/pkg/gui/views.go b/pkg/gui/views.go index c7c648f94..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 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/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/test_list.go b/pkg/integration/tests/test_list.go index 7b8c23905..79d40de3e 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -339,6 +339,7 @@ var tests = []*components.IntegrationTest{ 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, From c28e3576d1f94d106caeadbdc44e1cb29941011a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 12 Nov 2026 09:15:48 +0100 Subject: [PATCH 18/19] Drop pasted keys for views without an OnPaste handler Every view that takes text has an OnPaste handler now, and views that don't take text already ignored pasted keys. Make this a single rule. While a paste is in progress, keys go to the focused view's OnPaste handler or nowhere. This replaces the check in execKeybindings. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/gui.go | 10 ++++------ pkg/gocui/paste_test.go | 10 +++++++++- pkg/gocui/view.go | 9 +++++---- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 8c811c5d1..73aaeda6f 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1809,8 +1809,10 @@ func (g *Gui) onKey(ev *GocuiEvent) error { ev.Key = NewKeyName(KeyEnter) } - if g.IsPasting && g.currentView != nil && g.currentView.OnPaste != nil { - g.collectPastedKey(g.currentView, ev.Key) + if g.IsPasting { + if g.currentView != nil && g.currentView.OnPaste != nil { + g.collectPastedKey(g.currentView, ev.Key) + } return nil } @@ -2087,10 +2089,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 index 473ca574b..9dbfcbae8 100644 --- a/pkg/gocui/paste_test.go +++ b/pkg/gocui/paste_test.go @@ -55,9 +55,16 @@ func TestPasteIntoViewWithoutPasteHandler(t *testing.T) { _, 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), } { @@ -65,5 +72,6 @@ func TestPasteIntoViewWithoutPasteHandler(t *testing.T) { assert.NoError(t, g.handleEvent(&ev)) } - assert.Equal(t, "ab", v.TextArea.GetContent()) + assert.Equal(t, "", v.TextArea.GetContent()) + assert.False(t, enterPressed) } diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index ae4715a9f..ed9fee232 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -163,10 +163,11 @@ type View struct { // default. Editor Editor - // OnPaste, if set, receives pasted text in one piece when the paste ends, - // instead of one key press per character. The text consists of the pasted - // printable characters, newlines and tabs; other keys in the paste, such as - // Esc or control characters, are dropped. + // 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. From 14a36053e9ac896f09de48f355a910998fea9fec Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 12 Nov 2026 09:37:18 +0100 Subject: [PATCH 19/19] Unexport IsPasting Nothing outside gocui needs to know about pastes any more. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/block_events_test.go | 2 +- pkg/gocui/gui.go | 10 ++++++---- pkg/gocui/paste_test.go | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) 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 73aaeda6f..9af69f305 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -188,7 +188,9 @@ 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 @@ -1288,7 +1290,7 @@ 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() } @@ -1805,11 +1807,11 @@ 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.isPasting { if g.currentView != nil && g.currentView.OnPaste != nil { g.collectPastedKey(g.currentView, ev.Key) } diff --git a/pkg/gocui/paste_test.go b/pkg/gocui/paste_test.go index 9dbfcbae8..7735cde0c 100644 --- a/pkg/gocui/paste_test.go +++ b/pkg/gocui/paste_test.go @@ -44,7 +44,7 @@ func TestPasteIntoViewWithPasteHandler(t *testing.T) { assert.Equal(t, []string{"a\n\n\tb"}, pasted) assert.False(t, escPressed) assert.Equal(t, "", v.TextArea.GetContent()) - assert.False(t, g.IsPasting) + assert.False(t, g.isPasting) } func TestPasteIntoViewWithoutPasteHandler(t *testing.T) {