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.
This commit is contained in:
tmwatchanan 2026-06-20 22:24:14 +07:00
parent 01072bac90
commit da09bb7171
No known key found for this signature in database
GPG key ID: F4D43507B7A7F4AC
10 changed files with 191 additions and 44 deletions

View file

@ -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 ")
}

View file

@ -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 {

View file

@ -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()

View file

@ -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
}

View file

@ -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 {

View file

@ -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

View file

@ -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",

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

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

View file

@ -333,6 +333,7 @@ var tests = []*components.IntegrationTest{
interactive_rebase.SwapWithConflict,
interactive_rebase.ViewFilesOfTodoEntries,
misc.ConfirmOnQuit,
misc.CopyAllGitOutputToClipboard,
misc.CopyConfirmationMessageToClipboard,
misc.CopyGitOutputToClipboard,
misc.CopyToClipboard,