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,