From 6a2df0a15d4df8f3e002faef5f2f822c2e0dc772 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Tue, 5 May 2026 01:20:38 +0300 Subject: [PATCH] fix: command re-running caused early line truncating --- internal/ui/reload_test.go | 114 +++++++++++++++++++++++++++++++++++++ internal/ui/update.go | 33 +++++------ 2 files changed, 131 insertions(+), 16 deletions(-) create mode 100644 internal/ui/reload_test.go diff --git a/internal/ui/reload_test.go b/internal/ui/reload_test.go new file mode 100644 index 0000000..acad26c --- /dev/null +++ b/internal/ui/reload_test.go @@ -0,0 +1,114 @@ +package ui + +import ( + "testing" + "time" + + "github.com/chenasraf/watchr/internal/runner" +) + +func waitDone(t *testing.T, m *model) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if m.streamResult != nil && m.streamResult.IsDone() { + // fire one tick after done so model state syncs + _, _ = m.Update(streamTickMsg(time.Now())) + return + } + _, _ = m.Update(streamTickMsg(time.Now())) + time.Sleep(10 * time.Millisecond) + } + t.Fatal("stream did not complete") +} + +// TestReload_SameLineCountUpdatesInPlace verifies that when the new run +// produces the same number of lines as the previous run, the new content +// (delivered via in-place updates) is reflected in m.lines. +func TestReload_SameLineCountUpdatesInPlace(t *testing.T) { + cfg := Config{Command: "echo new1; echo new2; echo new3", Shell: "sh"} + m := testModel(cfg) + m.height = 30 + m.width = 80 + + // Pretend a previous run left 3 lines of stale content + m.lines = []runner.Line{ + {Number: 1, Content: "old1"}, + {Number: 2, Content: "old2"}, + {Number: 3, Content: "old3"}, + } + m.lastLineCount = 3 + m.updateFiltered() + + _, _ = m.actionReload() + waitDone(t, m) + + if len(m.lines) != 3 { + t.Fatalf("expected 3 lines, got %d", len(m.lines)) + } + for i, want := range []string{"new1", "new2", "new3"} { + if m.lines[i].Content != want { + t.Errorf("line %d: expected %q, got %q", i, want, m.lines[i].Content) + } + } +} + +// TestReload_FewerLinesTrimsToNewRun verifies that when the new run produces +// fewer lines than the previous run, m.lines is trimmed AND shows the new +// content in the surviving slots (not stale prev content). +func TestReload_FewerLinesTrimsToNewRun(t *testing.T) { + cfg := Config{Command: "echo new1; echo new2", Shell: "sh"} + m := testModel(cfg) + m.height = 30 + m.width = 80 + + m.lines = []runner.Line{ + {Number: 1, Content: "old1"}, + {Number: 2, Content: "old2"}, + {Number: 3, Content: "old3"}, + {Number: 4, Content: "old4"}, + } + m.lastLineCount = 4 + m.updateFiltered() + + _, _ = m.actionReload() + waitDone(t, m) + + if len(m.lines) != 2 { + t.Fatalf("expected 2 lines after reload, got %d", len(m.lines)) + } + for i, want := range []string{"new1", "new2"} { + if m.lines[i].Content != want { + t.Errorf("line %d: expected %q, got %q", i, want, m.lines[i].Content) + } + } +} + +// TestReload_MoreLines verifies that when the new run produces more lines +// than the previous run, all new lines are visible. +func TestReload_MoreLines(t *testing.T) { + cfg := Config{Command: "echo a; echo b; echo c; echo d; echo e", Shell: "sh"} + m := testModel(cfg) + m.height = 30 + m.width = 80 + + m.lines = []runner.Line{ + {Number: 1, Content: "old1"}, + {Number: 2, Content: "old2"}, + {Number: 3, Content: "old3"}, + } + m.lastLineCount = 3 + m.updateFiltered() + + _, _ = m.actionReload() + waitDone(t, m) + + if len(m.lines) != 5 { + t.Fatalf("expected 5 lines, got %d", len(m.lines)) + } + for i, want := range []string{"a", "b", "c", "d", "e"} { + if m.lines[i].Content != want { + t.Errorf("line %d: expected %q, got %q", i, want, m.lines[i].Content) + } + } +} diff --git a/internal/ui/update.go b/internal/ui/update.go index 1a9b48a..3279539 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -70,7 +70,9 @@ func (m *model) startStreaming() tea.Cmd { m.streamResult = m.runner.RunStreaming(m.ctx, m.lines) m.streaming = true m.loading = true - m.lastLineCount = len(m.lines) + // lastLineCount tracks the streamResult's CurrentLineCount (lines produced + // by the current run), which starts at 0 for a fresh streaming result. + m.lastLineCount = 0 m.exitCode = -1 m.errorMsg = "" m.userScrolled = false @@ -116,16 +118,23 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - // Check for new lines - newLines := m.streamResult.GetLines() - newCount := len(newLines) + isDone := m.streamResult.IsDone() + currentCount := m.streamResult.GetCurrentLineCount() - if newCount != m.lastLineCount { + // Sync m.lines whenever the stream has produced new line writes since + // the last tick (in-place edits and appends both bump CurrentLineCount), + // or once on completion so the trim-to-currentCount path runs. + if currentCount != m.lastLineCount || isDone { + newLines := m.streamResult.GetLines() + // On completion, drop any leftover slots that the new run never + // wrote into — they still hold previous-run content. + if isDone && currentCount < len(newLines) { + newLines = newLines[:currentCount] + } m.lines = newLines - m.lastLineCount = newCount + m.lastLineCount = currentCount m.updateFiltered() - // Auto-scroll to bottom if user hasn't manually scrolled if !m.userScrolled { visible := m.visibleLines() if visible > 0 { @@ -135,8 +144,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Check if command completed - if m.streamResult.IsDone() { + if isDone { m.streaming = false m.loading = false m.exitCode = m.streamResult.ExitCode @@ -144,13 +152,6 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.errorMsg = m.streamResult.Error.Error() } - // Trim excess lines from previous run - currentCount := m.streamResult.GetCurrentLineCount() - if currentCount < len(m.lines) { - m.lines = m.lines[:currentCount] - m.updateFiltered() - } - // If auto-refresh is enabled and timer starts from end, schedule the next run if m.config.RefreshInterval > 0 && !m.config.RefreshFromStart { m.refreshStartTime = time.Now()