From 0e3e1081616b97bf39d2c1f06f6352e63194ae9d Mon Sep 17 00:00:00 2001 From: yimsk Date: Thu, 8 Jan 2026 00:26:17 +0900 Subject: [PATCH 1/2] Add filter to CloudWatch Logs tail view (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add filter functionality to CloudWatch Logs tail view 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 * Fix Backspace handling in filter input mode 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 * Simplify filter to client-side only 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 * Refactor: extract filter logic to matchesFilter method 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 * Fix filter issues: SetSize recalc, tests, min width, truncate 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 --------- Co-authored-by: Claude Sonnet 4.5 --- internal/view/log_view.go | 145 +++++++++++++++++++++++- internal/view/log_view_test.go | 200 +++++++++++++++++++++++++++++++++ 2 files changed, 342 insertions(+), 3 deletions(-) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index f0b9488..f326aa0 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,15 @@ type LogView struct { lastEventTime int64 oldestEventTime int64 pollInterval time.Duration + + // Size tracking + width int + height int + + // Filter state + filterInput textinput.Model + filterActive bool + filterText string // Filter text (client-side substring match) } type logEntry struct { @@ -75,6 +85,11 @@ func newLogViewStyles() logViewStyles { } func NewLogView(ctx context.Context, logGroupName string) *LogView { + ti := textinput.New() + ti.Placeholder = "Filter logs..." + ti.Prompt = "/" + ti.CharLimit = 200 + return &LogView{ ctx: ctx, logGroupName: logGroupName, @@ -83,6 +98,7 @@ func NewLogView(ctx context.Context, logGroupName string) *LogView { logs: make([]logEntry, 0, initialLogBufferSize), loading: true, pollInterval: defaultLogPollInterval, + filterInput: ti, } } @@ -295,7 +311,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 +338,16 @@ 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.filterText != "" { + v.filterText = "" + v.filterInput.SetValue("") + if v.vp.Ready { + v.updateViewportContent() + v.SetSize(v.width, v.height) // Recalculate viewport height + } + return v, nil + } v.logs = v.logs[:0] v.oldestEventTime = 0 if v.vp.Ready { @@ -349,9 +384,23 @@ 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 + for _, entry := range v.logs { + if !v.matchesFilter(entry) { + 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 +408,34 @@ 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.filterText = v.filterInput.Value() + if v.vp.Ready { + v.updateViewportContent() + } + return v, nil + default: + var cmd tea.Cmd + v.filterInput, cmd = v.filterInput.Update(msg) + + // Apply filter in real-time as user types + v.filterText = v.filterInput.Value() + if v.vp.Ready { + v.updateViewportContent() + } + + return v, cmd + } +} + func (v *LogView) ViewString() string { if !v.vp.Ready { return LoadingMessage @@ -373,11 +450,28 @@ 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.filterText != "" { + sb.WriteString(ui.AccentStyle().Render(fmt.Sprintf("๐Ÿ” filter: %s", v.filterText))) + 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.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))) + } sb.WriteString("\n\n") if v.loading { @@ -400,19 +494,60 @@ func (v *LogView) ViewString() string { return sb.String() } +func (v *LogView) getDisplayedCount() int { + if v.filterText == "" { + return len(v.logs) + } + count := 0 + for _, entry := range v.logs { + if v.matchesFilter(entry) { + 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 + 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) + + // Set filter input width with minimum check + filterWidth := width - 4 + if filterWidth < 10 { + filterWidth = 10 + } + v.filterInput.SetWidth(filterWidth) + 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 { + return "Esc:cancel Enter:done" + } + + status := "Space:pause/resume p:older g/G:top/bottom c:clear /:filter Esc:back" + + if v.filterText != "" { + filterDisplay := v.filterText + if len(filterDisplay) > 20 { + filterDisplay = filterDisplay[:17] + "..." + } + status = fmt.Sprintf("๐Ÿ” %s โ€ข ", filterDisplay) + status + } + if v.paused { return "โธ PAUSED โ€ข " + status } @@ -421,3 +556,7 @@ func (v *LogView) StatusLine() string { } return "โ–ถ STREAMING โ€ข " + status } + +func (v *LogView) HasActiveInput() bool { + return v.filterActive +} 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) + } +} From 13e48fbcb287df9a70eca3122302dddb61817ca5 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 7 Jan 2026 15:35:57 +0000 Subject: [PATCH 2/2] Improve filter: constants, Unicode support, test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #122 review feedback: 1. Extract magic numbers to constants: - filterInputPadding = 4 - minFilterWidth = 10 - maxFilterDisplayLength = 20 2. Fix Unicode slicing in status line: - Use rune-based slicing instead of byte slicing - Prevents breaking multi-byte UTF-8 characters (emoji, CJK) 3. Add Unicode filter test coverage: - Emoji filtering (๐Ÿ”ฅ, โœ…) - Japanese character filtering (ๆ—ฅๆœฌ่ชž) - Long Unicode filter truncation - Verify no broken encoding (๏ฟฝ) All tests pass, including new TestLogViewFilterUnicode. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- internal/view/log_view.go | 16 ++++++++----- internal/view/log_view_test.go | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/internal/view/log_view.go b/internal/view/log_view.go index f326aa0..b4d69a9 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -28,6 +28,11 @@ const ( maxLogBufferSize = 1000 logFetchLimit = 100 viewportHeaderOffset = 4 // header(1) + status(2) + spacing(1) + + // Filter UI constants + filterInputPadding = 4 // Padding for filter input width + minFilterWidth = 10 // Minimum filter input width + maxFilterDisplayLength = 20 // Maximum filter text length in status line ) type LogView struct { @@ -523,9 +528,9 @@ func (v *LogView) SetSize(width, height int) tea.Cmd { v.vp.SetSize(width, viewportHeight) // Set filter input width with minimum check - filterWidth := width - 4 - if filterWidth < 10 { - filterWidth = 10 + filterWidth := width - filterInputPadding + if filterWidth < minFilterWidth { + filterWidth = minFilterWidth } v.filterInput.SetWidth(filterWidth) @@ -542,8 +547,9 @@ func (v *LogView) StatusLine() string { if v.filterText != "" { filterDisplay := v.filterText - if len(filterDisplay) > 20 { - filterDisplay = filterDisplay[:17] + "..." + runes := []rune(filterDisplay) + if len(runes) > maxFilterDisplayLength { + filterDisplay = string(runes[:maxFilterDisplayLength-3]) + "..." } status = fmt.Sprintf("๐Ÿ” %s โ€ข ", filterDisplay) + status } diff --git a/internal/view/log_view_test.go b/internal/view/log_view_test.go index 1f9e5dd..62038ce 100644 --- a/internal/view/log_view_test.go +++ b/internal/view/log_view_test.go @@ -484,3 +484,44 @@ func TestLogViewFilterStatusLine(t *testing.T) { t.Errorf("Expected filter input status line, got %q", status) } } + +func TestLogViewFilterUnicode(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + + // Test emoji in log message + entry1 := logEntry{timestamp: time.Now(), message: "Error: ๐Ÿ”ฅ server crashed"} + entry2 := logEntry{timestamp: time.Now(), message: "Info: โœ… all good"} + entry3 := logEntry{timestamp: time.Now(), message: "Warning: ๆ—ฅๆœฌ่ชžใƒ†ใ‚นใƒˆ"} + + // Filter by emoji + lv.filterText = "๐Ÿ”ฅ" + if !lv.matchesFilter(entry1) { + t.Error("Expected entry1 to match emoji filter '๐Ÿ”ฅ'") + } + if lv.matchesFilter(entry2) { + t.Error("Expected entry2 to not match emoji filter '๐Ÿ”ฅ'") + } + + // Filter by Japanese characters + lv.filterText = "ๆ—ฅๆœฌ่ชž" + if !lv.matchesFilter(entry3) { + t.Error("Expected entry3 to match Japanese filter 'ๆ—ฅๆœฌ่ชž'") + } + if lv.matchesFilter(entry1) { + t.Error("Expected entry1 to not match Japanese filter") + } + + // Test truncation of long Unicode filter in status line + lv.filterText = "๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ" + status := lv.StatusLine() + if !strings.Contains(status, "...") { + t.Errorf("Expected long Unicode filter to be truncated, got %q", status) + } + // Verify it doesn't break Unicode characters + if strings.Contains(status, "๏ฟฝ") { + t.Error("Unicode truncation broke character encoding") + } +}