Repository: jesseduffield/lazygit (language: go)

Issue:
Adding a command to copy current selected diff to clipboard
**Is your feature request related to a problem? Please describe.**
On some client projects i'm working on, I have no rights to push anything, but I may test fixes and enhancements, and suggest them to the lead. My current behavior for small diff is to `git diff folder_or_file | wl-copy` et paste it into a chat, or by mail (they don't use git mail), or on a specific app.

**Describe the solution you'd like**
It would be nice to have a shortcut to copy "what we are seeing on lazygit diff panel", which depends on what file/folder is selected on the left panel.

**Describe alternatives you've considered**
Writing selected diff into a file could be an alternative but I doubt this would be a easier solution, clipboard management being already implemented into lazygit.

**Additional context**
Here is a custom command that can do that, but is limited to the given clipboard manager and won't work between systems. Also it won't handle the context lines number currently in lazygit, I didn't find how to get it into a custom command. A more integrated command would be better I think.

```yaml
customCommands:
  - key: "X"
    command: "git diff {{ .SelectedPath }} | wl-copy"
    context: "files"
    description: "copy current diff to clipboard"
    subprocess: false
```

I would like to contribute, but I know nothing about Go and don't have much time to learn it, so if someone is motivated to make a PR of that, it could be great !


Discussion / hints:
While this would come in handy, I'm certain someone will come with "I want to send them a diff of _multiple_ files" so it might be better to implement it as "copy diff of all staged files" which encompasses this request as well.

@jesseduffield a menu for the `Files` panel for yanking stuff, like we have in the `Commits` panel in which we could also put the `Copy the file name to the clipboard` feature?
Yeah the common use case for me is wanting to copy the diff of all files, but I can see value in copying an individual file (and a set of files once we support multi-actions). We should add a menu behind the 'y' key which includes an option for copying the current file's diff to clipboard and all files.

EDIT: just realised that's pretty much what you just proposed @mark2185  haha
> and a set of files once we support multi-actions

Until then we could allow for copying only the staged files? `git` does offer `git diff --staged` and the user could create their own "set" by staging the desired files and unstaging them after copying the diff.

So, the menu could have (just an example):
```
n - copy the file name
d - copy the diff of all files
s - copy the diff of staged files
f - copy the diff of the current file
```
`f - copy the diff of the current file` could be `p - copy the diff of the current path`.

Not all projects work with a subdirectory `src` or `lib`, but on those cases, selecting the subfolder allow to get a global diff of the source code without selecting project files.
If I understood correctly, that would allow you to position your cursor on `a_subdirectory/` and copy its diff?

I.e. the same thing as `copy the diff of the current file`, except the "current file" is actually a directory?
That makes sense to me.

We might need to specify an option for staged/unstaged for all options: I don't think it's possible to just say 'get the diff of this file' without specifying staged/unstaged.
> 'get the diff of this file'

- `git diff` shows the unstaged changes
- `git diff --staged` shows the staged changes 
- `git diff HEAD` shows all the changes

The `git diff HEAD` is sufficient for selected path diff and all the diff (staged or not) will be selected.

For staged diff, I don't think the selected path is really useful, if you already have staged things, you have your selection and `copy the diff of staged files` would be enough.

Is unstaged changes copy (ie: we explicitly staged changes but we focus on unstaged ones) really interesting as an option ? If this is not to much work, why not.

IMO 2 options `copy the diff of staged changes` and `copy the diff of selected path` are enough, if we want to copy all the files we stage all with `a` and copy staged changes.
I'm joining the conversation (I was about to open a similar issue right before finding this one) 👋🏻

I agree with creating a copy-related menu for the files panel, but I'm struggling to imagine a situation where diffing all the **unstaged** files comes in handy. Even so, the implementation is straightforward, so I'm not against it.

P.S. If @mark2185 is not picking this up, I'd be up to work on this.
Sure, go nuts, I'm in no rush!

Fix patch:
diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md
index 1d29e2f16b0..5c7d2f1f97d 100644
--- a/docs/keybindings/Keybindings_en.md
+++ b/docs/keybindings/Keybindings_en.md
@@ -116,6 +116,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
   <kbd>d</kbd>: View 'discard changes' options
   <kbd>&lt;space&gt;</kbd>: Toggle staged
   <kbd>&lt;c-b&gt;</kbd>: Filter files by status
+  <kbd>y</kbd>: Copy to clipboard
   <kbd>c</kbd>: Commit changes
   <kbd>w</kbd>: Commit changes without pre-commit hook
   <kbd>A</kbd>: Amend last commit
diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md
index 29a9f5de6f8..2acbd3777cb 100644
--- a/docs/keybindings/Keybindings_ja.md
+++ b/docs/keybindings/Keybindings_ja.md
@@ -188,6 +188,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
   <kbd>d</kbd>: View 'discard changes' options
   <kbd>&lt;space&gt;</kbd>: ステージ/アンステージ
   <kbd>&lt;c-b&gt;</kbd>: ファイルをフィルタ (ステージ/アンステージ)
+  <kbd>y</kbd>: Copy to clipboard
   <kbd>c</kbd>: 変更をコミット
   <kbd>w</kbd>: pre-commitフックを実行せずに変更をコミット
   <kbd>A</kbd>: 最新のコミットにamend
diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md
index 025bb955938..68e3a25ed5a 100644
--- a/docs/keybindings/Keybindings_ko.md
+++ b/docs/keybindings/Keybindings_ko.md
@@ -325,6 +325,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
   <kbd>d</kbd>: View 'discard changes' options
   <kbd>&lt;space&gt;</kbd>: Staged 전환
   <kbd>&lt;c-b&gt;</kbd>: 파일을 필터하기 (Staged/unstaged)
+  <kbd>y</kbd>: Copy to clipboard
   <kbd>c</kbd>: 커밋 변경내용
   <kbd>w</kbd>: Commit changes without pre-commit hook
   <kbd>A</kbd>: 마지맛 커밋 수정
diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md
index 23d42e47726..a53ad13a875 100644
--- a/docs/keybindings/Keybindings_nl.md
+++ b/docs/keybindings/Keybindings_nl.md
@@ -51,6 +51,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
   <kbd>d</kbd>: Bekijk 'veranderingen ongedaan maken' opties
   <kbd>&lt;space&gt;</kbd>: Toggle staged
   <kbd>&lt;c-b&gt;</kbd>: Filter files by status
+  <kbd>y</kbd>: Copy to clipboard
   <kbd>c</kbd>: Commit veranderingen
   <kbd>w</kbd>: Commit veranderingen zonder pre-commit hook
   <kbd>A</kbd>: Wijzig laatste commit
diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md
index 1c1ce73aed2..9ffffade50a 100644
--- a/docs/keybindings/Keybindings_pl.md
+++ b/docs/keybindings/Keybindings_pl.md
@@ -150,6 +150,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
   <kbd>d</kbd>: Pokaż opcje porzucania zmian
   <kbd>&lt;space&gt;</kbd>: Przełącz stan poczekalni
   <kbd>&lt;c-b&gt;</kbd>: Filter files by status
+  <kbd>y</kbd>: Copy to clipboard
   <kbd>c</kbd>: Zatwierdź zmiany
   <kbd>w</kbd>: Zatwierdź zmiany bez skryptu pre-commit
   <kbd>A</kbd>: Zmień ostatni commit
diff --git a/docs/keybindings/Keybindings_ru.md b/docs/keybindings/Keybindings_ru.md
index 6e397d0a257..3296c8c1615 100644
--- a/docs/keybindings/Keybindings_ru.md
+++ b/docs/keybindings/Keybindings_ru.md
@@ -319,6 +319,7 @@ _Связки клавиш_
   <kbd>d</kbd>: Просмотреть параметры «отмены изменении»
   <kbd>&lt;space&gt;</kbd>: Переключить индекс
   <kbd>&lt;c-b&gt;</kbd>: Фильтровать файлы (проиндексированные/непроиндексированные)
+  <kbd>y</kbd>: Copy to clipboard
   <kbd>c</kbd>: Сохранить изменения
   <kbd>w</kbd>: Закоммитить изменения без предварительного хука коммита
   <kbd>A</kbd>: Правка последнего коммита
diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md
index ed232d70b65..3f9d060ca45 100644
--- a/docs/keybindings/Keybindings_zh-CN.md
+++ b/docs/keybindings/Keybindings_zh-CN.md
@@ -194,6 +194,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
   <kbd>d</kbd>: 查看'放弃更改'选项
   <kbd>&lt;space&gt;</kbd>: 切换暂存状态
   <kbd>&lt;c-b&gt;</kbd>: Filter files by status
+  <kbd>y</kbd>: Copy to clipboard
   <kbd>c</kbd>: 提交更改
   <kbd>w</kbd>: 提交更改而无需预先提交钩子
   <kbd>A</kbd>: 修补最后一次提交
diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md
index eff90a5c000..78fd756872f 100644
--- a/docs/keybindings/Keybindings_zh-TW.md
+++ b/docs/keybindings/Keybindings_zh-TW.md
@@ -289,6 +289,7 @@ _說明：`<c-b>` 表示 Ctrl+B、`<a-b>` 表示 Alt+B，`B`表示 Shift+B_
   <kbd>d</kbd>: 檢視“捨棄更改”的選項
   <kbd>&lt;space&gt;</kbd>: 切換預存
   <kbd>&lt;c-b&gt;</kbd>: 篩選檔案 (預存/未預存)
+  <kbd>y</kbd>: Copy to clipboard
   <kbd>c</kbd>: 提交變更
   <kbd>w</kbd>: 沒有預提交 hook 就提交更改
   <kbd>A</kbd>: 修正上次提交
diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go
index 2f0e1b54769..1e5f9824402 100644
--- a/pkg/commands/git_commands/diff.go
+++ b/pkg/commands/git_commands/diff.go
@@ -17,3 +17,26 @@ func (self *DiffCommands) DiffCmdObj(diffArgs []string) oscommands.ICmdObj {
 		NewGitCmd("diff").Arg("--submodule", "--no-ext-diff", "--color").Arg(diffArgs...).ToArgv(),
 	)
 }
+
+func (self *DiffCommands) internalDiffCmdObj(diffArgs ...string) *GitCommandBuilder {
+	return NewGitCmd("diff").
+		Arg("--no-ext-diff", "--no-color").
+		Arg(diffArgs...)
+}
+
+func (self *DiffCommands) GetPathDiff(path string, staged bool) (string, error) {
+	return self.cmd.New(
+		self.internalDiffCmdObj().
+			ArgIf(staged, "--staged").
+			Arg(path).
+			ToArgv(),
+	).RunWithOutput()
+}
+
+func (self *DiffCommands) GetAllDiff(staged bool) (string, error) {
+	return self.cmd.New(
+		self.internalDiffCmdObj().
+			ArgIf(staged, "--staged").
+			ToArgv(),
+	).RunWithOutput()
+}
diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go
index 1ae89e5d6f8..70b81accad7 100644
--- a/pkg/config/user_config.go
+++ b/pkg/config/user_config.go
@@ -371,6 +371,7 @@ type KeybindingFilesConfig struct {
 	ToggleTreeView           string `yaml:"toggleTreeView"`
 	OpenMergeTool            string `yaml:"openMergeTool"`
 	OpenStatusFilter         string `yaml:"openStatusFilter"`
+	CopyFileInfoToClipboard  string `yaml:"copyFileInfoToClipboard"`
 }
 
 type KeybindingBranchesConfig struct {
@@ -763,6 +764,7 @@ func GetDefaultConfig() *UserConfig {
 				OpenMergeTool:            "M",
 				OpenStatusFilter:         "<c-b>",
 				ConfirmDiscard:           "x",
+				CopyFileInfoToClipboard:  "y",
 			},
 			Branches: KeybindingBranchesConfig{
 				CopyPullRequestURL:     "<c-y>",
diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go
index 75fa52da47f..733487d5ad5 100644
--- a/pkg/gui/controllers/files_controller.go
+++ b/pkg/gui/controllers/files_controller.go
@@ -37,6 +37,12 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
 			Handler:     self.handleStatusFilterPressed,
 			Description: self.c.Tr.FileFilter,
 		},
+		{
+			Key:         opts.GetKey(opts.Config.Files.CopyFileInfoToClipboard),
+			Handler:     self.openCopyMenu,
+			Description: self.c.Tr.CopyToClipboardMenu,
+			OpensMenu:   true,
+		},
 		{
 			Key:         opts.GetKey(opts.Config.Files.CommitChanges),
 			Handler:     self.c.Helpers().WorkingTree.HandleCommitPress,
@@ -748,6 +754,103 @@ func (self *FilesController) createStashMenu() error {
 	})
 }
 
+func (self *FilesController) openCopyMenu() error {
+	node := self.context().GetSelected()
+
+	copyNameItem := &types.MenuItem{
+		Label: self.c.Tr.CopyFileName,
+		OnPress: func() error {
+			if err := self.c.OS().CopyToClipboard(node.Name()); err != nil {
+				return self.c.Error(err)
+			}
+			self.c.Toast(self.c.Tr.FileNameCopiedToast)
+			return nil
+		},
+		Key: 'n',
+	}
+	copyPathItem := &types.MenuItem{
+		Label: self.c.Tr.CopyFilePath,
+		OnPress: func() error {
+			if err := self.c.OS().CopyToClipboard(node.Path); err != nil {
+				return self.c.Error(err)
+			}
+			self.c.Toast(self.c.Tr.FilePathCopiedToast)
+			return nil
+		},
+		Key: 'p',
+	}
+	copyFileDiffItem := &types.MenuItem{
+		Label:   self.c.Tr.CopySelectedDiff,
+		Tooltip: self.c.Tr.CopyFileDiffTooltip,
+		OnPress: func() error {
+			path := self.context().GetSelectedPath()
+			hasStaged := self.hasPathStagedChanges(node)
+			diff, err := self.c.Git().Diff.GetPathDiff(path, hasStaged)
+			if err != nil {
+				return self.c.Error(err)
+			}
+			if err := self.c.OS().CopyToClipboard(diff); err != nil {
+				return self.c.Error(err)
+			}
+			self.c.Toast(self.c.Tr.FileDiffCopiedToast)
+			return nil
+		},
+		Key: 's',
+	}
+	copyAllDiff := &types.MenuItem{
+		Label:   self.c.Tr.CopyAllFilesDiff,
+		Tooltip: self.c.Tr.CopyFileDiffTooltip,
+		OnPress: func() error {
+			hasStaged := self.c.Helpers().WorkingTree.AnyStagedFiles()
+			diff, err := self.c.Git().Diff.GetAllDiff(hasStaged)
+			if err != nil {
+				return self.c.Error(err)
+			}
+			if err := self.c.OS().CopyToClipboard(diff); err != nil {
+				return self.c.Error(err)
+			}
+			self.c.Toast(self.c.Tr.AllFilesDiffCopiedToast)
+			return nil
+		},
+		Key: 'a',
+	}
+
+	if node == nil {
+		copyNameItem.DisabledReason = self.c.Tr.NoContentToCopyError
+		copyPathItem.DisabledReason = self.c.Tr.NoContentToCopyError
+		copyFileDiffItem.DisabledReason = self.c.Tr.NoContentToCopyError
+	}
+	if node != nil && !node.GetHasStagedOrTrackedChanges() {
+		copyFileDiffItem.DisabledReason = self.c.Tr.NoContentToCopyError
+	}
+	if !self.anyStagedOrTrackedFile() {
+		copyAllDiff.DisabledReason = self.c.Tr.NoContentToCopyError
+	}
+
+	return self.c.Menu(types.CreateMenuOptions{
+		Title: self.c.Tr.CopyToClipboardMenu,
+		Items: []*types.MenuItem{
+			copyNameItem,
+			copyPathItem,
+			copyFileDiffItem,
+			copyAllDiff,
+		},
+	})
+}
+
+func (self *FilesController) anyStagedOrTrackedFile() bool {
+	if !self.c.Helpers().WorkingTree.AnyStagedFiles() {
+		return self.c.Helpers().WorkingTree.AnyTrackedFiles()
+	}
+	return true
+}
+
+func (self *FilesController) hasPathStagedChanges(node *filetree.FileNode) bool {
+	return node.SomeFile(func(t *models.File) bool {
+		return t.HasStagedChanges
+	})
+}
+
 func (self *FilesController) stash() error {
 	return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashAllChanges)
 }
diff --git a/pkg/gui/filetree/file_node.go b/pkg/gui/filetree/file_node.go
index abfdbafe6c1..d9b28d1ca6d 100644
--- a/pkg/gui/filetree/file_node.go
+++ b/pkg/gui/filetree/file_node.go
@@ -30,6 +30,15 @@ func (self *FileNode) GetHasUnstagedChanges() bool {
 	return self.SomeFile(func(file *models.File) bool { return file.HasUnstagedChanges })
 }
 
+func (self *FileNode) GetHasStagedOrTrackedChanges() bool {
+	if !self.GetHasStagedChanges() {
+		return self.SomeFile(func(t *models.File) bool {
+			return t.Tracked
+		})
+	}
+	return true
+}
+
 func (self *FileNode) GetHasStagedChanges() bool {
 	return self.SomeFile(func(file *models.File) bool { return file.HasStagedChanges })
 }
diff --git a/pkg/gui/filetree/node.go b/pkg/gui/filetree/node.go
index 3c125bc7d84..efb64f649cd 100644
--- a/pkg/gui/filetree/node.go
+++ b/pkg/gui/filetree/node.go
@@ -1,6 +1,8 @@
 package filetree
 
 import (
+	"path"
+
 	"github.com/jesseduffield/lazygit/pkg/commands/models"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
 	"github.com/samber/lo"
@@ -300,3 +302,7 @@ func (self *Node[T]) ID() string {
 func (self *Node[T]) Description() string {
 	return self.GetPath()
 }
+
+func (self *Node[T]) Name() string {
+	return path.Base(self.Path)
+}
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index c940eea6fd6..a70d08ee75d 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -52,6 +52,17 @@ type TranslationSet struct {
 	Pull                                string
 	Scroll                              string
 	FileFilter                          string
+	CopyToClipboardMenu                 string
+	CopyFileName                        string
+	CopyFilePath                        string
+	CopyFileDiffTooltip                 string
+	CopySelectedDiff                    string
+	CopyAllFilesDiff                    string
+	NoContentToCopyError                string
+	FileNameCopiedToast                 string
+	FilePathCopiedToast                 string
+	FileDiffCopiedToast                 string
+	AllFilesDiffCopiedToast             string
 	FilterStagedFiles                   string
 	FilterUnstagedFiles                 string
 	ResetFilter                         string
@@ -851,6 +862,17 @@ func EnglishTranslationSet() TranslationSet {
 		CantCheckoutBranchWhilePulling:      "You cannot checkout another branch while pulling the current branch",
 		CantPullOrPushSameBranchTwice:       "You cannot push or pull a branch while it is already being pushed or pulled",
 		FileFilter:                          "Filter files by status",
+		CopyToClipboardMenu:                 "Copy to clipboard",
+		CopyFileName:                        "File name",
+		CopyFilePath:                        "Path",
+		CopyFileDiffTooltip:                 "If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.",
+		CopySelectedDiff:                    "Diff of selected file",
+		CopyAllFilesDiff:                    "Diff of all files",
+		NoContentToCopyError:                "Nothing to copy",
+		FileNameCopiedToast:                 "File name copied to clipboard",
+		FilePathCopiedToast:                 "File path copied to clipboard",
+		FileDiffCopiedToast:                 "File diff copied to clipboard",
+		AllFilesDiffCopiedToast:             "All files diff copied to clipboard",
 		FilterStagedFiles:                   "Show only staged files",
 		FilterUnstagedFiles:                 "Show only unstaged files",
 		ResetFilter:                         "Reset filter",
diff --git a/schema/config.json b/schema/config.json
index 2f1dba309db..51b7439cb62 100644
--- a/schema/config.json
+++ b/schema/config.json
@@ -948,6 +948,10 @@
             "openStatusFilter": {
               "type": "string",
               "default": "\u003cc-b\u003e"
+            },
+            "copyFileInfoToClipboard": {
+              "type": "string",
+              "default": "y"
             }
           },
           "additionalProperties": false,


Test patch:
diff --git a/pkg/integration/tests/file/copy_menu.go b/pkg/integration/tests/file/copy_menu.go
new file mode 100644
index 00000000000..f00425c967f
--- /dev/null
+++ b/pkg/integration/tests/file/copy_menu.go
@@ -0,0 +1,185 @@
+package file
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+// note: this is required to simulate the clipboard during CI
+func expectClipboard(t *TestDriver, matcher *TextMatcher) {
+	defer t.Shell().DeleteFile("clipboard")
+
+	t.FileSystem().FileContent("clipboard", matcher)
+}
+
+var CopyMenu = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "The copy menu allows to copy name and diff of selected/all files",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig: func(config *config.AppConfig) {
+		config.UserConfig.OS.CopyToClipboardCmd = "echo {{text}} > clipboard"
+	},
+	SetupRepo: func(shell *Shell) {},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		// Disabled item
+		t.Views().Files().
+			IsEmpty().
+			Press(keys.Files.CopyFileInfoToClipboard).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Copy to clipboard")).
+					Select(Contains("File name")).
+					Tooltip(Equals("Disabled: Nothing to copy")).
+					Confirm()
+
+				t.ExpectPopup().Alert().
+					Title(Equals("Error")).
+					Content(Equals("Nothing to copy")).
+					Confirm()
+			})
+
+		t.Shell().
+			CreateDir("dir").
+			CreateFile("dir/1-unstaged_file", "unstaged content")
+
+		// Empty content (new file)
+		t.Views().Files().
+			Press(keys.Universal.Refresh).
+			Lines(
+				Contains("dir").IsSelected(),
+				Contains("unstaged_file"),
+			).
+			SelectNextItem().
+			Press(keys.Files.CopyFileInfoToClipboard).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Copy to clipboard")).
+					Select(Contains("Diff of selected file")).
+					Tooltip(Contains("Disabled: Nothing to copy")).
+					Confirm()
+
+				t.ExpectPopup().Alert().
+					Title(Equals("Error")).
+					Content(Equals("Nothing to copy")).
+					Confirm()
+			}).
+			Press(keys.Files.CopyFileInfoToClipboard).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Copy to clipboard")).
+					Select(Contains("Diff of all files")).
+					Tooltip(Contains("Disabled: Nothing to copy")).
+					Confirm()
+
+				t.ExpectPopup().Alert().
+					Title(Equals("Error")).
+					Content(Equals("Nothing to copy")).
+					Confirm()
+			})
+
+		t.Shell().
+			GitAdd("dir/1-unstaged_file").
+			Commit("commit-unstaged").
+			UpdateFile("dir/1-unstaged_file", "unstaged content (new)").
+			CreateFileAndAdd("dir/2-staged_file", "staged content").
+			Commit("commit-staged").
+			UpdateFile("dir/2-staged_file", "staged content (new)").
+			GitAdd("dir/2-staged_file")
+
+		// Copy file name
+		t.Views().Files().
+			Press(keys.Universal.Refresh).
+			Lines(
+				Contains("dir"),
+				Contains("unstaged_file").IsSelected(),
+				Contains("staged_file"),
+			).
+			Press(keys.Files.CopyFileInfoToClipboard).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Copy to clipboard")).
+					Select(Contains("File name")).
+					Confirm()
+
+				expectClipboard(t, Contains("unstaged_file"))
+			})
+
+		// Copy file path
+		t.Views().Files().
+			Press(keys.Files.CopyFileInfoToClipboard).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Copy to clipboard")).
+					Select(Contains("Path")).
+					Confirm()
+
+				expectClipboard(t, Contains("dir/1-unstaged_file"))
+			})
+
+		// Selected path diff on a single (unstaged) file
+		t.Views().Files().
+			Press(keys.Files.CopyFileInfoToClipboard).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Copy to clipboard")).
+					Select(Contains("Diff of selected file")).
+					Tooltip(Equals("If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.")).
+					Confirm()
+
+				expectClipboard(t, Contains("+unstaged content (new)"))
+			})
+
+		// Selected path diff with staged and unstaged files
+		t.Views().Files().
+			SelectPreviousItem().
+			Lines(
+				Contains("dir").IsSelected(),
+				Contains("unstaged_file"),
+				Contains("staged_file"),
+			).
+			Press(keys.Files.CopyFileInfoToClipboard).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Copy to clipboard")).
+					Select(Contains("Diff of selected file")).
+					Tooltip(Equals("If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.")).
+					Confirm()
+
+				expectClipboard(t, Contains("+staged content (new)"))
+			})
+
+		// All files diff with staged files
+		t.Views().Files().
+			Press(keys.Files.CopyFileInfoToClipboard).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Copy to clipboard")).
+					Select(Contains("Diff of all files")).
+					Tooltip(Equals("If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.")).
+					Confirm()
+
+				expectClipboard(t, Contains("+staged content (new)"))
+			})
+
+		// All files diff with no staged files
+		t.Views().Files().
+			SelectNextItem().
+			SelectNextItem().
+			Lines(
+				Contains("dir"),
+				Contains("unstaged_file"),
+				Contains("staged_file").IsSelected(),
+			).
+			Press(keys.Universal.Select).
+			Press(keys.Files.CopyFileInfoToClipboard).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Copy to clipboard")).
+					Select(Contains("Diff of all files")).
+					Tooltip(Equals("If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.")).
+					Confirm()
+
+				expectClipboard(t, Contains("+staged content (new)").Contains("+unstaged content (new)"))
+			})
+	},
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index 5716507f846..0aa61b463bf 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -117,6 +117,7 @@ var tests = []*components.IntegrationTest{
 	diff.DiffAndApplyPatch,
 	diff.DiffCommits,
 	diff.IgnoreWhitespace,
+	file.CopyMenu,
 	file.DirWithUntrackedFile,
 	file.DiscardAllDirChanges,
 	file.DiscardChanges,