diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index 2f0e1b547..1e5f98244 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 1ae89e5d6..70b81acca 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: "", ConfirmDiscard: "x", + CopyFileInfoToClipboard: "y", }, Branches: KeybindingBranchesConfig{ CopyPullRequestURL: "", diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 75fa52da4..733487d5a 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 abfdbafe6..d9b28d1ca 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 3c125bc7d..efb64f649 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 c940eea6f..a70d08ee7 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/pkg/integration/tests/file/copy_menu.go b/pkg/integration/tests/file/copy_menu.go new file mode 100644 index 000000000..f00425c96 --- /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 5716507f8..0aa61b463 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,