mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-13 09:06:27 -04:00
Allow copying streamed git output from the command log
Hook and push output is easy to read in lazygit but hard to reuse elsewhere. Parse git output blocks from the command log view so copies match what is shown, include the action and command above each block, and avoid logging clipboard operations back into the stream.
This commit is contained in:
parent
0b78438848
commit
224f8e7e50
|
|
@ -267,6 +267,15 @@ func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
|
|||
}
|
||||
|
||||
func (c *OSCommand) CopyToClipboard(str string) error {
|
||||
c.logCopyToClipboard(str)
|
||||
return c.writeToClipboard(str)
|
||||
}
|
||||
|
||||
func (c *OSCommand) CopyToClipboardQuiet(str string) error {
|
||||
return c.writeToClipboard(str)
|
||||
}
|
||||
|
||||
func (c *OSCommand) logCopyToClipboard(str string) {
|
||||
escaped := strings.ReplaceAll(str, "\n", "\\n")
|
||||
truncated := utils.TruncateWithEllipsis(escaped, 40)
|
||||
|
||||
|
|
@ -277,6 +286,9 @@ func (c *OSCommand) CopyToClipboard(str string) error {
|
|||
},
|
||||
)
|
||||
c.LogCommand(msg, false)
|
||||
}
|
||||
|
||||
func (c *OSCommand) writeToClipboard(str string) error {
|
||||
if c.UserConfig().OS.CopyToClipboardCmd != "" {
|
||||
cmdStr := utils.ResolvePlaceholderString(c.UserConfig().OS.CopyToClipboardCmd, map[string]string{
|
||||
"text": c.Cmd.Quote(str),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,128 @@ func (gui *Gui) LogAction(action string) {
|
|||
fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action))
|
||||
}
|
||||
|
||||
func (gui *Gui) gitOutputBlocksFromView() []string {
|
||||
if gui.Views.Extras == nil {
|
||||
return nil
|
||||
}
|
||||
return gitOutputBlocksFromCommandLogLines(gui.Views.Extras.BufferLines(), gui.c.Tr.GitOutput)
|
||||
}
|
||||
|
||||
func (gui *Gui) lastGitOutput() string {
|
||||
blocks := gui.gitOutputBlocksFromView()
|
||||
if len(blocks) == 0 {
|
||||
return ""
|
||||
}
|
||||
return blocks[len(blocks)-1]
|
||||
}
|
||||
|
||||
func (gui *Gui) allGitOutput() string {
|
||||
return strings.Join(gui.gitOutputBlocksFromView(), "\n\n")
|
||||
}
|
||||
|
||||
func (gui *Gui) hasGitOutput() bool {
|
||||
return gui.lastGitOutput() != ""
|
||||
}
|
||||
|
||||
func gitOutputBlocksFromCommandLogLines(lines []string, gitOutputHeader string) []string {
|
||||
var blocks []string
|
||||
|
||||
for i, line := range lines {
|
||||
if line != gitOutputHeader {
|
||||
continue
|
||||
}
|
||||
|
||||
block := commandLogEntryBeforeGitOutput(lines, i, gitOutputHeader)
|
||||
if len(block) > 0 {
|
||||
block = append(block, "")
|
||||
}
|
||||
block = append(block, gitOutputHeader)
|
||||
block = append(block, gitOutputLinesAfterHeader(lines, i+1, gitOutputHeader)...)
|
||||
|
||||
if trimmed := strings.TrimRight(strings.Join(block, "\n"), "\n"); trimmed != "" {
|
||||
blocks = append(blocks, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
func commandLogEntryBeforeGitOutput(lines []string, headerIdx int, gitOutputHeader string) []string {
|
||||
i := headerIdx - 1
|
||||
for i >= 0 && lines[i] == "" {
|
||||
i--
|
||||
}
|
||||
|
||||
var commands []string
|
||||
for i >= 0 && strings.HasPrefix(lines[i], " ") && !isCopyToClipboardLogLine(lines[i]) {
|
||||
commands = append([]string{lines[i]}, commands...)
|
||||
i--
|
||||
}
|
||||
|
||||
if len(commands) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i >= 0 && lines[i] == "" {
|
||||
i--
|
||||
}
|
||||
|
||||
var entry []string
|
||||
if i >= 0 && !strings.HasPrefix(lines[i], " ") && lines[i] != gitOutputHeader {
|
||||
entry = append(entry, lines[i])
|
||||
}
|
||||
entry = append(entry, commands...)
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func gitOutputLinesAfterHeader(lines []string, startIdx int, gitOutputHeader string) []string {
|
||||
output := make([]string, 0, len(lines)-startIdx)
|
||||
|
||||
for i := startIdx; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
if line == gitOutputHeader {
|
||||
break
|
||||
}
|
||||
if isCopyToClipboardLogLine(line) {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, " ") {
|
||||
continue
|
||||
}
|
||||
if isStartOfNewCommandLogEntry(lines, i) {
|
||||
break
|
||||
}
|
||||
output = append(output, line)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func isCopyToClipboardLogLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
return strings.HasPrefix(trimmed, "Copying '") && strings.HasSuffix(trimmed, "' to clipboard")
|
||||
}
|
||||
|
||||
func isStartOfNewCommandLogEntry(lines []string, i int) bool {
|
||||
line := lines[i]
|
||||
if line == "" || strings.HasPrefix(line, " ") {
|
||||
return false
|
||||
}
|
||||
|
||||
for j := i + 1; j < len(lines); j++ {
|
||||
if lines[j] == "" {
|
||||
continue
|
||||
}
|
||||
if isCopyToClipboardLogLine(lines[j]) {
|
||||
continue
|
||||
}
|
||||
return strings.HasPrefix(lines[j], " ")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (gui *Gui) LogCommand(cmdStr string, commandLine bool) {
|
||||
if gui.Views.Extras == nil {
|
||||
return
|
||||
|
|
|
|||
89
pkg/gui/command_log_panel_test.go
Normal file
89
pkg/gui/command_log_panel_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package gui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const gitOutputHeader = "Git output:"
|
||||
|
||||
func TestGitOutputBlocksFromCommandLogLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lines := []string{
|
||||
"Push",
|
||||
" git push",
|
||||
"",
|
||||
gitOutputHeader,
|
||||
"line1",
|
||||
"line2",
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"Push\n git push\n\nGit output:\nline1\nline2"}, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader))
|
||||
}
|
||||
|
||||
func TestGitOutputBlocksSkipCopyNotifications(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lines := []string{
|
||||
"Push",
|
||||
" git push",
|
||||
gitOutputHeader,
|
||||
"hook line",
|
||||
" Copying 'hook line' to clipboard",
|
||||
"hook line 2",
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"Push\n git push\n\nGit output:\nhook line\nhook line 2"}, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader))
|
||||
}
|
||||
|
||||
func TestGitOutputBlocksEndAtNextCommandLogEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lines := []string{
|
||||
"Push",
|
||||
" git push",
|
||||
gitOutputHeader,
|
||||
"first command output",
|
||||
"Stage file",
|
||||
" git add foo",
|
||||
"",
|
||||
gitOutputHeader,
|
||||
"second command output",
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{
|
||||
"Push\n git push\n\nGit output:\nfirst command output",
|
||||
"Stage file\n git add foo\n\nGit output:\nsecond command output",
|
||||
}, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader))
|
||||
}
|
||||
|
||||
func TestGitOutputBlocksMultipleBlocksJoined(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lines := []string{
|
||||
"Push",
|
||||
" git push",
|
||||
gitOutputHeader,
|
||||
"first command",
|
||||
"Pull",
|
||||
" git pull",
|
||||
gitOutputHeader,
|
||||
"second command",
|
||||
}
|
||||
|
||||
blocks := gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader)
|
||||
assert.Equal(t, "Push\n git push\n\nGit output:\nfirst command\n\nPull\n git pull\n\nGit output:\nsecond command", joinGitOutputBlocks(blocks))
|
||||
}
|
||||
|
||||
func joinGitOutputBlocks(blocks []string) string {
|
||||
result := ""
|
||||
for i, block := range blocks {
|
||||
if i > 0 {
|
||||
result += "\n\n"
|
||||
}
|
||||
result += block
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package gui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
|
|
@ -10,6 +11,13 @@ import (
|
|||
)
|
||||
|
||||
func (gui *Gui) handleCreateExtrasMenuPanel() error {
|
||||
noGitOutputDisabledReason := func() *types.DisabledReason {
|
||||
if gui.hasGitOutput() {
|
||||
return nil
|
||||
}
|
||||
return &types.DisabledReason{Text: gui.c.Tr.NoGitOutputToCopy}
|
||||
}
|
||||
|
||||
return gui.c.Menu(types.CreateMenuOptions{
|
||||
Title: gui.c.Tr.CommandLog,
|
||||
Items: []*types.MenuItem{
|
||||
|
|
@ -33,10 +41,50 @@ func (gui *Gui) handleCreateExtrasMenuPanel() error {
|
|||
Keys: []gocui.Key{gocui.NewKeyRune('f')},
|
||||
OnPress: gui.handleFocusCommandLog,
|
||||
},
|
||||
{
|
||||
Label: gui.c.Tr.CopyGitOutputToClipboard,
|
||||
Keys: []gocui.Key{gocui.NewKeyRune('c')},
|
||||
OnPress: gui.handleCopyLastGitOutputToClipboard,
|
||||
DisabledReason: noGitOutputDisabledReason(),
|
||||
},
|
||||
{
|
||||
Label: gui.c.Tr.CopyAllGitOutputToClipboard,
|
||||
Keys: []gocui.Key{gocui.NewKeyRune('a')},
|
||||
OnPress: gui.handleCopyAllGitOutputToClipboard,
|
||||
DisabledReason: noGitOutputDisabledReason(),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) handleCopyLastGitOutputToClipboard() error {
|
||||
output := gui.lastGitOutput()
|
||||
if output == "" {
|
||||
return errors.New(gui.c.Tr.NoGitOutputToCopy)
|
||||
}
|
||||
|
||||
if err := gui.os.CopyToClipboardQuiet(output); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gui.c.Toast(gui.c.Tr.GitOutputCopiedToClipboard)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) handleCopyAllGitOutputToClipboard() error {
|
||||
output := gui.allGitOutput()
|
||||
if output == "" {
|
||||
return errors.New(gui.c.Tr.NoGitOutputToCopy)
|
||||
}
|
||||
|
||||
if err := gui.os.CopyToClipboardQuiet(output); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gui.c.Toast(gui.c.Tr.GitOutputCopiedToClipboard)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) handleFocusCommandLog() error {
|
||||
gui.c.State().SetShowExtrasWindow(true)
|
||||
// TODO: is this necessary? Can't I just call 'return from context'?
|
||||
|
|
@ -94,7 +142,10 @@ func (gui *Gui) goToExtrasPanelBottom() error {
|
|||
}
|
||||
|
||||
func (gui *Gui) getCmdWriter() io.Writer {
|
||||
return &prefixWriter{writer: gui.Views.Extras, prefix: style.FgMagenta.Sprintf("\n\n%s\n", gui.c.Tr.GitOutput)}
|
||||
return &prefixWriter{
|
||||
writer: gui.Views.Extras,
|
||||
prefix: style.FgMagenta.Sprintf("\n\n%s\n", gui.c.Tr.GitOutput),
|
||||
}
|
||||
}
|
||||
|
||||
// Ensures that the first write is preceded by writing a prefix.
|
||||
|
|
|
|||
|
|
@ -189,3 +189,11 @@ func (self *guiCommon) WithInlineStatus(item types.HasUrn, operation types.ItemO
|
|||
self.gui.helpers.InlineStatus.WithInlineStatus(helpers.InlineStatusOpts{Item: item, Operation: operation, ContextKey: contextKey}, f)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *guiCommon) LastGitOutput() string {
|
||||
return self.gui.lastGitOutput()
|
||||
}
|
||||
|
||||
func (self *guiCommon) AllGitOutput() string {
|
||||
return self.gui.allGitOutput()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ type IGuiCommon interface {
|
|||
|
||||
ResetKeybindings() error
|
||||
|
||||
LastGitOutput() string
|
||||
AllGitOutput() string
|
||||
|
||||
// hopefully we can remove this once we've moved all our keybinding stuff out of the gui god struct.
|
||||
GetInitialKeybindingsWithCustomCommands() ([]*Binding, []*gocui.ViewMouseBinding)
|
||||
|
||||
|
|
|
|||
|
|
@ -768,6 +768,10 @@ type TranslationSet struct {
|
|||
CommandLog string
|
||||
ToggleShowCommandLog string
|
||||
FocusCommandLog string
|
||||
CopyGitOutputToClipboard string
|
||||
CopyAllGitOutputToClipboard string
|
||||
NoGitOutputToCopy string
|
||||
GitOutputCopiedToClipboard string
|
||||
CommandLogHeader string
|
||||
RandomTip string
|
||||
ToggleWhitespaceInDiffView string
|
||||
|
|
@ -1899,6 +1903,10 @@ func EnglishTranslationSet() *TranslationSet {
|
|||
ErrWorktreeMovedOrRemoved: "Cannot find worktree. It might have been moved or removed ¯\\_(ツ)_/¯",
|
||||
ToggleShowCommandLog: "Toggle show/hide command log",
|
||||
FocusCommandLog: "Focus command log",
|
||||
CopyGitOutputToClipboard: "Copy last git output to clipboard",
|
||||
CopyAllGitOutputToClipboard: "Copy all git outputs to clipboard",
|
||||
NoGitOutputToCopy: "No git output to copy",
|
||||
GitOutputCopiedToClipboard: "Git output copied to clipboard",
|
||||
CommandLogHeader: "You can hide/focus this panel by pressing '%s'\n",
|
||||
RandomTip: "Random tip",
|
||||
ToggleWhitespaceInDiffView: "Toggle whitespace",
|
||||
|
|
|
|||
45
pkg/integration/tests/misc/copy_git_output_to_clipboard.go
Normal file
45
pkg/integration/tests/misc/copy_git_output_to_clipboard.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package misc
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var CopyGitOutputToClipboard = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Copy streamed git output from the command log to the clipboard",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("one")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
|
||||
shell.SetBranchUpstream("master", "origin/master")
|
||||
|
||||
shell.EmptyCommit("two")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Press(keys.Universal.Push)
|
||||
|
||||
t.Views().Status().Content(Equals("✓ repo → master"))
|
||||
|
||||
t.GlobalPress(keys.Universal.ExtrasMenu)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Command log")).
|
||||
Select(Contains("Copy last git output to clipboard")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectToast(Equals("Git output copied to clipboard"))
|
||||
|
||||
t.FileSystem().FileContent("clipboard",
|
||||
Contains("master -> master").
|
||||
Contains("git push").
|
||||
Contains("Push"))
|
||||
},
|
||||
})
|
||||
|
|
@ -334,6 +334,7 @@ var tests = []*components.IntegrationTest{
|
|||
interactive_rebase.ViewFilesOfTodoEntries,
|
||||
misc.ConfirmOnQuit,
|
||||
misc.CopyConfirmationMessageToClipboard,
|
||||
misc.CopyGitOutputToClipboard,
|
||||
misc.CopyToClipboard,
|
||||
misc.DirenvApprovesEnvrc,
|
||||
misc.DirenvLoadedOnRepoSwitch,
|
||||
|
|
|
|||
Loading…
Reference in a new issue