Repository: jesseduffield/lazygit
Pull request #4341: Add a "Content of selected file" entry to the copy menu for commit files
Author: login: stefanhaller
Labels: name: enhancement
description: New feature or request
Created: 2025-02-28T08:02:10Z | Merged: 2025-03-20T10:54:49Z

- **PR Description**

Some people have the need to get at the file content of a file as it was in an earlier commit. They expect to press `e` in the commit files view and get the file opened in the state it was in at that commit, somehow. I explained a few times (e.g. in #3902, #4109, and #4327) that this is not how `e` should work; so if we want this as a feature, we'd need it as a separate command. However, it's a bit tricky to implement; if we check out the selected file to a temp file, it's unclear when to remove the file again. Alternatively we could use EditAndWait to open the file, then we would block until the user closes the editor and delete the file then. This is probably not the best user experience though, e.g. it wouldn't allow opening two files from an older commit at once.

Instead of finding solutions to all these questions, this PR takes a simpler route and adds a command to the "Copy to Clipboard" menu that allows copying the entire content of a file. Users can then open a new, empty editor buffer and paste it in, this is almost as good as opening a temp file, but without all the questions. This was suggested in #4327.

- **Please check if the PR fulfills these requirements**

* [x] Cheatsheets are up-to-date (run `go generate ./...`)
* [x] Code has been formatted (see [here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting))
* [x] Tests have been added/updated (see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md) for the integration test guide)
* [x] Text is internationalised (see [here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation))
* [ ] If a new UserConfig entry was added, make sure it can be hot-reloaded (see [here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig))
* [ ] Docs have been updated if necessary
* [x] You've read through your own file changes for silly mistakes etc



Diff:
diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go
index 52a65fb6dbd..46f07bdf150 100644
--- a/pkg/commands/git_commands/commit.go
+++ b/pkg/commands/git_commands/commit.go
@@ -279,6 +279,13 @@ func (self *CommitCommands) ShowCmdObj(hash string, filterPath string) oscommand
 	return self.cmd.New(cmdArgs).DontLog()
 }
 
+func (self *CommitCommands) ShowFileContentCmdObj(hash string, filePath string) oscommands.ICmdObj {
+	cmdArgs := NewGitCmd("show").
+		Arg(fmt.Sprintf("%s:%s", hash, filePath)).
+		ToArgv()
+	return self.cmd.New(cmdArgs).DontLog()
+}
+
 // Revert reverts the selected commit by hash
 func (self *CommitCommands) Revert(hash string) error {
 	cmdArgs := NewGitCmd("revert").Arg(hash).ToArgv()
diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go
index 09f8e54b0dd..69be8ae5de6 100644
--- a/pkg/gui/controllers/commits_files_controller.go
+++ b/pkg/gui/controllers/commits_files_controller.go
@@ -203,6 +203,16 @@ func (self *CommitFilesController) copyDiffToClipboard(path string, toastMessage
 	return nil
 }
 
+func (self *CommitFilesController) copyFileContentToClipboard(path string) error {
+	_, to := self.context().GetFromAndToForDiff()
+	cmdObj := self.c.Git().Commit.ShowFileContentCmdObj(to, path)
+	diff, err := cmdObj.RunWithOutput()
+	if err != nil {
+		return err
+	}
+	return self.c.OS().CopyToClipboard(diff)
+}
+
 func (self *CommitFilesController) openCopyMenu() error {
 	node := self.context().GetSelected()
 
@@ -246,6 +256,27 @@ func (self *CommitFilesController) openCopyMenu() error {
 		DisabledReason: self.require(self.itemsSelected())(),
 		Key:            'a',
 	}
+	copyFileContentItem := &types.MenuItem{
+		Label: self.c.Tr.CopyFileContent,
+		OnPress: func() error {
+			if err := self.copyFileContentToClipboard(node.GetPath()); err != nil {
+				return err
+			}
+			self.c.Toast(self.c.Tr.FileContentCopiedToast)
+			return nil
+		},
+		DisabledReason: self.require(self.singleItemSelected(
+			func(node *filetree.CommitFileNode) *types.DisabledReason {
+				if !node.IsFile() {
+					return &types.DisabledReason{
+						Text:             self.c.Tr.ErrCannotCopyContentOfDirectory,
+						ShowErrorInPanel: true,
+					}
+				}
+				return nil
+			}))(),
+		Key: 'c',
+	}
 
 	return self.c.Menu(types.CreateMenuOptions{
 		Title: self.c.Tr.CopyToClipboardMenu,
@@ -254,6 +285,7 @@ func (self *CommitFilesController) openCopyMenu() error {
 			copyPathItem,
 			copyFileDiffItem,
 			copyAllDiff,
+			copyFileContentItem,
 		},
 	})
 }
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index 977f6863ce9..ca46d750201 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -80,11 +80,13 @@ type TranslationSet struct {
 	CopyFileDiffTooltip                   string
 	CopySelectedDiff                      string
 	CopyAllFilesDiff                      string
+	CopyFileContent                       string
 	NoContentToCopyError                  string
 	FileNameCopiedToast                   string
 	FilePathCopiedToast                   string
 	FileDiffCopiedToast                   string
 	AllFilesDiffCopiedToast               string
+	FileContentCopiedToast                string
 	FilterStagedFiles                     string
 	FilterUnstagedFiles                   string
 	FilterTrackedFiles                    string
@@ -696,6 +698,7 @@ type TranslationSet struct {
 	PatchCopiedToClipboard                   string
 	CopiedToClipboard                        string
 	ErrCannotEditDirectory                   string
+	ErrCannotCopyContentOfDirectory          string
 	ErrStageDirWithInlineMergeConflicts      string
 	ErrRepositoryMovedOrDeleted              string
 	ErrWorktreeMovedOrRemoved                string
@@ -1120,11 +1123,13 @@ func EnglishTranslationSet() *TranslationSet {
 		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",
+		CopyFileContent:                      "Content of selected file",
 		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",
+		FileContentCopiedToast:               "File content copied to clipboard",
 		FilterStagedFiles:                    "Show only staged files",
 		FilterUnstagedFiles:                  "Show only unstaged files",
 		FilterTrackedFiles:                   "Show only tracked files",
@@ -1737,6 +1742,7 @@ func EnglishTranslationSet() *TranslationSet {
 		PatchCopiedToClipboard:                   "Patch copied to clipboard",
 		CopiedToClipboard:                        "copied to clipboard",
 		ErrCannotEditDirectory:                   "Cannot edit directories: you can only edit individual files",
+		ErrCannotCopyContentOfDirectory:          "Cannot copy content of directories: you can only copy content of individual files",
 		ErrStageDirWithInlineMergeConflicts:      "Cannot stage/unstage directory containing files with inline merge conflicts. Please fix up the merge conflicts first",
 		ErrRepositoryMovedOrDeleted:              "Cannot find repo. It might have been moved or deleted ¯\\_(ツ)_/¯",
 		CommandLog:                               "Command log",
diff --git a/pkg/integration/tests/diff/copy_to_clipboard.go b/pkg/integration/tests/diff/copy_to_clipboard.go
index 88c14cd4886..a1a6b4a9e91 100644
--- a/pkg/integration/tests/diff/copy_to_clipboard.go
+++ b/pkg/integration/tests/diff/copy_to_clipboard.go
@@ -23,17 +23,21 @@ var CopyToClipboard = NewIntegrationTest(NewIntegrationTestArgs{
 		shell.CreateDir("dir")
 		shell.CreateFileAndAdd("dir/file1", "1st line\n")
 		shell.Commit("1")
-		shell.CreateFileAndAdd("dir/file1", "1st line\n2nd line\n")
+		shell.UpdateFileAndAdd("dir/file1", "1st line\n2nd line\n")
 		shell.CreateFileAndAdd("dir/file2", "file2\n")
 		shell.Commit("2")
+		shell.UpdateFileAndAdd("dir/file1", "1st line\n2nd line\n3rd line\n")
+		shell.Commit("3")
 	},
 	Run: func(t *TestDriver, keys config.KeybindingConfig) {
 		t.Views().Commits().
 			Focus().
 			Lines(
-				Contains("2").IsSelected(),
+				Contains("3").IsSelected(),
+				Contains("2"),
 				Contains("1"),
 			).
+			SelectNextItem().
 			PressEnter()
 
 		t.Views().CommitFiles().
@@ -91,11 +95,22 @@ var CopyToClipboard = NewIntegrationTest(NewIntegrationTestArgs{
 							Contains("diff --git a/dir/file1 b/dir/file1").Contains("+2nd line").DoesNotContain("+1st line").
 								Contains("diff --git a/dir/file2 b/dir/file2").Contains("+file2"))
 					})
+			}).
+			Press(keys.Files.CopyFileInfoToClipboard).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Copy to clipboard")).
+					Select(Contains("Content of selected file")).
+					Confirm().
+					Tap(func() {
+						t.ExpectToast(Equals("File content copied to clipboard"))
+						expectClipboard(t, Equals("1st line\n2nd line\n"))
+					})
 			})
 
 		t.Views().Commits().
 			Focus().
-			// Select both commits
+			// Select commits 1 and 2
 			Press(keys.Universal.RangeSelectDown).
 			PressEnter()
 
@@ -118,6 +133,17 @@ var CopyToClipboard = NewIntegrationTest(NewIntegrationTestArgs{
 						expectClipboard(t,
 							Contains("diff --git a/dir/file1 b/dir/file1").Contains("+1st line").Contains("+2nd line"))
 					})
+			}).
+			Press(keys.Files.CopyFileInfoToClipboard).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Copy to clipboard")).
+					Select(Contains("Content of selected file")).
+					Confirm().
+					Tap(func() {
+						t.ExpectToast(Equals("File content copied to clipboard"))
+						expectClipboard(t, Equals("1st line\n2nd line\n"))
+					})
 			})
 	},
 })


Reviews:
user: login: ChrisMcD1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T03:06:35Z
user: login: stefanhaller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-08T12:43:11Z
user: login: jesseduffield
body: LGTM, I can see myself using this feature
state: APPROVED
author_association: OWNER
submitted_at: 2025-03-20T10:39:09Z

Review comments:
diff_hunk: @@ -203,6 +203,16 @@ func (self *CommitFilesController) copyDiffToClipboard(path string, toastMessage
 	return nil
 }
 
+func (self *CommitFilesController) copyFileContentToClipboard(path string) error {
+	_, to := self.context().GetFromAndToForDiff()
path: pkg/gui/controllers/commits_files_controller.go
user: login: ChrisMcD1
body: I was curious about this method returning the `From` ref, and discovered `RefForAdjustingLineNumberInDiff()` just returns the single target commit. Might be a preferable alternative
created_at: 2025-03-07T03:06:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -203,6 +203,16 @@ func (self *CommitFilesController) copyDiffToClipboard(path string, toastMessage
 	return nil
 }
 
+func (self *CommitFilesController) copyFileContentToClipboard(path string) error {
+	_, to := self.context().GetFromAndToForDiff()
path: pkg/gui/controllers/commits_files_controller.go
user: login: stefanhaller
body: I did consider this, but that function is for a very specific purpose (and therefore has a very specific name), so it feels wrong to use it here. I think it doesn't make a big difference either way; calling `GetFromAndToForDiff` and disregarding the from ref also seems clear enough to me.
created_at: 2025-03-08T12:43:11Z
author_association: COLLABORATOR

PR comments:
user: login: codacy-production[bot]
created_at: 2025-02-28T08:04:09Z
body: ## Coverage summary from Codacy
### [See diff coverage on Codacy](https://app.codacy.com/gh/jesseduffield/lazygit/pull-requests/4341/coverage?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryPullRequest)

 | Coverage variation | Diff coverage | 
 | ------------- | ------------- |
 | Report missing for 357c046df2234222630fadfbee9717c974c48618[^1] | :white_check_mark: **84.75%** |

<details>
<summary>Coverage variation details</summary>

| | Coverable lines | Covered lines | Coverage |
| ------------- | ------------- | ------------- | ------------- |
| Common ancestor commit (357c046df2234222630fadfbee9717c974c48618) | Report Missing | Report Missing | Report Missing | |
| Head commit (f7295a97c012097460809b743cee367d2c6d0474) | 53330 | 46194 | 86.62% |

**Coverage variation** is the difference between the coverage for the head and common ancestor commits of the pull request branch: `<coverage of head commit> - <coverage of common ancestor commit>`
</details>

<details>
<summary>Diff coverage details</summary>

| | Coverable lines | Covered lines | Diff coverage |
| ------------- | ------------- | ------------- | ------------- |
| Pull request (#4341) | 59 | 50 | **84.75%** |

**Diff coverage** is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: `<covered lines added or modified>/<coverable lines added or modified> * 100%`
</details>

#### [See your quality gate settings](https://app.codacy.com/gh/jesseduffield/lazygit/settings/quality?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryGates)&nbsp;&nbsp;&nbsp;&nbsp;[Change summary preferences](https://app.codacy.com/gh/jesseduffield/lazygit/settings/integrations?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummarySettings)
<sub>Codacy stopped sending the deprecated coverage status on June 5th, 2024. [Learn more](https://docs.codacy.com/release-notes/cloud/cloud-2023-11-23-new-coverage-engine-status-checks/#deprecation-and-removal-of-the-old-coverage-engine-status-checks)</sub>
[^1]: Codacy didn't receive coverage data for the commit, or there was an error processing the received data. [Check your integration for errors](https://app.codacy.com/gh/jesseduffield/lazygit/settings/coverage?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummarySetup) and [validate that your coverage setup is correct](https://docs.codacy.com/coverage-reporter/#validating-coverage?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryTroubleshoot).
<!-- 108f945d-e553-4918-8ccf-027c6d4acc2c -->
author_association: NONE
user: login: ChrisMcD1
created_at: 2025-03-07T03:07:58Z
body: The command log output here is not particularly helpful. It doesn't appear configurable here, but if it could show the file path and refname, that would make more sense? Probably doesn't matter.

![image](https://github.com/user-attachments/assets/83c45a59-f551-4ec3-b274-2b9a1e2f00c1)

author_association: CONTRIBUTOR
user: login: ChrisMcD1
created_at: 2025-03-07T03:11:50Z
body: > The command log output here is not particularly helpful. It doesn't appear configurable here, but if it could show the file path and refname, that would make more sense? Probably doesn't matter.

Yeah, that's not configurable. Okay!

https://github.com/jesseduffield/lazygit/blob/19ac926116863740e63cf7dabcb46845c8d41b91/pkg/commands/oscommands/os.go#L287-L297
author_association: CONTRIBUTOR