From 60b293e18c85f28cfbaad047d4e4534856432b4b Mon Sep 17 00:00:00 2001 From: Falk Harrison Date: Tue, 29 Sep 2026 10:46:31 +0200 Subject: [PATCH] fix(ui): ignore shell results from previous prompts --- internal/ui/main_model.go | 3 +- internal/ui/shell_result_test.go | 82 ++++++++++++++++++++++++++++++++ internal/ui/var_resolve.go | 13 +++-- 3 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 internal/ui/shell_result_test.go diff --git a/internal/ui/main_model.go b/internal/ui/main_model.go index bca2ae2..ae48b14 100644 --- a/internal/ui/main_model.go +++ b/internal/ui/main_model.go @@ -83,7 +83,8 @@ type mainModel struct { lastQuery string // Variable resolution state (only used in phaseVarResolve) - varState *varResolveState + varState *varResolveState + shellGeneration uint64 // Substitute search state (only used in phaseSubstituteSearch) subState *substituteSearchState diff --git a/internal/ui/shell_result_test.go b/internal/ui/shell_result_test.go new file mode 100644 index 0000000..ea7078a --- /dev/null +++ b/internal/ui/shell_result_test.go @@ -0,0 +1,82 @@ +package ui + +import ( + "fmt" + "reflect" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/cheatmd-dev/cheatmd/pkg/config" + "github.com/cheatmd-dev/cheatmd/pkg/parser" +) + +type shellRequestExecutor struct{ mockExecutor } + +func (*shellRequestExecutor) RunShell(command string) (string, error) { + if command == "failed" { + return "", fmt.Errorf("stale shell failure") + } + return command + "\n" + command + "-other", nil +} + +func TestShellResultsFollowCurrentPrompt(t *testing.T) { + oldSyntax := config.Get().VarSyntax + t.Cleanup(func() { config.Get().VarSyntax = oldSyntax }) + config.Get().VarSyntax = "dollar" + t.Setenv("first", "") + t.Setenv("second", "") + + for _, revisit := range []bool{false, true} { + for _, firstValue := range []string{"old", "failed"} { + t.Run(fmt.Sprintf("revisit=%t/result=%s", revisit, firstValue), func(t *testing.T) { + m, index := setupTestModel() + cheat := index.Cheats[0] + cheat.Command = "echo $first $second" + cheat.Vars = []parser.VarDef{{Name: "first"}, {Name: "second", Shell: "$first"}} + m.executor = &shellRequestExecutor{} + m.selected = cheat + m.startVarResolution() + m.textInput.SetValue(firstValue) + _, delayed := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if delayed == nil { + t.Fatal("shell variable returned no command") + } + + m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m.textInput.SetValue("editing") + wantInput := "editing" + wantIndex := 0 + var wantOptions []string + if revisit { + m.textInput.SetValue("current") + _, current := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if current == nil { + t.Fatal("revisited shell variable returned no command") + } + m.Update(current()) + wantOptions = []string{"current", "current-other"} + wantInput = "" + wantIndex = 1 + if !reflect.DeepEqual(m.varState.options, wantOptions) { + t.Fatalf("current result options = %v, want %v", m.varState.options, wantOptions) + } + } + + m.Update(delayed()) + + if m.varState.currentIdx != wantIndex || m.textInput.Value() != wantInput { + t.Errorf("prompt after stale result = (%d, %q), want (%d, %q)", m.varState.currentIdx, m.textInput.Value(), wantIndex, wantInput) + } + if !reflect.DeepEqual(m.varState.options, wantOptions) { + t.Errorf("options after stale result = %v, want %v", m.varState.options, wantOptions) + } + if m.varState.shellErr != nil { + t.Errorf("stale error reached current prompt: %v", m.varState.shellErr) + } + if m.varState.isPromptOnly == revisit { + t.Errorf("prompt-only mode = %t, want %t", m.varState.isPromptOnly, !revisit) + } + }) + } + } +} diff --git a/internal/ui/var_resolve.go b/internal/ui/var_resolve.go index 19a970d..98d7a92 100644 --- a/internal/ui/var_resolve.go +++ b/internal/ui/var_resolve.go @@ -18,8 +18,9 @@ import ( // shellResultMsg is sent when a shell command completes. type shellResultMsg struct { - options []string - err error + options []string + err error + generation uint64 } // ============================================================================ @@ -83,6 +84,7 @@ func (m *mainModel) startVarResolutionInternal() { // prepareCurrentVar prepares the current variable for display. May return a // command to run a shell command to get options. func (m *mainModel) prepareCurrentVar() tea.Cmd { + m.shellGeneration++ if m.varState == nil || m.varState.currentIdx >= len(m.varState.vars) { if m.varState != nil { for _, vs := range m.varState.vars { @@ -185,13 +187,14 @@ func (m *mainModel) preparePromptVar(vs *varState) tea.Cmd { func (m *mainModel) prepareShellVar(vs *varState, scope map[string]string) tea.Cmd { shellCmd := executor.SubstituteVars(vs.def.Shell, scope, config.Get().VarSyntax) + generation := m.shellGeneration return func() tea.Msg { output, err := m.executor.RunShell(shellCmd) if err != nil { - return shellResultMsg{nil, err} + return shellResultMsg{err: err, generation: generation} } lines := parser.SplitLines(output) - return shellResultMsg{lines, nil} + return shellResultMsg{options: lines, generation: generation} } } @@ -230,7 +233,7 @@ func (m *mainModel) updateVarResolve(msg tea.Msg) (tea.Model, tea.Cmd) { // handleShellResult processes the result of a shell command. func (m *mainModel) handleShellResult(msg shellResultMsg) (tea.Model, tea.Cmd) { - if m.varState == nil { + if m.varState == nil || msg.generation != m.shellGeneration { return m, nil }