From da09bb717152e4853a69981bf0280484fe547f3f Mon Sep 17 00:00:00 2001 From: tmwatchanan Date: Sat, 20 Jun 2026 22:24:14 +0700 Subject: [PATCH] Fix command log menu disabled state and editor export content Evaluate menu disabled reasons when rendering and invoking so copy items update while the menu is open. Export actions and git output without panel chrome, match copy-notification lines via i18n log templates, and disable open-in-editor until there is real log content. --- pkg/gui/command_log_panel.go | 86 +++++++++++++++---- pkg/gui/command_log_panel_test.go | 26 ++++-- pkg/gui/context/menu_context.go | 16 ++-- .../helpers/confirmation_helper.go | 4 +- pkg/gui/extras_panel.go | 37 +++++--- pkg/gui/types/common.go | 12 +++ pkg/i18n/english.go | 2 + .../misc/copy_all_git_output_to_clipboard.go | 45 ++++++++++ .../tests/misc/open_command_log_in_editor.go | 6 +- pkg/integration/tests/test_list.go | 1 + 10 files changed, 191 insertions(+), 44 deletions(-) create mode 100644 pkg/integration/tests/misc/copy_all_git_output_to_clipboard.go diff --git a/pkg/gui/command_log_panel.go b/pkg/gui/command_log_panel.go index a30639baa..4028fa1de 100644 --- a/pkg/gui/command_log_panel.go +++ b/pkg/gui/command_log_panel.go @@ -37,7 +37,11 @@ func (gui *Gui) gitOutputBlocksFromView() []string { if gui.Views.Extras == nil { return nil } - return gitOutputBlocksFromCommandLogLines(gui.Views.Extras.BufferLines(), gui.c.Tr.GitOutput) + return gitOutputBlocksFromCommandLogLines( + gui.Views.Extras.BufferLines(), + gui.c.Tr.GitOutput, + gui.isCopyToClipboardLogLine, + ) } func (gui *Gui) lastGitOutput() string { @@ -56,14 +60,69 @@ 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 "" } - return strings.TrimRight(gui.Views.Extras.Buffer(), "\n") + + 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 gitOutputBlocksFromCommandLogLines(lines []string, gitOutputHeader string) []string { +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 { @@ -71,12 +130,12 @@ func gitOutputBlocksFromCommandLogLines(lines []string, gitOutputHeader string) continue } - block := commandLogEntryBeforeGitOutput(lines, i, gitOutputHeader) + 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)...) + block = append(block, gitOutputLinesAfterHeader(lines, i+1, gitOutputHeader, isCopyToClipboardLogLine)...) if trimmed := strings.TrimRight(strings.Join(block, "\n"), "\n"); trimmed != "" { blocks = append(blocks, trimmed) @@ -86,7 +145,7 @@ func gitOutputBlocksFromCommandLogLines(lines []string, gitOutputHeader string) return blocks } -func commandLogEntryBeforeGitOutput(lines []string, headerIdx int, gitOutputHeader string) []string { +func commandLogEntryBeforeGitOutput(lines []string, headerIdx int, gitOutputHeader string, isCopyToClipboardLogLine func(string) bool) []string { i := headerIdx - 1 for i >= 0 && lines[i] == "" { i-- @@ -115,7 +174,7 @@ func commandLogEntryBeforeGitOutput(lines []string, headerIdx int, gitOutputHead return entry } -func gitOutputLinesAfterHeader(lines []string, startIdx int, gitOutputHeader string) []string { +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++ { @@ -126,7 +185,7 @@ func gitOutputLinesAfterHeader(lines []string, startIdx int, gitOutputHeader str if isCopyToClipboardLogLine(line) { continue } - if isStartOfNewCommandLogEntry(lines, i) { + if isStartOfNewCommandLogEntry(lines, i, isCopyToClipboardLogLine) { break } output = append(output, line) @@ -135,12 +194,7 @@ func gitOutputLinesAfterHeader(lines []string, startIdx int, gitOutputHeader str 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 { +func isStartOfNewCommandLogEntry(lines []string, i int, isCopyToClipboardLogLine func(string) bool) bool { line := lines[i] if line == "" || strings.HasPrefix(line, " ") { return false @@ -153,13 +207,13 @@ func isStartOfNewCommandLogEntry(lines []string, i int) bool { if isCopyToClipboardLogLine(lines[j]) { continue } - return isLazygitCommandLogLine(lines[j]) + return isLazygitCommandLogLine(lines[j], isCopyToClipboardLogLine) } return false } -func isLazygitCommandLogLine(line string) bool { +func isLazygitCommandLogLine(line string, isCopyToClipboardLogLine func(string) bool) bool { return isCopyToClipboardLogLine(line) || strings.HasPrefix(line, " git ") } diff --git a/pkg/gui/command_log_panel_test.go b/pkg/gui/command_log_panel_test.go index 485c731c6..3556ef366 100644 --- a/pkg/gui/command_log_panel_test.go +++ b/pkg/gui/command_log_panel_test.go @@ -8,6 +8,12 @@ import ( 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() @@ -20,7 +26,7 @@ func TestGitOutputBlocksFromCommandLogLines(t *testing.T) { "line2", } - assert.Equal(t, []string{"Push\n git push\n\nGit output:\nline1\nline2"}, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader)) + assert.Equal(t, []string{"Push\n git push\n\nGit output:\nline1\nline2"}, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader, englishCopyToClipboardLogLineMatcher())) } func TestGitOutputBlocksIncludeIndentedStderr(t *testing.T) { @@ -35,7 +41,7 @@ func TestGitOutputBlocksIncludeIndentedStderr(t *testing.T) { "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)) + 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) { @@ -57,7 +63,7 @@ func TestGitOutputBlocksIncludeToolErrorWithIndentedContext(t *testing.T) { 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)) + }, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader, englishCopyToClipboardLogLineMatcher())) } func TestGitOutputBlocksSkipCopyNotifications(t *testing.T) { @@ -72,7 +78,7 @@ func TestGitOutputBlocksSkipCopyNotifications(t *testing.T) { "hook line 2", } - assert.Equal(t, []string{"Push\n git push\n\nGit output:\nhook line\nhook line 2"}, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader)) + 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) { @@ -93,7 +99,7 @@ func TestGitOutputBlocksEndAtNextCommandLogEntry(t *testing.T) { 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)) + }, gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader, englishCopyToClipboardLogLineMatcher())) } func TestGitOutputBlocksMultipleBlocksJoined(t *testing.T) { @@ -110,10 +116,18 @@ func TestGitOutputBlocksMultipleBlocksJoined(t *testing.T) { "second command", } - blocks := gitOutputBlocksFromCommandLogLines(lines, gitOutputHeader) + 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 { diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 9feef1e4c..283cfa36e 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -138,7 +138,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]) } @@ -237,13 +237,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() diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index 3663cd4ea..5a8657002 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -443,11 +443,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 } diff --git a/pkg/gui/extras_panel.go b/pkg/gui/extras_panel.go index 951ba0f75..54019aeb3 100644 --- a/pkg/gui/extras_panel.go +++ b/pkg/gui/extras_panel.go @@ -20,6 +20,13 @@ func (gui *Gui) handleCreateExtrasMenuPanel() error { 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{ @@ -44,21 +51,22 @@ func (gui *Gui) handleCreateExtrasMenuPanel() error { OnPress: gui.handleFocusCommandLog, }, { - Label: gui.c.Tr.CopyGitOutputToClipboard, - Keys: []gocui.Key{gocui.NewKeyRune('c')}, - OnPress: gui.handleCopyLastGitOutputToClipboard, - DisabledReason: noGitOutputDisabledReason(), + 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, - DisabledReason: 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, + Label: gui.c.Tr.OpenCommandLogInEditor, + Keys: []gocui.Key{gocui.NewKeyRune('o')}, + OnPress: gui.handleOpenCommandLogInEditor, + GetDisabledReason: noCommandLogDisabledReason, }, }, }) @@ -107,7 +115,12 @@ func (gui *Gui) handleOpenCommandLogInEditor() error { return err } - return gui.Helpers().Files.EditFiles([]string{filepath}) + 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 { diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index d81da5093..11ce2234f 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -284,6 +284,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. @@ -299,6 +303,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 diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index aca3bc0cb..446604c5f 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -774,6 +774,7 @@ type TranslationSet struct { GitOutputCopiedToClipboard string OpenCommandLogInEditor string NoCommandLogToOpenInEditor string + CommandLogOpenedInEditor string CommandLogHeader string RandomTip string ToggleWhitespaceInDiffView string @@ -1911,6 +1912,7 @@ func EnglishTranslationSet() *TranslationSet { 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", diff --git a/pkg/integration/tests/misc/copy_all_git_output_to_clipboard.go b/pkg/integration/tests/misc/copy_all_git_output_to_clipboard.go new file mode 100644 index 000000000..2688aaf1f --- /dev/null +++ b/pkg/integration/tests/misc/copy_all_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 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")) + }, +}) diff --git a/pkg/integration/tests/misc/open_command_log_in_editor.go b/pkg/integration/tests/misc/open_command_log_in_editor.go index 75c1c5aa0..45f96c21d 100644 --- a/pkg/integration/tests/misc/open_command_log_in_editor.go +++ b/pkg/integration/tests/misc/open_command_log_in_editor.go @@ -11,6 +11,7 @@ var OpenCommandLogInEditor = NewIntegrationTest(NewIntegrationTestArgs{ 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") @@ -35,9 +36,12 @@ var OpenCommandLogInEditor = NewIntegrationTest(NewIntegrationTestArgs{ 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")) + Contains("master -> master"). + DoesNotContain("You can hide/focus")) }, }) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index ff0fabf89..767fe2464 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -333,6 +333,7 @@ var tests = []*components.IntegrationTest{ interactive_rebase.SwapWithConflict, interactive_rebase.ViewFilesOfTodoEntries, misc.ConfirmOnQuit, + misc.CopyAllGitOutputToClipboard, misc.CopyConfirmationMessageToClipboard, misc.CopyGitOutputToClipboard, misc.CopyToClipboard,