diff --git a/internal/view/log_view.go b/internal/view/log_view.go index f0b9488..b4d69a9 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" @@ -27,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 { @@ -47,6 +53,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 +90,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 +103,7 @@ func NewLogView(ctx context.Context, logGroupName string) *LogView { logs: make([]logEntry, 0, initialLogBufferSize), loading: true, pollInterval: defaultLogPollInterval, + filterInput: ti, } } @@ -295,7 +316,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 +343,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 +389,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 +413,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 +455,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 +499,61 @@ 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 - filterInputPadding + if filterWidth < minFilterWidth { + filterWidth = minFilterWidth + } + 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 + runes := []rune(filterDisplay) + if len(runes) > maxFilterDisplayLength { + filterDisplay = string(runes[:maxFilterDisplayLength-3]) + "..." + } + status = fmt.Sprintf("๐Ÿ” %s โ€ข ", filterDisplay) + status + } + if v.paused { return "โธ PAUSED โ€ข " + status } @@ -421,3 +562,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..62038ce 100644 --- a/internal/view/log_view_test.go +++ b/internal/view/log_view_test.go @@ -284,3 +284,244 @@ 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) + } +} + +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") + } +}