From 33e53a7579e04204a48d4b6143ef595162e36236 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 16 Aug 2026 06:28:39 +0200 Subject: [PATCH] Copy the selected diff lines from the focused main view The main view shows a diff renderer's picture of a diff, and that is not what you want on your clipboard: a renderer may drop the +/- column, move the line numbers into a gutter, group a hunk's deletions before its additions, or leave lines out altogether. So copying takes the lines from the diff itself, locating them by the identity of the selected rows. Only the panel that produced the diff can produce it again, though: the working tree's staged or unstaged side, a commit's, a stash entry's. The new seam is there for that. It asks per file, so that copying three lines of a commit's diff doesn't fetch the whole of it, and it will grow the actions on a selection as staging and patch building arrive. The clipboard gets the run of diff lines from the first selected line of a file to the last, so that lines the renderer hid come along and the result still reads as a diff. Headers count as selected lines too. A hunk header names the first line of its hunk, so it is looked for the way a line of the file is. A file header names no line at all, and a rendering may draw it over any number of rows, so a selection touching one of them takes the whole header. As in the patch explorers, a selection that is all additions or all deletions loses its +/- column, ready to be pasted into code. One that reaches into a header keeps the columns, and what comes out is a patch fragment rather than lines of code. Co-authored-by: Claude Opus 5 (1M context) --- docs-master/keybindings/Keybindings_en.md | 2 + docs-master/keybindings/Keybindings_ja.md | 2 + docs-master/keybindings/Keybindings_ko.md | 2 + docs-master/keybindings/Keybindings_nl.md | 2 + docs-master/keybindings/Keybindings_pl.md | 2 + docs-master/keybindings/Keybindings_pt.md | 2 + docs-master/keybindings/Keybindings_ru.md | 2 + docs-master/keybindings/Keybindings_zh-CN.md | 2 + docs-master/keybindings/Keybindings_zh-TW.md | 2 + pkg/gui/context/base_context.go | 31 +++- pkg/gui/controllers/attach.go | 1 + pkg/gui/controllers/base_controller.go | 4 + .../controllers/commits_files_controller.go | 12 ++ pkg/gui/controllers/files_controller.go | 8 + pkg/gui/controllers/helpers/diff_helper.go | 13 ++ .../helpers/diff_line_plain_text.go | 172 ++++++++++++++++++ .../controllers/helpers/diff_line_queries.go | 24 +++ pkg/gui/controllers/main_view_controller.go | 64 +++++++ .../controllers/reflog_commits_controller.go | 14 ++ .../switch_to_diff_files_controller.go | 17 ++ .../controllers/working_tree_diff_actions.go | 44 +++++ pkg/gui/types/context.go | 24 +++ pkg/gui/types/diff_line_info.go | 8 + pkg/i18n/english.go | 4 + .../copy_rows_that_are_no_diff_line.go | 56 ++++++ .../main_view/copy_selected_diff_lines.go | 141 ++++++++++++++ pkg/integration/tests/test_list.go | 2 + 27 files changed, 649 insertions(+), 8 deletions(-) create mode 100644 pkg/gui/controllers/helpers/diff_line_plain_text.go create mode 100644 pkg/gui/controllers/working_tree_diff_actions.go create mode 100644 pkg/integration/tests/main_view/copy_rows_that_are_no_diff_line.go create mode 100644 pkg/integration/tests/main_view/copy_selected_diff_lines.go diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 463d6ac19..6f3d6cbac 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -226,6 +226,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | Toggle range select | | | `` e `` | Edit file | Open file in external editor. | +| `` `` | Copy selected text to clipboard | | | `` , h `` | Go to previous hunk | | | `` , l `` | Go to next hunk | | | `` N `` | Go to previous file | | @@ -338,6 +339,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | Toggle range select | | | `` e `` | Edit file | Open file in external editor. | +| `` `` | Copy selected text to clipboard | | | `` , h `` | Go to previous hunk | | | `` , l `` | Go to next hunk | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 9865e2dee..7fd62f726 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -195,6 +195,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | | `` v `` | 範囲選択を切り替え | | | `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 | +| `` `` | 選択したテキストをクリップボードにコピー | | | `` , h `` | 前のハンクに移動 | | | `` , l `` | 次のハンクに移動 | | | `` N `` | Go to previous file | | @@ -315,6 +316,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | | `` v `` | 範囲選択を切り替え | | | `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 | +| `` `` | 選択したテキストをクリップボードにコピー | | | `` , h `` | 前のハンクに移動 | | | `` , l `` | 次のハンクに移動 | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index b2d3f5e6a..b0437591f 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -87,6 +87,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | 드래그 선택 전환 | | | `` e `` | 파일 편집 | Open file in external editor. | +| `` `` | 선택한 텍스트를 클립보드에 복사 | | | `` , h `` | 이전 hunk를 선택 | | | `` , l `` | 다음 hunk를 선택 | | | `` N `` | Go to previous file | | @@ -172,6 +173,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | 드래그 선택 전환 | | | `` e `` | 파일 편집 | Open file in external editor. | +| `` `` | 선택한 텍스트를 클립보드에 복사 | | | `` , h `` | 이전 hunk를 선택 | | | `` , l `` | 다음 hunk를 선택 | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 1b2bca0c1..3d7012267 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -234,6 +234,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. | | `` v `` | Toggle drag selecteer | | | `` e `` | Verander bestand | Open bestand in externe editor. | +| `` `` | Copy selected text to clipboard | | | `` , h `` | Selecteer de vorige hunk | | | `` , l `` | Selecteer de volgende hunk | | | `` N `` | Go to previous file | | @@ -316,6 +317,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. | | `` v `` | Toggle drag selecteer | | | `` e `` | Verander bestand | Open bestand in externe editor. | +| `` `` | Copy selected text to clipboard | | | `` , h `` | Selecteer de vorige hunk | | | `` , l `` | Selecteer de volgende hunk | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 3da5d4eb8..e79338b3c 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -102,6 +102,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | Przełącz zaznaczenie zakresu | | | `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | +| `` `` | Kopiuj zaznaczony tekst do schowka | | | `` , h `` | Idź do poprzedniego fragmentu | | | `` , l `` | Idź do następnego fragmentu | | | `` N `` | Go to previous file | | @@ -211,6 +212,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | Przełącz zaznaczenie zakresu | | | `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | +| `` `` | Kopiuj zaznaczony tekst do schowka | | | `` , h `` | Idź do poprzedniego fragmentu | | | `` , l `` | Idź do następnego fragmentu | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 04af6d319..4041c5edd 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -238,6 +238,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | | `` v `` | Toggle range select | | | `` e `` | Editar arquivo | Abrir arquivo no editor externo. | +| `` `` | Copiar texto selecionado para área de transferência | | | `` , h `` | Ir para o local anterior | | | `` , l `` | Ir para o próximo trecho | | | `` N `` | Go to previous file | | @@ -347,6 +348,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | | `` v `` | Toggle range select | | | `` e `` | Editar arquivo | Abrir arquivo no editor externo. | +| `` `` | Copiar texto selecionado para área de transferência | | | `` , h `` | Ir para o local anterior | | | `` , l `` | Ir para o próximo trecho | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index feba4b266..d113eb4fa 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -77,6 +77,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | Переключить выборку перетаскивания | | | `` e `` | Редактировать файл | Open file in external editor. | +| `` `` | Скопировать выделенный текст в буфер обмена | | | `` , h `` | Выбрать предыдущую часть | | | `` , l `` | Выбрать следующую часть | | | `` N `` | Go to previous file | | @@ -116,6 +117,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | Переключить выборку перетаскивания | | | `` e `` | Редактировать файл | Open file in external editor. | +| `` `` | Скопировать выделенный текст в буфер обмена | | | `` , h `` | Выбрать предыдущую часть | | | `` , l `` | Выбрать следующую часть | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 9ab544400..97d006713 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -289,6 +289,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | | `` v `` | 切换拖动选择 | | | `` e `` | 编辑文件 | 使用外部编辑器打开文件 | +| `` `` | 复制选中文本到剪贴板 | | | `` , h `` | 选择上一个区块 | | | `` , l `` | 选择下一个区块 | | | `` N `` | Go to previous file | | @@ -344,6 +345,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | | `` v `` | 切换拖动选择 | | | `` e `` | 编辑文件 | 使用外部编辑器打开文件 | +| `` `` | 复制选中文本到剪贴板 | | | `` , h `` | 选择上一个区块 | | | `` , l `` | 选择下一个区块 | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 484ac4994..cc8116843 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -78,6 +78,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | | `` v `` | 切換拖曳選擇 | | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 | +| `` `` | 複製所選文本至剪貼簿 | | | `` , h `` | 選擇上一段 | | | `` , l `` | 選擇下一段 | | | `` N `` | Go to previous file | | @@ -366,6 +367,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | | `` v `` | 切換拖曳選擇 | | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 | +| `` `` | 複製所選文本至剪貼簿 | | | `` , h `` | 選擇上一段 | | | `` , l `` | 選擇下一段 | | | `` N `` | Go to previous file | | diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index 8a2e8a55e..67135d772 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -13,14 +13,15 @@ type BaseContext struct { windowName string onGetOptionsMap func() map[string]string - keybindingsFns []types.KeybindingsFn - mouseKeybindingsFns []types.MouseKeybindingsFn - onDoubleClickFn func() error - onClickFn func(opts gocui.ViewMouseBindingOpts) error - onRenderToMainFn func() - onFocusFns []onFocusFn - onFocusLostFns []onFocusLostFn - onQuitFns []func() + keybindingsFns []types.KeybindingsFn + mouseKeybindingsFns []types.MouseKeybindingsFn + onDoubleClickFn func() error + onClickFn func(opts gocui.ViewMouseBindingOpts) error + focusedMainViewDiffSource types.FocusedMainViewDiffSource + onRenderToMainFn func() + onFocusFns []onFocusFn + onFocusLostFns []onFocusLostFn + onQuitFns []func() focusable bool transient bool @@ -159,6 +160,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() { self.onQuitFns = nil self.onDoubleClickFn = nil self.onClickFn = nil + self.focusedMainViewDiffSource = nil self.onRenderToMainFn = nil } @@ -180,6 +182,15 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e } } +func (self *BaseContext) AddFocusedMainViewDiffSource(source types.FocusedMainViewDiffSource) { + if source != nil { + if self.focusedMainViewDiffSource != nil { + panic("only one controller is allowed to set the focused main view diff source") + } + self.focusedMainViewDiffSource = source + } +} + func (self *BaseContext) GetOnDoubleClick() func() error { return self.onDoubleClickFn } @@ -188,6 +199,10 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro return self.onClickFn } +func (self *BaseContext) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource { + return self.focusedMainViewDiffSource +} + func (self *BaseContext) AddOnRenderToMainFn(fn func()) { if fn != nil { if self.onRenderToMainFn != nil { diff --git a/pkg/gui/controllers/attach.go b/pkg/gui/controllers/attach.go index ae5ac5c09..f3c36da43 100644 --- a/pkg/gui/controllers/attach.go +++ b/pkg/gui/controllers/attach.go @@ -8,6 +8,7 @@ func AttachControllers(context types.Context, controllers ...types.IController) context.AddMouseKeybindingsFn(controller.GetMouseKeybindings) context.AddOnDoubleClickFn(controller.GetOnDoubleClick()) context.AddOnClickFn(controller.GetOnClick()) + context.AddFocusedMainViewDiffSource(controller.GetFocusedMainViewDiffSource()) context.AddOnRenderToMainFn(controller.GetOnRenderToMain()) context.AddOnFocusFn(controller.GetOnFocus()) context.AddOnFocusLostFn(controller.GetOnFocusLost()) diff --git a/pkg/gui/controllers/base_controller.go b/pkg/gui/controllers/base_controller.go index f0684461f..5ef03e4fd 100644 --- a/pkg/gui/controllers/base_controller.go +++ b/pkg/gui/controllers/base_controller.go @@ -23,6 +23,10 @@ func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) e return nil } +func (self *baseController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource { + return nil +} + func (self *baseController) GetOnRenderToMain() func() { return nil } diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 33cb7f045..2e31ad906 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -191,6 +191,18 @@ func (self *CommitFilesController) GetOnRenderToMain() func() { } } +func (self *CommitFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource { + return self +} + +// PlainDiff hands out the commit's diff for the given files. Both panes show the same +// diff here — the secondary one shows the custom patch built from it, which is not a +// diff of the commit — so which pane asks makes no difference. +func (self *CommitFilesController) PlainDiff(_ types.DiffPaneContext, paths []string) string { + from, to := self.context().GetFromAndToForDiff() + return self.c.Helpers().Diff.PlainDiffBetweenRefs(from, to, paths) +} + func (self *CommitFilesController) copyDiffToClipboard(paths []string, toastMessage string) error { from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 6579a4543..ab58de7c8 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -21,6 +21,9 @@ type FilesController struct { baseController *ListControllerTrait[*filetree.FileNode] c *ControllerCommon + + // what this panel offers on the diff it shows in the focused main view + diffActions *WorkingTreeDiffActions } var _ types.IController = &FilesController{} @@ -36,6 +39,7 @@ func NewFilesController( c.Contexts().Files.GetSelected, c.Contexts().Files.GetSelectedItems, ), + diffActions: NewWorkingTreeDiffActions(c), } } @@ -400,6 +404,10 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { self.c.RenderToMainViews(refreshOpts) } +func (self *FilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource { + return self.diffActions +} + func (self *FilesController) GetOnDoubleClick() func() error { return self.withItemGraceful(func(node *filetree.FileNode) error { return self.press([]*filetree.FileNode{node}) diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go index 0547fafa3..c5d88f39b 100644 --- a/pkg/gui/controllers/helpers/diff_helper.go +++ b/pkg/gui/controllers/helpers/diff_helper.go @@ -83,6 +83,19 @@ func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Comm return types.NewRunDiffRendererTask(cmdObj.GetCmd()) } +// PlainDiffBetweenRefs returns the diff of the given files between two refs as git +// writes it, without colour or a diff renderer's involvement — what a panel showing +// a commit's diff hands out as the diff behind its rendering (see +// types.FocusedMainViewDiffSource). It honours diffing mode, so that the diff is of +// the same two ends the main view is showing. +func (self *DiffHelper) PlainDiffBetweenRefs(from string, to string, paths []string) string { + from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) + // An error means there is no diff to be had, which for our purposes is the same + // as an empty one. + diff, _ := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, true).RunWithOutput() + return diff +} + func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string { filterPath := self.c.Modes().Filtering.GetPath() if filterPath != "" { diff --git a/pkg/gui/controllers/helpers/diff_line_plain_text.go b/pkg/gui/controllers/helpers/diff_line_plain_text.go new file mode 100644 index 000000000..63663599f --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_plain_text.go @@ -0,0 +1,172 @@ +package helpers + +import ( + "path/filepath" + "slices" + "strings" + + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +// Reading a rendering back to the diff it came from. What a diff view shows is a diff +// renderer's picture of a diff, and a picture is not what you want on your clipboard, +// or in a patch — so the lines of interest are located by identity in the diff itself, +// which the panel that rendered it hands out (types.FocusedMainViewDiffSource). + +// PlainDiffOfSelection returns the text of the rows selected in view, in two parts. +// +// aboveDiff is what the selection covers above the diff, as it reads on screen; see +// textAboveDiff. fromDiff is the diff behind the rest: per file the selection touches, +// the run of diff lines from the first of its selected lines to the last, with the +// files in the order the selection meets them. +// +// A run, rather than the matched lines alone, so that what comes out reads as a diff: +// the lines between two selected ones come along even when the rendering didn't show +// them (difftastic leaves out whitespace-only changes) or showed them in another order +// (a side-by-side rendering groups the deletions of a hunk before its additions). +// +// Headers are selected lines like any other. A hunk header names the first line of its +// hunk, in the rendering as in the diff, so it is looked for the way a line of the file +// is; a file header names no line at all and a rendering may spread it over as many +// rows as it likes, so a selection touching one of them takes the whole header. +// +// plainDiff fetches the diff of the given repo-relative files, and is asked only for +// the files the selection touches, so that copying three lines of a commit's diff +// doesn't fetch the whole of it. fromDiff is "" when no selected row could be found in +// the diff, e.g. because the selection covers nothing but a renderer's decoration. +func (self *DiffLineHelper) PlainDiffOfSelection( + view *gocui.View, first int, last int, plainDiff func(paths []string) string, +) (aboveDiff string, fromDiff string) { + worktreePath := self.c.Git().RepoPaths.WorktreePath() + aboveDiff = self.textAboveDiff(view, first, last) + + // The files in the order they are shown, and per file what the selection covers of + // its diff. + paths := []string{} + selection := map[string]*selectedDiffLines{} + for _, info := range self.DiffLinesInViewRange(view, first, last) { + // A row that is neither a line of the file nor a header — the "\ No newline at + // end of file" marker — names nothing to look for. It comes along anyway when it + // falls within a run. + if info.Type == types.DiffLineOther { + continue + } + selected, ok := selection[info.Path] + if !ok { + paths = append(paths, info.Path) + selected = &selectedDiffLines{lines: map[patchLine]bool{}} + selection[info.Path] = selected + } + if info.Type == types.DiffLineFileHeader { + selected.header = true + } else { + selected.lines[patchLineOf(info)] = true + } + } + + relPaths := repoRelativePaths(worktreePath, paths) + if len(relPaths) == 0 { + return aboveDiff, "" + } + + diffLines := strings.Split(strings.TrimSuffix(plainDiff(relPaths), "\n"), "\n") + runs := map[string][2]int{} + for i, parsed := range parseAllDiffLinesFromBuffer(diffLines) { + if !parsed.ok { + continue + } + info := diffLineInfoIn(worktreePath, parsed.parsed) + selected := selection[info.Path] + if selected == nil || !selected.covers(info) { + continue + } + if run, ok := runs[info.Path]; ok { + runs[info.Path] = [2]int{run[0], i} + } else { + runs[info.Path] = [2]int{i, i} + } + } + + text := strings.Builder{} + for _, path := range paths { + run, ok := runs[path] + if !ok { + continue + } + for _, line := range diffLines[run[0] : run[1]+1] { + text.WriteString(line) + text.WriteString("\n") + } + } + return aboveDiff, text.String() +} + +// textAboveDiff returns the text of the selected rows that sit above the diff, as they +// read on screen. What a diff view shows before its first file — a commit's message and +// git's summary of it — is part of no file's diff, so there is nothing to look those +// rows up in and the rendering is all we have of them. +// +// It is "" for a view that shows no file at all: with no diff on screen there is +// nothing for the text to be above, and a rendering we couldn't read as a diff is +// exactly what we don't want on the clipboard. +func (self *DiffLineHelper) textAboveDiff(view *gocui.View, first int, last int) string { + firstRow, ok := view.BufferLineForViewLine(first) + if !ok { + return "" + } + lastRow, ok := view.BufferLineForViewLine(last) + if !ok { + return "" + } + + startOfDiff := slices.IndexFunc(self.filePaths(view), func(path string) bool { return path != "" }) + if startOfDiff == -1 { + return "" + } + + lastRow = min(lastRow, startOfDiff-1) + if lastRow < firstRow { + return "" + } + return strings.Join(view.BufferLines()[firstRow:lastRow+1], "\n") + "\n" +} + +// selectedDiffLines is what a selection covers of one file's diff. +type selectedDiffLines struct { + // The lines of the file to look for, hunk headers among them, by the identity that + // names them in any rendering of the diff. + lines map[patchLine]bool + // Whether the file's header is covered, in whole or in part. + header bool +} + +// covers reports whether the given line of a file's diff is one the selection holds. +// +// A file header is answered for by kind rather than looked for: the two ways a row's +// identity is recovered disagree about what to call it — a parse of the diff says the +// file's first line, a renderer's record says the file has no line — and a rendering +// may show the header as one row or as ten. So a selection that touches it holds every +// line of it. +func (self *selectedDiffLines) covers(info types.DiffLineInfo) bool { + if info.Type == types.DiffLineFileHeader { + return self.header + } + return self.lines[patchLineOf(info)] +} + +// repoRelativePaths turns the absolute paths a diff line's identity carries into the +// repo-relative ones git speaks, dropping any that lies outside the worktree — a diff +// renderer states the path however it likes, and one we can't place is one we can't +// ask git about. +func repoRelativePaths(worktreePath string, paths []string) []string { + relPaths := make([]string, 0, len(paths)) + for _, path := range paths { + relPath, err := filepath.Rel(worktreePath, path) + if err != nil || strings.HasPrefix(relPath, "..") { + continue + } + relPaths = append(relPaths, filepath.ToSlash(relPath)) + } + return relPaths +} diff --git a/pkg/gui/controllers/helpers/diff_line_queries.go b/pkg/gui/controllers/helpers/diff_line_queries.go index 6437e8458..0363f16fb 100644 --- a/pkg/gui/controllers/helpers/diff_line_queries.go +++ b/pkg/gui/controllers/helpers/diff_line_queries.go @@ -11,6 +11,30 @@ import ( // cursor and a click speak. They are all built on the identities recovered in // diff_line_helper.go, which is where the answering stops and the recovering starts. +// DiffLinesInViewRange returns the identity of every diff line shown by the rows in +// the inclusive view-line range [first, last] of view's rendered diff, in display +// order. Rows whose identity can't be recovered are left out, as are the wrapped +// segments of a row already counted. +// +// A row can show more than one diff line — a side-by-side rendering puts a deletion +// beside the addition replacing it — and all of them are reported: what the user +// pointed at is the row, so everything on it is selected. +func (self *DiffLineHelper) DiffLinesInViewRange(view *gocui.View, first int, last int) []types.DiffLineInfo { + identities := self.resolveDiffLineIdentities(view.DiffLineContents()) + + infos := []types.DiffLineInfo{} + previousBufferLine := -1 + for viewLine := first; viewLine <= last; viewLine++ { + bufferLine, ok := view.BufferLineForViewLine(viewLine) + if !ok || bufferLine == previousBufferLine || bufferLine >= len(identities) { + continue + } + previousBufferLine = bufferLine + infos = append(infos, identities[bufferLine]...) + } + return infos +} + // changeLines resolves view's rendered diff to one flag per buffer line: whether // that row is a change line (an addition or a deletion), as opposed to context, a // header, or a row whose identity couldn't be recovered. Those are the rows a diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index a5ad2aff4..2391d8af1 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -79,6 +79,13 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty GetDisabledReason: self.diffSelectionDisabledReason, Tooltip: self.c.Tr.EditFileTooltip, }, + { + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), + Handler: self.copySelection, + Description: self.c.Tr.CopySelectedTextToClipboard, + DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.CopySelectedTextToClipboard), + GetDisabledReason: self.diffSelectionDisabledReason, + }, { Keys: opts.GetKeys(opts.Config.Main.PrevHunk), Handler: self.prevChangeBlock, @@ -248,6 +255,63 @@ func (self *MainViewController) diffMainViewType() types.DiffMainViewType { return types.DiffMainViewTypeNone } +// diffSource returns the panel beneath the focused main view, as the thing that can +// hand out the diff it rendered there. nil when this pane isn't on the stack, or the +// panel beneath shows no diff. +func (self *MainViewController) diffSource() types.FocusedMainViewDiffSource { + if !self.c.Context().IsInStack(self.context) { + return nil + } + sidePanel := self.c.Context().NextInStack(self.context) + if sidePanel == nil { + return nil + } + return sidePanel.GetFocusedMainViewDiffSource() +} + +// copySelection copies the selected diff lines to the clipboard — not as the diff +// renderer drew them, but as they read in the diff itself, which is both what you meant +// to copy and the only form a renderer can't have mangled. A selection that is all +// additions or all deletions loses its +/- column, so that it can be pasted straight +// into code. +// +// The rows above the diff (a commit's message, git's summary of it) belong to no file, +// so they are copied as they stand on screen. +func (self *MainViewController) copySelection() error { + text := self.textOfSelection() + if text == "" { + self.c.ErrorToast(self.c.Tr.SelectionNotFoundInDiffToast) + return nil + } + + self.c.LogAction(self.c.Tr.Actions.CopySelectedTextToClipboard) + if err := self.c.OS().CopyToClipboard(text); err != nil { + return err + } + self.c.Toast(self.c.Tr.SelectedDiffLinesCopiedToast) + return nil +} + +// textOfSelection is what copying the selection puts on the clipboard. It is "" when +// none of the selected rows could be placed in the diff, which is what a rendering's +// own decoration comes to. +func (self *MainViewController) textOfSelection() string { + source := self.diffSource() + if source == nil { + return "" + } + view := self.context.GetView() + first, last := view.SelectedLineRange() + aboveDiff, fromDiff := self.c.Helpers().DiffLine.PlainDiffOfSelection(view, first, last, + func(paths []string) string { return source.PlainDiff(self.context, paths) }) + if aboveDiff == "" { + // Only text that is all diff has a +/- column to lose: a line of a commit message + // may begin with a '-' without being a deletion of anything. + fromDiff = dropDiffPrefix(fromDiff) + } + return aboveDiff + fromDiff +} + // diffSelectState returns this pane's diff selection mode state. func (self *MainViewController) diffSelectState() *types.DiffSelectState { return self.context.DiffSelectState() diff --git a/pkg/gui/controllers/reflog_commits_controller.go b/pkg/gui/controllers/reflog_commits_controller.go index 1a42ff401..a0a008105 100644 --- a/pkg/gui/controllers/reflog_commits_controller.go +++ b/pkg/gui/controllers/reflog_commits_controller.go @@ -37,6 +37,20 @@ func (self *ReflogCommitsController) context() *context.ReflogCommitsContext { return self.c.Contexts().ReflogCommits } +func (self *ReflogCommitsController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource { + return self +} + +// PlainDiff hands out the reflog entry's diff for the given files — the same diff its +// main view shows, only without the entry's message and stat above it. +func (self *ReflogCommitsController) PlainDiff(_ types.DiffPaneContext, paths []string) string { + commit := self.context().GetSelected() + if commit == nil { + return "" + } + return self.c.Helpers().Diff.PlainDiffBetweenRefs(commit.ParentRefName(), commit.RefName(), paths) +} + func (self *ReflogCommitsController) GetOnRenderToMain() func() { return func() { self.c.Helpers().Diff.WithDiffModeCheck(func() { diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index afdf92c80..e8dd886cb 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -4,6 +4,7 @@ import ( "path/filepath" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -64,6 +65,22 @@ func (self *SwitchToDiffFilesController) GetOnDoubleClick() func() error { } } +func (self *SwitchToDiffFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource { + return self +} + +// PlainDiff hands out the diff of the panel's selected commit (or range of them) for +// the given files — the same diff its main view shows, only without the commit's +// message and stat above it. +func (self *SwitchToDiffFilesController) PlainDiff(_ types.DiffPaneContext, paths []string) string { + ref := self.context.GetSelectedRef() + if ref == nil { + return "" + } + from, to := context.FromAndToForDiff(ref, self.context.GetSelectedRefRangeForDiffFiles()) + return self.c.Helpers().Diff.PlainDiffBetweenRefs(from, to, paths) +} + func (self *SwitchToDiffFilesController) enter() error { ref := self.context.GetSelectedRef() refsRange := self.context.GetSelectedRefRangeForDiffFiles() diff --git a/pkg/gui/controllers/working_tree_diff_actions.go b/pkg/gui/controllers/working_tree_diff_actions.go new file mode 100644 index 000000000..889cc0859 --- /dev/null +++ b/pkg/gui/controllers/working_tree_diff_actions.go @@ -0,0 +1,44 @@ +package controllers + +import ( + "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +// WorkingTreeDiffActions implements what the files panel offers on the diff it renders +// into the focused main view: the diff itself, for the commands that need to read lines +// out of it rather than off the screen. +type WorkingTreeDiffActions struct { + c *ControllerCommon +} + +var _ types.FocusedMainViewDiffSource = &WorkingTreeDiffActions{} + +func NewWorkingTreeDiffActions(c *ControllerCommon) *WorkingTreeDiffActions { + return &WorkingTreeDiffActions{c: c} +} + +func (self *WorkingTreeDiffActions) context() *context.WorkingTreeContext { + return self.c.Contexts().Files +} + +// PlainDiff hands out the working tree's diff for the given files, taken from the +// side of the index that the asking pane shows. +func (self *WorkingTreeDiffActions) PlainDiff(pane types.DiffPaneContext, paths []string) string { + node := self.context().GetSelected() + if node == nil { + return "" + } + // An error means there is no diff to be had, which for our purposes is the same as + // an empty one. + diff, _ := self.c.Git().WorkingTree. + WorktreeFileDiffCmdObj(node, true, self.showsStagedSide(pane), paths). + RunWithOutput() + return diff +} + +// showsStagedSide reports whether the given main pane is the one showing the staged +// side of a file's diff, which is always the lower one. +func (self *WorkingTreeDiffActions) showsStagedSide(pane types.DiffPaneContext) bool { + return pane.GetKey() == self.c.Contexts().NormalSecondary.GetKey() +} diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 2201b66a8..2ed28dd17 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -106,6 +106,10 @@ type IBaseContext interface { // Adding on to the above, this is so that a list-specific handler can register // a hook for doing additional click handling AddOnClickFn(func(opts gocui.ViewMouseBindingOpts) error) + // Likewise for the focused main view, which acts on the diff of whichever panel + // is beneath it and so has to reach that panel's controller. nil for a panel + // that shows no diff. + AddFocusedMainViewDiffSource(FocusedMainViewDiffSource) AddOnRenderToMainFn(func()) AddOnFocusFn(func(OnFocusOpts)) @@ -220,6 +224,21 @@ type DiffPaneContext interface { DiffSelectState() *DiffSelectState } +// FocusedMainViewDiffSource is how a side panel hands out the diff behind what it +// renders into the focused main view: the diff of the given files as git writes it, +// with no colour and no diff renderer in the way. What the main view shows is that +// same diff after a renderer has had it, which may have restructured, reordered or +// dropped parts of it — so anything that needs the diff itself, rather than a picture +// of it, asks the panel that produced it. +// +// paths are repo-relative, and are asked for rather than assumed so that a few lines +// of a commit's diff can be had without fetching the whole thing. pane says which of +// the two main panes is asking, since a panel can show a different diff in each — the +// files panel shows the unstaged changes in one and the staged ones in the other. +type FocusedMainViewDiffSource interface { + PlainDiff(pane DiffPaneContext, paths []string) string +} + type IListContext interface { Context @@ -315,6 +334,11 @@ type HasKeybindings interface { // HandleFocus has already been called (so the main view is up to date). Should return nil if it // decides not to do anything with the click. GetOnClick() func(opts gocui.ViewMouseBindingOpts) error + + // Implement this in a side-panel controller to hand out the diff behind what your + // panel renders into the focused main view, for the commands that act on a + // selection in it. nil for a controller whose panel shows no diff. + GetFocusedMainViewDiffSource() FocusedMainViewDiffSource } type IController interface { diff --git a/pkg/gui/types/diff_line_info.go b/pkg/gui/types/diff_line_info.go index ae3d8eee9..6326837c2 100644 --- a/pkg/gui/types/diff_line_info.go +++ b/pkg/gui/types/diff_line_info.go @@ -40,3 +40,11 @@ type DiffLineInfo struct { func (self DiffLineInfo) IsChange() bool { return self.Type == DiffLineAdded || self.Type == DiffLineDeleted } + +// IsContent reports whether the row is a line of the file itself — a change or a +// context line — as opposed to a header or a marker. Those are the rows that have a +// position in the file, and so can be looked for in another rendering of the same +// diff, or in the diff itself. +func (self DiffLineInfo) IsContent() bool { + return self.IsChange() || self.Type == DiffLineContext +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index e54a52d5f..004ff4288 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -736,6 +736,8 @@ type TranslationSet struct { CopyPathToClipboard string CommitPrefixPatternError string CopySelectedTextToClipboard string + SelectedDiffLinesCopiedToast string + SelectionNotFoundInDiffToast string NoFilesStagedTitle string NoFilesStagedPrompt string BranchNotFoundTitle string @@ -1925,6 +1927,8 @@ func EnglishTranslationSet() *TranslationSet { CopyTagToClipboard: "Copy tag to clipboard", CopyPathToClipboard: "Copy path to clipboard", CopySelectedTextToClipboard: "Copy selected text to clipboard", + SelectedDiffLinesCopiedToast: "Selected diff lines copied to clipboard", + SelectionNotFoundInDiffToast: "Nothing in the selection could be found in the diff", CommitPrefixPatternError: "Error in commitPrefix pattern", NoFilesStagedTitle: "No files staged", NoFilesStagedPrompt: "You have not staged any files. Commit all files?", diff --git a/pkg/integration/tests/main_view/copy_rows_that_are_no_diff_line.go b/pkg/integration/tests/main_view/copy_rows_that_are_no_diff_line.go new file mode 100644 index 000000000..8a32405f3 --- /dev/null +++ b/pkg/integration/tests/main_view/copy_rows_that_are_no_diff_line.go @@ -0,0 +1,56 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CopyRowsThatAreNoDiffLine = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Copying rows that stand for no line of the diff says so rather than copying nothing", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false + cfg.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" + // A renderer that states which line of the file each row of its diff shows, and + // ends with a row of its own that shows none. It ignores its input and prints + // this one. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: `printf '\033]1717;1\007'; ` + + `printf '\033]1717;1;f;;;file1\007file1\n'; ` + + `printf '\033]1717;1;c;1;;file1\007 one\n'; ` + + `printf '\033]1717;1;d;2;2;file1\007-two\n'; ` + + `printf '\033]1717;1;a;2;;file1\007+TWO\n'; ` + + `printf '\033]1717;1;c;3;;file1\007 three\n'; ` + + `printf -- '--- that was the diff ---\n'; ` + + `cat >/dev/null`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\nTWO\nthree\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two"), + ). + SelectNextItem(). + SelectNextItem(). + SelectNextItem(). + SelectedLines( + Contains("that was the diff"), + ). + Press(keys.Universal.CopyToClipboard) + + t.ExpectToast(Equals("Nothing in the selection could be found in the diff")) + t.FileSystem().PathNotPresent("clipboard") + }, +}) diff --git a/pkg/integration/tests/main_view/copy_selected_diff_lines.go b/pkg/integration/tests/main_view/copy_selected_diff_lines.go new file mode 100644 index 000000000..812f4e8fe --- /dev/null +++ b/pkg/integration/tests/main_view/copy_selected_diff_lines.go @@ -0,0 +1,141 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// The clipboard is emulated by a file, so that this works on CI too. A copy says so +// with a toast, which the harness wants acknowledged before the next keypress. +func expectClipboard(t *TestDriver, matcher *TextMatcher) { + defer t.Shell().DeleteFile("clipboard") + + t.ExpectToast(Equals("Selected diff lines copied to clipboard")) + t.FileSystem().FileContent("clipboard", matcher) +} + +var CopySelectedDiffLines = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Copy the selected diff lines from the focused main view, as the diff reads rather than as the renderer drew it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = true + // Emulate the clipboard by writing to a file. + cfg.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" + // A renderer that decorates every line of a diff's body, so that what is on + // screen is not what the diff says. It reads the +/- column, so it wants its + // input uncoloured. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + { + Command: `awk '/^@@/ { body = 1 } body && /^[-+ ]/ { print $0 " <<<"; next } { print }'`, + ColorArg: "never", + }, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n") + shell.Commit("two") + + shell.UpdateFile("file1", "one\nTWO\nADD1\nADD2\nthree\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) + + // The renderer's decoration is nowhere in what was copied, and a selection that + // is all additions loses its '+' column, ready to be pasted into code. + expectClipboard(t, Equals("ADD1\nADD2\n")) + + // A selection reaching up into the hunk header takes the header with it, and what + // comes out is a patch fragment rather than lines of code, so the columns stay. + t.Views().Main(). + IsFocused(). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("+ADD1 <<<"), + ). + Press(keys.Universal.ToggleRangeSelect). + Press(keys.Universal.PrevItem). + Press(keys.Universal.PrevItem). + Press(keys.Universal.PrevItem). + SelectedLines( + Contains("@@ -1,3 +1,5 @@"), + Contains(" one <<<"), + Contains(" TWO <<<"), + Contains("+ADD1 <<<"), + ). + Press(keys.Universal.CopyToClipboard) + + expectClipboard(t, Equals("@@ -1,3 +1,5 @@\n one\n TWO\n+ADD1\n")) + + // A file header is taken whole, however much of it is selected: the rendering may + // show it as any number of rows, none of which is a line of the file. + t.Views().Main(). + IsFocused(). + Press(keys.Universal.PrevItem). + SelectedLines( + Contains("+++ b/file1"), + Contains("@@ -1,3 +1,5 @@"), + Contains(" one <<<"), + Contains(" TWO <<<"), + Contains("+ADD1 <<<"), + ). + Press(keys.Universal.CopyToClipboard) + + expectClipboard(t, MatchesRegexp( + `\Adiff --git a/file1 b/file1\nindex [0-9a-f]+\.\.[0-9a-f]+ 100644\n`+ + `--- a/file1\n\+\+\+ b/file1\n@@ -1,3 \+1,5 @@\n one\n TWO\n\+ADD1\n\z`)) + + // A commit's diff is copied the same way, through the panel that produced it. + t.Views().Commits(). + Focus(). + NavigateToLine(Contains("two")). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two <<<"), + Contains("+TWO <<<"), + ). + Press(keys.Universal.CopyToClipboard) + + // Both kinds of line are in the selection, so the columns stay: what comes out + // is the diff itself. + expectClipboard(t, Equals("-two\n+TWO\n")) + + // What a diff view shows above the diff is part of no file's diff, a commit's + // message among it, so a selection of those rows is copied as it stands on screen. + t.Views().Main(). + IsFocused(). + Press(keys.Main.ToggleSelectHunk). + NavigateToLine(Contains(" two")). + Press(keys.Universal.CopyToClipboard) + + expectClipboard(t, Equals(" two\n")) + + // A selection reaching from there into the diff takes the screen's rows down to + // the first file and the diff's own from there on. + t.Views().Main(). + IsFocused(). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("-two <<<")). + Press(keys.Universal.CopyToClipboard) + + expectClipboard(t, MatchesRegexp( + `\A {4}two\n---\n file1 \|[^\n]*\n 1 file changed[^\n]*\n\n`+ + `diff --git a/file1 b/file1\nindex [0-9a-f]+\.\.[0-9a-f]+ 100644\n`+ + `--- a/file1\n\+\+\+ b/file1\n@@ -1,3 \+1,3 @@\n one\n-two\n\z`)) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 8f06b2afe..3d3738e33 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -367,6 +367,8 @@ var tests = []*components.IntegrationTest{ interactive_rebase.SwapWithConflict, interactive_rebase.ViewFilesOfTodoEntries, main_view.ClickSelectsDiffLine, + main_view.CopyRowsThatAreNoDiffLine, + main_view.CopySelectedDiffLines, main_view.DragRangeWithAutoscroll, main_view.DragSelectsDiffLineRange, main_view.EditSelectedDiffLine,