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,