From 224f8e7e50f2082ea9f3631f7dc98d2f25ea2773 Mon Sep 17 00:00:00 2001 From: tmwatchanan Date: Sat, 20 Jun 2026 18:43:09 +0700 Subject: [PATCH] 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. --- pkg/commands/oscommands/os.go | 12 ++ pkg/gui/command_log_panel.go | 122 ++++++++++++++++++ pkg/gui/command_log_panel_test.go | 89 +++++++++++++ pkg/gui/extras_panel.go | 53 +++++++- pkg/gui/gui_common.go | 8 ++ pkg/gui/types/common.go | 3 + pkg/i18n/english.go | 8 ++ .../misc/copy_git_output_to_clipboard.go | 45 +++++++ pkg/integration/tests/test_list.go | 1 + 9 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 pkg/gui/command_log_panel_test.go create mode 100644 pkg/integration/tests/misc/copy_git_output_to_clipboard.go diff --git a/pkg/commands/oscommands/os.go b/pkg/commands/oscommands/os.go index f1ee86c4c..a4212b78d 100644 --- a/pkg/commands/oscommands/os.go +++ b/pkg/commands/oscommands/os.go @@ -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), diff --git a/pkg/gui/command_log_panel.go b/pkg/gui/command_log_panel.go index 8f2e06b98..ee6b2ae52 100644 --- a/pkg/gui/command_log_panel.go +++ b/pkg/gui/command_log_panel.go @@ -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 diff --git a/pkg/gui/command_log_panel_test.go b/pkg/gui/command_log_panel_test.go new file mode 100644 index 000000000..6ab059930 --- /dev/null +++ b/pkg/gui/command_log_panel_test.go @@ -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 +} diff --git a/pkg/gui/extras_panel.go b/pkg/gui/extras_panel.go index 980c31d45..d4988b981 100644 --- a/pkg/gui/extras_panel.go +++ b/pkg/gui/extras_panel.go @@ -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. diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index c74a99a05..29057f9d4 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -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() +} diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index b81fb15e1..d81da5093 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -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) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 20d0d5ff6..346b10c54 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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", diff --git a/pkg/integration/tests/misc/copy_git_output_to_clipboard.go b/pkg/integration/tests/misc/copy_git_output_to_clipboard.go new file mode 100644 index 000000000..2dfc2208c --- /dev/null +++ b/pkg/integration/tests/misc/copy_git_output_to_clipboard.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 1b264e50d..7c55017ce 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -334,6 +334,7 @@ var tests = []*components.IntegrationTest{ interactive_rebase.ViewFilesOfTodoEntries, misc.ConfirmOnQuit, misc.CopyConfirmationMessageToClipboard, + misc.CopyGitOutputToClipboard, misc.CopyToClipboard, misc.DirenvApprovesEnvrc, misc.DirenvLoadedOnRepoSwitch,