summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAzraelSec <me@azraelsec.sh>2023-11-02 23:31:38 +0100
committerStefan Haller <stefan@haller-berlin.de>2023-12-07 08:30:03 +0100
commitc7012528fce35802440342e0f9e69d5c84acbabb (patch)
tree41039fe4d290485fffbafbda9a593b06e77f04a3
parent2162e5ff64607856fe76b5f9e37612e415852a25 (diff)
feat: introduce a copy menu into the file view
-rw-r--r--pkg/commands/git_commands/diff.go23
-rw-r--r--pkg/config/user_config.go2
-rw-r--r--pkg/gui/controllers/files_controller.go103
-rw-r--r--pkg/gui/filetree/file_node.go9
-rw-r--r--pkg/gui/filetree/node.go6
-rw-r--r--pkg/i18n/english.go22
-rw-r--r--pkg/integration/tests/file/copy_menu.go185
-rw-r--r--pkg/integration/tests/test_list.go1
8 files changed, 351 insertions, 0 deletions
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: "<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 75fa52da4..733487d5a 100644
--- a/pkg/gui/controllers/files_controller.go
+++ b/pkg/gui/controllers/files_controller.go
@@ -38,6 +38,12 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
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,
Description: self.c.Tr.CommitChanges,
@@ -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,