1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-08-10 22:42:00 +02:00

Add a copy-to-clipboard menu to the file view (with diff copy options) (#3104)

This commit is contained in:
Stefan Haller
2023-12-07 08:35:28 +01:00
committed by GitHub
17 changed files with 363 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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>: 마지맛 커밋 수정

View File

@@ -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

View File

@@ -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

View File

@@ -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>: Правка последнего коммита

View File

@@ -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>: 修补最后一次提交

View File

@@ -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>: 修正上次提交

View File

@@ -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()
}

View File

@@ -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>",

View File

@@ -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)
}

View File

@@ -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 })
}

View File

@@ -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)
}

View File

@@ -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",

View File

@@ -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)"))
})
},
})

View File

@@ -117,6 +117,7 @@ var tests = []*components.IntegrationTest{
diff.DiffAndApplyPatch,
diff.DiffCommits,
diff.IgnoreWhitespace,
file.CopyMenu,
file.DirWithUntrackedFile,
file.DiscardAllDirChanges,
file.DiscardChanges,

View File

@@ -948,6 +948,10 @@
"openStatusFilter": {
"type": "string",
"default": "\u003cc-b\u003e"
},
"copyFileInfoToClipboard": {
"type": "string",
"default": "y"
}
},
"additionalProperties": false,