From 8e2843954a3a4facd226c182a7c7c97701172437 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 7 Jan 2026 14:30:25 +0000 Subject: [PATCH 1/5] Add filter functionality to CloudWatch Logs tail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements hybrid filter system with AWS and client modes: - AWS mode: Uses CloudWatch filterPattern API (cost efficient) - Client mode: Local substring match (instant UX) Key features: - / key activates filter input (consistent with ResourceBrowser) - Client mode: real-time filtering as user types - AWS mode: applies filter on Enter (triggers API re-fetch) - Ctrl+F toggles between modes - c key clears filter (or buffer if no filter) - Visual indicators: 🔍 filter display, mode label [AWS]/[Client] - Filtered count display: (45/1,234 lines) Implementation: - Single file change: internal/view/log_view.go - Pure additive feature, no breaking changes - Follows existing patterns (textinput, ViewportState) Closes #120 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- internal/view/log_view.go | 184 +++++++++++++++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 3 deletions(-) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index f0b9488..c121d8e 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -7,6 +7,7 @@ import ( "time" "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -47,6 +48,13 @@ type LogView struct { lastEventTime int64 oldestEventTime int64 pollInterval time.Duration + + // Filter state + filterInput textinput.Model + filterActive bool + filterPattern string // AWS filterPattern (server-side) + filterMode string // "aws" or "client" + clientFilter string // Client-side substring filter } type logEntry struct { @@ -75,6 +83,11 @@ func newLogViewStyles() logViewStyles { } func NewLogView(ctx context.Context, logGroupName string) *LogView { + ti := textinput.New() + ti.Placeholder = "Filter pattern (AWS syntax or text)" + ti.Prompt = "/" + ti.CharLimit = 200 + return &LogView{ ctx: ctx, logGroupName: logGroupName, @@ -83,6 +96,8 @@ func NewLogView(ctx context.Context, logGroupName string) *LogView { logs: make([]logEntry, 0, initialLogBufferSize), loading: true, pollInterval: defaultLogPollInterval, + filterInput: ti, + filterMode: "aws", // Default to AWS mode } } @@ -164,6 +179,11 @@ func (v *LogView) doFetchLogs(startTime, endTime int64, older bool) tea.Msg { input.LogStreamNames = []string{v.logStreamName} } + // Add filter pattern if set (AWS mode) + if v.filterPattern != "" { + input.FilterPattern = appaws.StringPtr(v.filterPattern) + } + if older { input.StartTime = appaws.Int64Ptr(endTime - time.Hour.Milliseconds()) input.EndTime = appaws.Int64Ptr(endTime - 1) @@ -295,7 +315,16 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, v.fetchLogsCmd() case tea.KeyPressMsg: + // Handle filter input if active + if v.filterActive { + return v.handleFilterInput(msg) + } + switch msg.String() { + case "/": + v.filterActive = true + v.filterInput.Focus() + return v, textinput.Blink case "space": v.paused = !v.paused if !v.paused { @@ -313,6 +342,11 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return v, nil case "c": + // Clear filter if active, otherwise clear buffer + if v.filterPattern != "" || v.clientFilter != "" { + v.clearFilter() + return v, v.fetchLogsCmd() + } v.logs = v.logs[:0] v.oldestEventTime = 0 if v.vp.Ready { @@ -325,6 +359,9 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, v.fetchOlderLogsCmd() } return v, nil + case "ctrl+f": + v.toggleFilterMode() + return v, nil } case spinner.TickMsg: @@ -351,7 +388,17 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (v *LogView) updateViewportContent() { var sb strings.Builder + filter := strings.ToLower(v.clientFilter) + for _, entry := range v.logs { + // Client-side filter (case-insensitive substring match) + if v.clientFilter != "" { + msg := strings.ToLower(entry.message) + if !strings.Contains(msg, filter) { + continue + } + } + ts := v.styles.timestamp.Render(entry.timestamp.Format("15:04:05.000")) msg := v.styles.message.Render(entry.message) sb.WriteString(fmt.Sprintf("%s %s\n", ts, msg)) @@ -359,6 +406,76 @@ func (v *LogView) updateViewportContent() { v.vp.Model.SetContent(sb.String()) } +func (v *LogView) handleFilterInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + v.filterActive = false + v.filterInput.Blur() + return v, nil + case "enter": + v.filterActive = false + v.filterInput.Blur() + v.applyFilter(v.filterInput.Value()) + if v.filterMode == "aws" { + // AWS mode: re-fetch with new pattern + return v, v.fetchLogsCmd() + } + // Client mode: already applied during typing + return v, nil + case "ctrl+f": + v.toggleFilterMode() + return v, nil + default: + var cmd tea.Cmd + v.filterInput, cmd = v.filterInput.Update(msg) + + // Client mode: apply filter in real-time as user types + if v.filterMode == "client" { + v.clientFilter = v.filterInput.Value() + if v.vp.Ready { + v.updateViewportContent() + } + } + + return v, cmd + } +} + +func (v *LogView) applyFilter(pattern string) { + if v.filterMode == "aws" { + // AWS mode: set filterPattern and reset for fresh fetch + v.filterPattern = pattern + v.clientFilter = "" + v.logs = v.logs[:0] + v.lastEventTime = 0 + v.oldestEventTime = 0 + } else { + // Client mode: set clientFilter (already applied during typing) + v.clientFilter = pattern + v.filterPattern = "" + if v.vp.Ready { + v.updateViewportContent() + } + } +} + +func (v *LogView) clearFilter() { + v.filterPattern = "" + v.clientFilter = "" + v.filterInput.SetValue("") + v.logs = v.logs[:0] + v.lastEventTime = 0 + v.oldestEventTime = 0 +} + +func (v *LogView) toggleFilterMode() { + if v.filterMode == "aws" { + v.filterMode = "client" + } else { + v.filterMode = "aws" + } +} + func (v *LogView) ViewString() string { if !v.vp.Ready { return LoadingMessage @@ -373,11 +490,36 @@ func (v *LogView) ViewString() string { sb.WriteString(v.styles.header.Render("📜 " + title)) sb.WriteString("\n") + // Filter UI + if v.filterActive { + sb.WriteString(ui.InputFieldStyle().Render(v.filterInput.View())) + sb.WriteString("\n") + } else if v.filterPattern != "" || v.clientFilter != "" { + activeFilter := v.filterPattern + if v.clientFilter != "" { + activeFilter = v.clientFilter + } + modeLabel := "AWS" + if v.filterMode == "client" { + modeLabel = "Client" + } + sb.WriteString(ui.AccentStyle().Render(fmt.Sprintf("🔍 filter: %s [%s]", activeFilter, modeLabel))) + sb.WriteString("\n") + } + if v.paused { sb.WriteString(v.styles.paused.Render("⏸ PAUSED")) sb.WriteString(" ") } - sb.WriteString(v.styles.dim.Render(fmt.Sprintf("(%d lines)", len(v.logs)))) + + // Show filtered/total count + totalCount := len(v.logs) + displayedCount := v.getDisplayedCount() + if v.clientFilter != "" && displayedCount < totalCount { + sb.WriteString(v.styles.dim.Render(fmt.Sprintf("(%d/%d lines)", displayedCount, totalCount))) + } else { + sb.WriteString(v.styles.dim.Render(fmt.Sprintf("(%d lines)", totalCount))) + } sb.WriteString("\n\n") if v.loading { @@ -400,19 +542,55 @@ func (v *LogView) ViewString() string { return sb.String() } +func (v *LogView) getDisplayedCount() int { + if v.clientFilter == "" { + return len(v.logs) + } + count := 0 + filter := strings.ToLower(v.clientFilter) + for _, entry := range v.logs { + if strings.Contains(strings.ToLower(entry.message), filter) { + count++ + } + } + return count +} + func (v *LogView) View() tea.View { return tea.NewView(v.ViewString()) } func (v *LogView) SetSize(width, height int) tea.Cmd { - viewportHeight := height - viewportHeaderOffset + headerOffset := viewportHeaderOffset + if v.filterActive || v.filterPattern != "" || v.clientFilter != "" { + headerOffset++ // Extra line for filter UI + } + viewportHeight := height - headerOffset v.vp.SetSize(width, viewportHeight) + v.filterInput.SetWidth(width - 4) v.updateViewportContent() return nil } func (v *LogView) StatusLine() string { - status := "Space:pause/resume p:older g/G:top/bottom c:clear Esc:back" + if v.filterActive { + modeHint := "AWS" + if v.filterMode == "client" { + modeHint = "Client" + } + return fmt.Sprintf("Mode:%s (Ctrl+F) • Esc:cancel Enter:apply", modeHint) + } + + status := "Space:pause/resume p:older g/G:top/bottom c:clear /:filter Esc:back" + + if v.filterPattern != "" || v.clientFilter != "" { + activeFilter := v.filterPattern + if v.clientFilter != "" { + activeFilter = v.clientFilter + } + status = fmt.Sprintf("🔍 %s • ", activeFilter) + status + } + if v.paused { return "⏸ PAUSED • " + status } From 767179e00070ab7074532d7996412e3fc238e415 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 7 Jan 2026 14:41:20 +0000 Subject: [PATCH 2/5] Fix Backspace handling in filter input mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add HasActiveInput() method to LogView so app.go correctly handles input mode and doesn't treat Backspace as back navigation. Fixes issue where editing filter text with Backspace caused screen to go back instead of deleting characters. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- internal/view/log_view.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index c121d8e..24a3bef 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -599,3 +599,7 @@ func (v *LogView) StatusLine() string { } return "▶ STREAMING • " + status } + +func (v *LogView) HasActiveInput() bool { + return v.filterActive +} From 587c66779666f09afed2c04b9c19cceb34fed261 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 7 Jan 2026 14:49:59 +0000 Subject: [PATCH 3/5] Simplify filter to client-side only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove AWS mode complexity. Client-side substring match is sufficient for tail use case where logs are already fetched. Removed: - filterPattern (AWS server-side filtering) - filterMode toggle - Ctrl+F mode switching - Mode display [AWS]/[Client] - API FilterPattern parameter Simplified: - Single filterText field (client-side substring) - / to activate filter - Real-time filtering as user types - Enter to done (no-op, already filtered) - c to clear - Cleaner UI without mode indicators Rationale: Tail views logs already fetched. AWS filtering only useful for high-volume streams (rare case). Hybrid approach added unnecessary complexity and confusion (which filter is active?). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- internal/view/log_view.go | 120 +++++++++----------------------------- 1 file changed, 26 insertions(+), 94 deletions(-) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 24a3bef..1c44259 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -50,11 +50,9 @@ type LogView struct { pollInterval time.Duration // Filter state - filterInput textinput.Model - filterActive bool - filterPattern string // AWS filterPattern (server-side) - filterMode string // "aws" or "client" - clientFilter string // Client-side substring filter + filterInput textinput.Model + filterActive bool + filterText string // Filter text (client-side substring match) } type logEntry struct { @@ -84,7 +82,7 @@ func newLogViewStyles() logViewStyles { func NewLogView(ctx context.Context, logGroupName string) *LogView { ti := textinput.New() - ti.Placeholder = "Filter pattern (AWS syntax or text)" + ti.Placeholder = "Filter logs..." ti.Prompt = "/" ti.CharLimit = 200 @@ -97,7 +95,6 @@ func NewLogView(ctx context.Context, logGroupName string) *LogView { loading: true, pollInterval: defaultLogPollInterval, filterInput: ti, - filterMode: "aws", // Default to AWS mode } } @@ -179,11 +176,6 @@ func (v *LogView) doFetchLogs(startTime, endTime int64, older bool) tea.Msg { input.LogStreamNames = []string{v.logStreamName} } - // Add filter pattern if set (AWS mode) - if v.filterPattern != "" { - input.FilterPattern = appaws.StringPtr(v.filterPattern) - } - if older { input.StartTime = appaws.Int64Ptr(endTime - time.Hour.Milliseconds()) input.EndTime = appaws.Int64Ptr(endTime - 1) @@ -343,9 +335,13 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, nil case "c": // Clear filter if active, otherwise clear buffer - if v.filterPattern != "" || v.clientFilter != "" { - v.clearFilter() - return v, v.fetchLogsCmd() + if v.filterText != "" { + v.filterText = "" + v.filterInput.SetValue("") + if v.vp.Ready { + v.updateViewportContent() + } + return v, nil } v.logs = v.logs[:0] v.oldestEventTime = 0 @@ -359,9 +355,6 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, v.fetchOlderLogsCmd() } return v, nil - case "ctrl+f": - v.toggleFilterMode() - return v, nil } case spinner.TickMsg: @@ -388,11 +381,11 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (v *LogView) updateViewportContent() { var sb strings.Builder - filter := strings.ToLower(v.clientFilter) + filter := strings.ToLower(v.filterText) for _, entry := range v.logs { // Client-side filter (case-insensitive substring match) - if v.clientFilter != "" { + if v.filterText != "" { msg := strings.ToLower(entry.message) if !strings.Contains(msg, filter) { continue @@ -415,64 +408,19 @@ func (v *LogView) handleFilterInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case "enter": v.filterActive = false v.filterInput.Blur() - v.applyFilter(v.filterInput.Value()) - if v.filterMode == "aws" { - // AWS mode: re-fetch with new pattern - return v, v.fetchLogsCmd() - } - // Client mode: already applied during typing - return v, nil - case "ctrl+f": - v.toggleFilterMode() + v.filterText = v.filterInput.Value() return v, nil default: var cmd tea.Cmd v.filterInput, cmd = v.filterInput.Update(msg) - // Client mode: apply filter in real-time as user types - if v.filterMode == "client" { - v.clientFilter = v.filterInput.Value() - if v.vp.Ready { - v.updateViewportContent() - } - } - - return v, cmd - } -} - -func (v *LogView) applyFilter(pattern string) { - if v.filterMode == "aws" { - // AWS mode: set filterPattern and reset for fresh fetch - v.filterPattern = pattern - v.clientFilter = "" - v.logs = v.logs[:0] - v.lastEventTime = 0 - v.oldestEventTime = 0 - } else { - // Client mode: set clientFilter (already applied during typing) - v.clientFilter = pattern - v.filterPattern = "" + // Apply filter in real-time as user types + v.filterText = v.filterInput.Value() if v.vp.Ready { v.updateViewportContent() } - } -} - -func (v *LogView) clearFilter() { - v.filterPattern = "" - v.clientFilter = "" - v.filterInput.SetValue("") - v.logs = v.logs[:0] - v.lastEventTime = 0 - v.oldestEventTime = 0 -} -func (v *LogView) toggleFilterMode() { - if v.filterMode == "aws" { - v.filterMode = "client" - } else { - v.filterMode = "aws" + return v, cmd } } @@ -494,16 +442,8 @@ func (v *LogView) ViewString() string { if v.filterActive { sb.WriteString(ui.InputFieldStyle().Render(v.filterInput.View())) sb.WriteString("\n") - } else if v.filterPattern != "" || v.clientFilter != "" { - activeFilter := v.filterPattern - if v.clientFilter != "" { - activeFilter = v.clientFilter - } - modeLabel := "AWS" - if v.filterMode == "client" { - modeLabel = "Client" - } - sb.WriteString(ui.AccentStyle().Render(fmt.Sprintf("🔍 filter: %s [%s]", activeFilter, modeLabel))) + } else if v.filterText != "" { + sb.WriteString(ui.AccentStyle().Render(fmt.Sprintf("🔍 filter: %s", v.filterText))) sb.WriteString("\n") } @@ -515,7 +455,7 @@ func (v *LogView) ViewString() string { // Show filtered/total count totalCount := len(v.logs) displayedCount := v.getDisplayedCount() - if v.clientFilter != "" && displayedCount < totalCount { + if v.filterText != "" && displayedCount < totalCount { sb.WriteString(v.styles.dim.Render(fmt.Sprintf("(%d/%d lines)", displayedCount, totalCount))) } else { sb.WriteString(v.styles.dim.Render(fmt.Sprintf("(%d lines)", totalCount))) @@ -543,11 +483,11 @@ func (v *LogView) ViewString() string { } func (v *LogView) getDisplayedCount() int { - if v.clientFilter == "" { + if v.filterText == "" { return len(v.logs) } count := 0 - filter := strings.ToLower(v.clientFilter) + filter := strings.ToLower(v.filterText) for _, entry := range v.logs { if strings.Contains(strings.ToLower(entry.message), filter) { count++ @@ -562,7 +502,7 @@ func (v *LogView) View() tea.View { func (v *LogView) SetSize(width, height int) tea.Cmd { headerOffset := viewportHeaderOffset - if v.filterActive || v.filterPattern != "" || v.clientFilter != "" { + if v.filterActive || v.filterText != "" { headerOffset++ // Extra line for filter UI } viewportHeight := height - headerOffset @@ -574,21 +514,13 @@ func (v *LogView) SetSize(width, height int) tea.Cmd { func (v *LogView) StatusLine() string { if v.filterActive { - modeHint := "AWS" - if v.filterMode == "client" { - modeHint = "Client" - } - return fmt.Sprintf("Mode:%s (Ctrl+F) • Esc:cancel Enter:apply", modeHint) + return "Esc:cancel Enter:done" } status := "Space:pause/resume p:older g/G:top/bottom c:clear /:filter Esc:back" - if v.filterPattern != "" || v.clientFilter != "" { - activeFilter := v.filterPattern - if v.clientFilter != "" { - activeFilter = v.clientFilter - } - status = fmt.Sprintf("🔍 %s • ", activeFilter) + status + if v.filterText != "" { + status = fmt.Sprintf("🔍 %s • ", v.filterText) + status } if v.paused { From d096ac825682c99c9b1e0dbb817dc80796bc4428 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 7 Jan 2026 15:01:09 +0000 Subject: [PATCH 4/5] Refactor: extract filter logic to matchesFilter method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate duplication by extracting filter matching logic into dedicated matchesFilter() method, reused in updateViewportContent() and getDisplayedCount(). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- internal/view/log_view.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 1c44259..3c955c7 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -379,17 +379,21 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, nil } +func (v *LogView) matchesFilter(entry logEntry) bool { + if v.filterText == "" { + return true + } + filter := strings.ToLower(v.filterText) + msg := strings.ToLower(entry.message) + return strings.Contains(msg, filter) +} + func (v *LogView) updateViewportContent() { var sb strings.Builder - filter := strings.ToLower(v.filterText) for _, entry := range v.logs { - // Client-side filter (case-insensitive substring match) - if v.filterText != "" { - msg := strings.ToLower(entry.message) - if !strings.Contains(msg, filter) { - continue - } + if !v.matchesFilter(entry) { + continue } ts := v.styles.timestamp.Render(entry.timestamp.Format("15:04:05.000")) @@ -487,9 +491,8 @@ func (v *LogView) getDisplayedCount() int { return len(v.logs) } count := 0 - filter := strings.ToLower(v.filterText) for _, entry := range v.logs { - if strings.Contains(strings.ToLower(entry.message), filter) { + if v.matchesFilter(entry) { count++ } } From 598e171b040d13729b408f476d432794f2060fd5 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 7 Jan 2026 15:17:36 +0000 Subject: [PATCH 5/5] Fix filter issues: SetSize recalc, tests, min width, truncate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review feedback: - Track width/height to recalc viewport when filter cleared - Add min width check (10 chars) for narrow terminals - Truncate long filter text in status line (>20 chars) - Add comprehensive test coverage (7 new tests) - Keep real-time filtering (1000 logs = negligible perf impact) Tests added: - TestLogViewFilterActivation - TestLogViewFilterMatching - TestLogViewFilterClear - TestLogViewFilteredCount - TestLogViewHasActiveInput - TestLogViewFilterSetSizeRecalculation - TestLogViewFilterStatusLine 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- internal/view/log_view.go | 26 ++++- internal/view/log_view_test.go | 200 +++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 2 deletions(-) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 3c955c7..f326aa0 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -49,6 +49,10 @@ type LogView struct { oldestEventTime int64 pollInterval time.Duration + // Size tracking + width int + height int + // Filter state filterInput textinput.Model filterActive bool @@ -340,6 +344,7 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { v.filterInput.SetValue("") if v.vp.Ready { v.updateViewportContent() + v.SetSize(v.width, v.height) // Recalculate viewport height } return v, nil } @@ -413,6 +418,9 @@ func (v *LogView) handleFilterInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { v.filterActive = false v.filterInput.Blur() v.filterText = v.filterInput.Value() + if v.vp.Ready { + v.updateViewportContent() + } return v, nil default: var cmd tea.Cmd @@ -504,13 +512,23 @@ func (v *LogView) View() tea.View { } func (v *LogView) SetSize(width, height int) tea.Cmd { + v.width = width + v.height = height + headerOffset := viewportHeaderOffset if v.filterActive || v.filterText != "" { headerOffset++ // Extra line for filter UI } viewportHeight := height - headerOffset v.vp.SetSize(width, viewportHeight) - v.filterInput.SetWidth(width - 4) + + // Set filter input width with minimum check + filterWidth := width - 4 + if filterWidth < 10 { + filterWidth = 10 + } + v.filterInput.SetWidth(filterWidth) + v.updateViewportContent() return nil } @@ -523,7 +541,11 @@ func (v *LogView) StatusLine() string { status := "Space:pause/resume p:older g/G:top/bottom c:clear /:filter Esc:back" if v.filterText != "" { - status = fmt.Sprintf("🔍 %s • ", v.filterText) + status + filterDisplay := v.filterText + if len(filterDisplay) > 20 { + filterDisplay = filterDisplay[:17] + "..." + } + status = fmt.Sprintf("🔍 %s • ", filterDisplay) + status } if v.paused { diff --git a/internal/view/log_view_test.go b/internal/view/log_view_test.go index 02a048d..1f9e5dd 100644 --- a/internal/view/log_view_test.go +++ b/internal/view/log_view_test.go @@ -284,3 +284,203 @@ func TestLogViewGotoTopBottom(t *testing.T) { GMsg := tea.KeyPressMsg{Code: 0, Text: "G"} lv.Update(GMsg) } + +func TestLogViewFilterActivation(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + + if lv.filterActive { + t.Error("Expected filterActive to be false initially") + } + + // Activate filter with "/" + slashMsg := tea.KeyPressMsg{Code: 0, Text: "/"} + lv.Update(slashMsg) + + if !lv.filterActive { + t.Error("Expected filterActive to be true after '/'") + } + + // Deactivate with Esc + escMsg := tea.KeyPressMsg{Code: tea.KeyEscape} + lv.Update(escMsg) + + if lv.filterActive { + t.Error("Expected filterActive to be false after Esc") + } +} + +func TestLogViewFilterMatching(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + + entry1 := logEntry{timestamp: time.Now(), message: "ERROR: failed to connect"} + entry2 := logEntry{timestamp: time.Now(), message: "INFO: connection successful"} + entry3 := logEntry{timestamp: time.Now(), message: "ERROR: timeout"} + + // Case-insensitive substring match + lv.filterText = "error" + if !lv.matchesFilter(entry1) { + t.Error("Expected entry1 to match filter 'error'") + } + if lv.matchesFilter(entry2) { + t.Error("Expected entry2 to not match filter 'error'") + } + if !lv.matchesFilter(entry3) { + t.Error("Expected entry3 to match filter 'error'") + } + + // Empty filter matches all + lv.filterText = "" + if !lv.matchesFilter(entry1) || !lv.matchesFilter(entry2) || !lv.matchesFilter(entry3) { + t.Error("Expected all entries to match empty filter") + } +} + +func TestLogViewFilterClear(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + lv.logs = []logEntry{ + {timestamp: time.Now(), message: "line 1"}, + {timestamp: time.Now(), message: "line 2"}, + } + + // Set filter + lv.filterText = "test" + lv.filterInput.SetValue("test") + + // Clear filter with "c" + cMsg := tea.KeyPressMsg{Code: 0, Text: "c"} + lv.Update(cMsg) + + if lv.filterText != "" { + t.Errorf("Expected filterText to be empty after 'c', got %q", lv.filterText) + } + if lv.filterInput.Value() != "" { + t.Error("Expected filterInput value to be empty after 'c'") + } + if len(lv.logs) != 2 { + t.Error("Expected logs to remain after clearing filter") + } + + // Second "c" clears buffer + lv.Update(cMsg) + if len(lv.logs) != 0 { + t.Errorf("Expected logs to be cleared after second 'c', got %d logs", len(lv.logs)) + } +} + +func TestLogViewFilteredCount(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + lv.logs = []logEntry{ + {timestamp: time.Now(), message: "ERROR: test1"}, + {timestamp: time.Now(), message: "INFO: test2"}, + {timestamp: time.Now(), message: "ERROR: test3"}, + {timestamp: time.Now(), message: "WARN: test4"}, + } + + // No filter + count := lv.getDisplayedCount() + if count != 4 { + t.Errorf("getDisplayedCount() = %d, want 4", count) + } + + // Filter for "error" + lv.filterText = "error" + count = lv.getDisplayedCount() + if count != 2 { + t.Errorf("getDisplayedCount() with filter 'error' = %d, want 2", count) + } +} + +func TestLogViewHasActiveInput(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + + if lv.HasActiveInput() { + t.Error("Expected HasActiveInput to be false initially") + } + + lv.filterActive = true + if !lv.HasActiveInput() { + t.Error("Expected HasActiveInput to be true when filterActive") + } + + lv.filterActive = false + if lv.HasActiveInput() { + t.Error("Expected HasActiveInput to be false when filterActive is false") + } +} + +func TestLogViewFilterSetSizeRecalculation(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + + // Store original viewport height + originalHeight := lv.vp.Model.Height() + + // Set filter + lv.filterText = "test" + + // SetSize should adjust for filter UI + lv.SetSize(80, 24) + + // Viewport height should be reduced by 1 line + if lv.vp.Model.Height() != originalHeight-1 { + t.Errorf("Expected viewport height to be %d with filter, got %d", originalHeight-1, lv.vp.Model.Height()) + } + + // Clear filter + lv.filterText = "" + lv.SetSize(80, 24) + + // Viewport height should be restored + if lv.vp.Model.Height() != originalHeight { + t.Errorf("Expected viewport height to be %d after clearing filter, got %d", originalHeight, lv.vp.Model.Height()) + } +} + +func TestLogViewFilterStatusLine(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + + // No filter + status := lv.StatusLine() + if strings.Contains(status, "🔍") { + t.Error("Expected status line to not contain filter indicator without filter") + } + + // With filter + lv.filterText = "error" + status = lv.StatusLine() + if !strings.Contains(status, "🔍") || !strings.Contains(status, "error") { + t.Errorf("Expected status line to contain filter indicator, got %q", status) + } + + // Long filter truncation + lv.filterText = "this is a very long filter text that should be truncated" + status = lv.StatusLine() + if !strings.Contains(status, "...") { + t.Errorf("Expected long filter to be truncated, got %q", status) + } + + // Filter input active + lv.filterActive = true + status = lv.StatusLine() + if !strings.Contains(status, "Esc:cancel") || !strings.Contains(status, "Enter:done") { + t.Errorf("Expected filter input status line, got %q", status) + } +}