Copy the selected diff line(s) from the focused main view

`ctrl+o` in the focused main view copies the selection to the clipboard,
mirroring the staging view's copy. Unlike stage / discard, copying is the same
for every diff panel — it just reads the text shown — so it's a direct
MainViewController command rather than a FocusedMainViewActions method. That
keeps it free of per-panel duplication and lets it work over panels that have
no actions, notably the reflog.

The +/- prefix handling is metadata-aware where it matters: with no pager the
main view shows the raw diff, so a homogeneous selection has its +/-/space
column stripped (dropDiffPrefix) to ease pasting into code. With a pager
configured the rendered lines carry no such column, so stripping a leading
character would eat real content — copy verbatim instead. (Refining the raw
case to classify header lines via the diff-line metadata, fixing the
'--- a/file' edge case noted in 159bbb0825, is left for later.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-19 20:10:20 +02:00
parent a0af1b1d9a
commit f0a997705a
12 changed files with 127 additions and 0 deletions

View file

@ -226,6 +226,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Go to previous hunk | |
@ -339,6 +340,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Go to previous hunk | |

View file

@ -195,6 +195,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | サイドパネルに戻る | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` <left>, h `` | 前のハンクに移動 | |
@ -316,6 +317,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | サイドパネルに戻る | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` <left>, h `` | 前のハンクに移動 | |

View file

@ -87,6 +87,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` <left>, h `` | 이전 hunk를 선택 | |
@ -173,6 +174,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` <left>, h `` | 이전 hunk를 선택 | |

View file

@ -234,6 +234,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle drag selecteer | |
| `` <left>, h `` | Selecteer de vorige hunk | |
@ -317,6 +318,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle drag selecteer | |
| `` <left>, h `` | Selecteer de vorige hunk | |

View file

@ -102,6 +102,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
@ -212,6 +213,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |

View file

@ -238,6 +238,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Ir para o local anterior | |
@ -348,6 +349,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Ir para o local anterior | |

View file

@ -77,6 +77,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` <left>, h `` | Выбрать предыдущую часть | |
@ -117,6 +118,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` <left>, h `` | Выбрать предыдущую часть | |

View file

@ -289,6 +289,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | 退出回到侧边面板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时使用git reset丢弃该变更。当选择已暂存的变更时取消暂存该变更 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` <left>, h `` | 选择上一个区块 | |
@ -345,6 +346,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | 退出回到侧边面板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时使用git reset丢弃该变更。当选择已暂存的变更时取消暂存该变更 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` <left>, h `` | 选择上一个区块 | |

View file

@ -85,6 +85,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 切換拖曳選擇 | |
| `` <left>, h `` | 選擇上一段 | |
@ -374,6 +375,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 切換拖曳選擇 | |
| `` <left>, h `` | 選擇上一段 | |

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
@ -87,6 +88,12 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
Tooltip: self.c.Tr.DiscardSelectionTooltip,
DisplayOnScreen: selectionShown,
},
{
Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard),
Handler: self.copySelection,
Description: self.c.Tr.CopySelectedTextToClipboard,
DisplayOnScreen: selectionShown,
},
{
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
Handler: self.enter,
@ -362,6 +369,66 @@ func (self *MainViewController) discardSelectionDisabledReason() *types.Disabled
return actions.DiscardSelectionDisabledReason()
}
// copySelection copies the selected diff line(s) to the clipboard. Unlike the primary
// action and discard, this is the same for every diff panel (it just reads the text the
// focused main view shows), so it lives here rather than on FocusedMainViewActions — and
// so it works over panels with no actions, like the reflog. A no-op when there's no
// selection (non-diff content).
//
// With no pager the main view shows the raw diff, so the +/-/space column is stripped
// from a homogeneous selection (dropDiffPrefix) to ease pasting into code, mirroring the
// staging view's copy. With a pager configured we can't assume that column is present —
// some pagers keep it and only colorize (e.g. ydiff), others drop or restructure it (e.g.
// delta) — and we can't tell which, so we conservatively don't strip and copy verbatim.
// (The cost is missing the convenience for column-preserving pagers.)
func (self *MainViewController) copySelection() error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
// The selection is in (wrapped) view-line space; map it to the unwrapped buffer lines
// it covers and copy those, so a wrapped line is copied whole and once — not indexed
// into the buffer by a view-line number (which copies the wrong line, or panics when
// the view line is past the buffer's length).
firstView, lastView := v.SelectedLineRange()
firstBuffer, ok := v.BufferLineForViewLine(firstView)
if !ok {
return nil
}
lastBuffer, ok := v.BufferLineForViewLine(lastView)
if !ok {
return nil
}
contents := v.DiffLineContents()
lastBuffer = min(lastBuffer, len(contents)-1)
if firstBuffer > lastBuffer {
return nil
}
lines := make([]string, 0, lastBuffer-firstBuffer+1)
for i := firstBuffer; i <= lastBuffer; i++ {
lines = append(lines, contents[i].Text)
}
// Trailing newline included so the last line is terminated too (dropDiffPrefix keeps
// it; the pager-verbatim path needs it added here).
selected := strings.Join(lines, "\n") + "\n"
if !self.usingExternalDiff() {
selected = dropDiffPrefix(selected)
}
self.c.LogAction(self.c.Tr.Actions.CopySelectedTextToClipboard)
return self.c.OS().CopyToClipboard(selected)
}
// usingExternalDiff reports whether the focused main view's diff is produced by an
// external diff command (a pager), in which case the rendered lines may not carry the raw
// +/-/space column (see copySelection).
func (self *MainViewController) usingExternalDiff() bool {
return self.c.State().GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit
}
// revealSelectionAfterPrimaryAction re-establishes the focused-main-view selection after a
// primary action (staging or a patch toggle) re-renders the diff. The selection's
// change-line ordinal is read from the source pane (still showing the pre-action diff

View file

@ -0,0 +1,41 @@
package staging
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var CopyFromMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Copy a hunk from the focused main view to the clipboard, with the raw diff's +/- prefix stripped",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
// Emulate the clipboard by writing to a file.
config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard"
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\n")
shell.Commit("one")
// Two consecutive added lines, so the selected hunk is a homogeneous addition.
shell.UpdateFile("file1", "one\nADD1\nADD2\ntwo\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("+ADD1"),
Contains("+ADD2"),
).
Press(keys.Universal.CopyToClipboard)
// With no pager the main view shows the raw diff, so a homogeneous addition is
// copied with the '+' column stripped — ready to paste into code.
t.FileSystem().FileContent("clipboard", Equals("ADD1\nADD2\n"))
},
})

View file

@ -412,6 +412,7 @@ var tests = []*components.IntegrationTest{
shell_commands.History,
shell_commands.OmitFromHistory,
staging.AdvanceToNextHunkAfterStagingShiftsLineNumbers,
staging.CopyFromMainView,
staging.DiffChangeScreenMode,
staging.DiffContextChange,
staging.DiscardAllChanges,