This commit is contained in:
Watchanan Chantapakul 2026-09-01 23:47:00 -07:00 committed by GitHub
commit db93acb923
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 614 additions and 10 deletions

View file

@ -277,6 +277,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)
@ -287,6 +296,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),

View file

@ -43,6 +43,190 @@ func (gui *Gui) LogAction(action string) {
})
}
func (gui *Gui) gitOutputBlocksFromView() []string {
if gui.Views.Extras == nil {
return nil
}
return gitOutputBlocksFromCommandLogLines(
gui.Views.Extras.BufferLines(),
gui.c.Tr.GitOutput,
gui.isCopyToClipboardLogLine,
)
}
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 (gui *Gui) hasCommandLogEntries() bool {
return gui.commandLogContent() != ""
}
func (gui *Gui) commandLogContent() string {
if gui.Views.Extras == nil {
return ""
}
introLine := gui.commandLogIntroLine()
var filtered []string
for _, line := range gui.Views.Extras.BufferLines() {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
if len(filtered) > 0 {
filtered = append(filtered, "")
}
continue
}
if trimmed == introLine {
continue
}
if strings.HasPrefix(trimmed, gui.c.Tr.RandomTip+":") {
continue
}
if gui.isCopyToClipboardLogLine(line) {
continue
}
if gui.isCreateFileLogLine(line) {
continue
}
filtered = append(filtered, line)
}
return strings.TrimRight(strings.Join(filtered, "\n"), "\n")
}
func (gui *Gui) commandLogIntroLine() string {
return strings.TrimSpace(fmt.Sprintf(
gui.c.Tr.CommandLogHeader,
gui.c.UserConfig().Keybinding.Universal.ExtrasMenu,
))
}
func (gui *Gui) isCopyToClipboardLogLine(line string) bool {
return logLineMatchesTemplate(line, gui.c.Tr.Log.CopyToClipboard, "{{.str}}")
}
func (gui *Gui) isCreateFileLogLine(line string) bool {
return logLineMatchesTemplate(line, gui.c.Tr.Log.CreateFileWithContent, "{{.path}}")
}
func logLineMatchesTemplate(line string, template string, placeholder string) bool {
parts := strings.Split(template, placeholder)
if len(parts) != 2 {
return false
}
trimmed := strings.TrimSpace(line)
return strings.HasPrefix(trimmed, parts[0]) && strings.HasSuffix(trimmed, parts[1])
}
func gitOutputBlocksFromCommandLogLines(lines []string, gitOutputHeader string, isCopyToClipboardLogLine func(string) bool) []string {
var blocks []string
for i, line := range lines {
if line != gitOutputHeader {
continue
}
block := commandLogEntryBeforeGitOutput(lines, i, gitOutputHeader, isCopyToClipboardLogLine)
if len(block) > 0 {
block = append(block, "")
}
block = append(block, gitOutputHeader)
block = append(block, gitOutputLinesAfterHeader(lines, i+1, gitOutputHeader, isCopyToClipboardLogLine)...)
if trimmed := strings.TrimRight(strings.Join(block, "\n"), "\n"); trimmed != "" {
blocks = append(blocks, trimmed)
}
}
return blocks
}
func commandLogEntryBeforeGitOutput(lines []string, headerIdx int, gitOutputHeader string, isCopyToClipboardLogLine func(string) bool) []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, isCopyToClipboardLogLine func(string) bool) []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 isStartOfNewCommandLogEntry(lines, i, isCopyToClipboardLogLine) {
break
}
output = append(output, line)
}
return output
}
func isStartOfNewCommandLogEntry(lines []string, i int, isCopyToClipboardLogLine func(string) bool) 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 isLazygitCommandLogLine(lines[j], isCopyToClipboardLogLine)
}
return false
}
func isLazygitCommandLogLine(line string, isCopyToClipboardLogLine func(string) bool) bool {
return isCopyToClipboardLogLine(line) || strings.HasPrefix(line, " git ")
}
func (gui *Gui) LogCommand(cmdStr string, commandLine bool) {
if gui.Views.Extras == nil {
return

View file

@ -0,0 +1,140 @@
package gui
import (
"testing"
"github.com/stretchr/testify/assert"
)
const gitOutputHeader = "Git output:"
func englishCopyToClipboardLogLineMatcher() func(string) bool {
return func(line string) bool {
return logLineMatchesTemplate(line, "Copying '{{.str}}' to clipboard", "{{.str}}")
}
}
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, englishCopyToClipboardLogLineMatcher()))
}
func TestGitOutputBlocksIncludeIndentedStderr(t *testing.T) {
t.Parallel()
lines := []string{
"Push",
" git push",
gitOutputHeader,
" at foo.go:10",
" at bar.go:20",
"hook failed",
}
assert.Equal(t, []string{"Push\n git push\n\nGit output:\n at foo.go:10\n at bar.go:20\nhook failed"}, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader, englishCopyToClipboardLogLineMatcher()))
}
func TestGitOutputBlocksIncludeToolErrorWithIndentedContext(t *testing.T) {
t.Parallel()
lines := []string{
"Push",
" git push",
gitOutputHeader,
"Error: validation failed",
" line 42: syntax error",
"more output",
"Stage file",
" git add foo",
gitOutputHeader,
"second command output",
}
assert.Equal(t, []string{
"Push\n git push\n\nGit output:\nError: validation failed\n line 42: syntax error\nmore output",
"Stage file\n git add foo\n\nGit output:\nsecond command output",
}, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader, englishCopyToClipboardLogLineMatcher()))
}
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, englishCopyToClipboardLogLineMatcher()))
}
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, englishCopyToClipboardLogLineMatcher()))
}
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, englishCopyToClipboardLogLineMatcher())
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 TestLogLineMatchesCopyToClipboardTemplate(t *testing.T) {
t.Parallel()
matcher := englishCopyToClipboardLogLineMatcher()
assert.True(t, matcher(" Copying 'hook line' to clipboard"))
assert.False(t, matcher("Push"))
}
func joinGitOutputBlocks(blocks []string) string {
result := ""
for i, block := range blocks {
if i > 0 {
result += "\n\n"
}
result += block
}
return result
}

View file

@ -173,7 +173,7 @@ func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string {
return lo.Map(menuItems, func(item *types.MenuItem, _ int) []string {
displayStrings := item.LabelColumns
if item.DisabledReason != nil {
if item.DisabledReasonAtUse() != nil {
displayStrings[0] = style.FgDefault.SetStrikethrough().Sprint(displayStrings[0])
}
@ -282,13 +282,15 @@ func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Bin
}
func (self *MenuContext) OnMenuPress(selectedItem *types.MenuItem) error {
if selectedItem != nil && selectedItem.DisabledReason != nil {
if selectedItem.DisabledReason.ShowErrorInPanel {
return errors.New(selectedItem.DisabledReason.Text)
}
if selectedItem != nil {
if disabledReason := selectedItem.DisabledReasonAtUse(); disabledReason != nil {
if disabledReason.ShowErrorInPanel {
return errors.New(disabledReason.Text)
}
self.c.ErrorToast(self.c.Tr.DisabledMenuItemPrefix + selectedItem.DisabledReason.Text)
return nil
self.c.ErrorToast(self.c.Tr.DisabledMenuItemPrefix + disabledReason.Text)
return nil
}
}
self.c.Context().Pop()

View file

@ -488,11 +488,11 @@ func (self *ConfirmationHelper) IsPopupPanelFocused() bool {
func (self *ConfirmationHelper) TooltipForMenuItem(menuItem *types.MenuItem) string {
tooltip := menuItem.Tooltip
if menuItem.DisabledReason != nil && menuItem.DisabledReason.Text != "" {
if disabledReason := menuItem.DisabledReasonAtUse(); disabledReason != nil && disabledReason.Text != "" {
if tooltip != "" {
tooltip += "\n\n"
}
tooltip += style.FgRed.Sprintf(self.c.Tr.DisabledMenuItemPrefix) + menuItem.DisabledReason.Text
tooltip += style.FgRed.Sprintf(self.c.Tr.DisabledMenuItemPrefix) + disabledReason.Text
}
return tooltip
}

View file

@ -1,7 +1,10 @@
package gui
import (
"errors"
"io"
"path/filepath"
"time"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
@ -10,6 +13,20 @@ import (
)
func (gui *Gui) handleCreateExtrasMenuPanel() error {
noGitOutputDisabledReason := func() *types.DisabledReason {
if gui.hasGitOutput() {
return nil
}
return &types.DisabledReason{Text: gui.c.Tr.NoGitOutputToCopy}
}
noCommandLogDisabledReason := func() *types.DisabledReason {
if gui.hasCommandLogEntries() {
return nil
}
return &types.DisabledReason{Text: gui.c.Tr.NoCommandLogToOpenInEditor}
}
return gui.c.Menu(types.CreateMenuOptions{
Title: gui.c.Tr.CommandLog,
Items: []*types.MenuItem{
@ -33,10 +50,79 @@ 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,
GetDisabledReason: noGitOutputDisabledReason,
},
{
Label: gui.c.Tr.CopyAllGitOutputToClipboard,
Keys: []gocui.Key{gocui.NewKeyRune('a')},
OnPress: gui.handleCopyAllGitOutputToClipboard,
GetDisabledReason: noGitOutputDisabledReason,
},
{
Label: gui.c.Tr.OpenCommandLogInEditor,
Keys: []gocui.Key{gocui.NewKeyRune('o')},
OnPress: gui.handleOpenCommandLogInEditor,
GetDisabledReason: noCommandLogDisabledReason,
},
},
})
}
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) handleOpenCommandLogInEditor() error {
content := gui.commandLogContent()
if content == "" {
return errors.New(gui.c.Tr.NoCommandLogToOpenInEditor)
}
filepath := filepath.Join(
gui.os.GetTempDir(),
gui.c.Git().RepoPaths.RepoName(),
time.Now().Format("Jan _2 15.04.05.000000000")+"-command-log.txt",
)
if err := gui.os.CreateFileWithContent(filepath, content); err != nil {
return err
}
if err := gui.Helpers().Files.EditFiles([]string{filepath}); err != nil {
return err
}
gui.c.Toast(gui.c.Tr.CommandLogOpenedInEditor)
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 +180,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.

View file

@ -229,3 +229,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()
}

View file

@ -147,6 +147,9 @@ type IGuiCommon interface {
ResetKeybindings()
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)
@ -333,6 +336,10 @@ type MenuItem struct {
// and refuse to invoke the command
DisabledReason *DisabledReason
// If non-nil, evaluated when rendering and invoking the menu item. Takes
// precedence over DisabledReason when set.
GetDisabledReason func() *DisabledReason
// Can be used to group menu items into sections with headers. MenuItems
// with the same Section should be contiguous, and will automatically get a
// section header. If nil, the item is not part of a section.
@ -348,6 +355,14 @@ func (self *MenuItem) ID() string {
return self.Label
}
func (self *MenuItem) DisabledReasonAtUse() *DisabledReason {
if self.GetDisabledReason != nil {
return self.GetDisabledReason()
}
return self.DisabledReason
}
type Model struct {
CommitFiles []*models.CommitFile
Files []*models.File

View file

@ -787,6 +787,13 @@ type TranslationSet struct {
CommandLog string
ToggleShowCommandLog string
FocusCommandLog string
CopyGitOutputToClipboard string
CopyAllGitOutputToClipboard string
NoGitOutputToCopy string
GitOutputCopiedToClipboard string
OpenCommandLogInEditor string
NoCommandLogToOpenInEditor string
CommandLogOpenedInEditor string
CommandLogHeader string
RandomTip string
ToggleWhitespaceInDiffView string
@ -1949,6 +1956,13 @@ func EnglishTranslationSet() *TranslationSet {
CantSwitchWhileOperationInProgress: "Can't switch repositories while an operation is in progress",
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",
OpenCommandLogInEditor: "Open command log in editor",
NoCommandLogToOpenInEditor: "No command log to open in editor",
CommandLogOpenedInEditor: "Command log opened in editor",
CommandLogHeader: "You can hide/focus this panel by pressing '%s'\n",
RandomTip: "Random tip",
ToggleWhitespaceInDiffView: "Toggle whitespace",

View file

@ -0,0 +1,45 @@
package misc
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var CopyAllGitOutputToClipboard = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Copy all streamed git outputs 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 all git outputs to clipboard")).
Confirm()
t.ExpectToast(Equals("Git output copied to clipboard"))
t.FileSystem().FileContent("clipboard",
Contains("master -> master").
Contains("git push").
Contains("Push"))
},
})

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

View file

@ -0,0 +1,47 @@
package misc
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var OpenCommandLogInEditor = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Open the full command log in the user's editor",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().OS.Edit = "cp {{filename}} editor-output.txt"
config.GetUserConfig().Gui.ShowRandomTip = false
},
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("Open command log in editor")).
Confirm()
t.ExpectToast(Equals("Command log opened in editor"))
t.FileSystem().FileContent("editor-output.txt",
Contains("Push").
Contains("git push").
Contains("master -> master").
DoesNotContain("You can hide/focus"))
},
})

View file

@ -355,13 +355,16 @@ var tests = []*components.IntegrationTest{
interactive_rebase.SwapWithConflict,
interactive_rebase.ViewFilesOfTodoEntries,
misc.ConfirmOnQuit,
misc.CopyAllGitOutputToClipboard,
misc.CopyConfirmationMessageToClipboard,
misc.CopyGitOutputToClipboard,
misc.CopyToClipboard,
misc.DirenvApprovesEnvrc,
misc.DirenvLoadedOnRepoSwitch,
misc.DirenvUnloadsOnBlockedEnvrc,
misc.FilterRecentRepos,
misc.InitialOpen,
misc.OpenCommandLogInEditor,
misc.RecentReposOnLaunch,
misc.StartInGitDir,
patch_building.Apply,