Skip to content

Commit e6f7180

Browse files
feat: Show context around keyword search matches
1 parent 55c2d4a commit e6f7180

2 files changed

Lines changed: 103 additions & 12 deletions

File tree

internal/ui/model.go

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ var (
5050
matchStyle = lipgloss.NewStyle().Background(lipgloss.Color("#FFFF00")).Foreground(lipgloss.Color("#000000"))
5151
)
5252

53+
const searchContextLineCount = 10
54+
55+
type filteredCandidate struct {
56+
line string
57+
textMatch bool
58+
}
59+
5360
type Point struct {
5461
X int
5562
Y int
@@ -748,8 +755,10 @@ func splitIncomingContent(content string) []string {
748755

749756
func (m *Model) applyFilters(resetView bool) {
750757
var filtered []string
758+
var candidates []filteredCandidate
751759
// Directly iterate over originalLines
752760
lines := m.originalLines
761+
filterLower := strings.ToLower(m.filterText)
753762

754763
// Pre-compile regex if in regex mode
755764
if m.filterText != "" {
@@ -795,23 +804,56 @@ func (m *Model) applyFilters(resetView bool) {
795804
}
796805

797806
// 3. Text/Regex Filtering
807+
textMatch := false
798808
if m.filterText != "" {
799809
if m.regexMode {
800-
if m.regex != nil && !m.regex.MatchString(line) {
801-
continue
810+
if m.regex != nil {
811+
textMatch = m.regex.MatchString(line)
802812
}
803813
} else {
804814
// Case-insensitive contains (old robust behavior)
805-
if !strings.Contains(strings.ToLower(line), strings.ToLower(m.filterText)) {
806-
continue
807-
}
815+
textMatch = strings.Contains(strings.ToLower(line), filterLower)
808816
}
809817
}
810818

811819
// Tab Normalization (Fixes offset drift in selection)
812820
line = strings.ReplaceAll(line, "\t", " ")
813821

814-
filtered = append(filtered, line)
822+
candidates = append(candidates, filteredCandidate{
823+
line: line,
824+
textMatch: textMatch,
825+
})
826+
}
827+
828+
if m.filterText == "" || (m.regexMode && m.regex == nil) {
829+
for _, candidate := range candidates {
830+
filtered = append(filtered, candidate.line)
831+
}
832+
} else {
833+
include := make([]bool, len(candidates))
834+
for i, candidate := range candidates {
835+
if !candidate.textMatch {
836+
continue
837+
}
838+
839+
start := i - searchContextLineCount
840+
if start < 0 {
841+
start = 0
842+
}
843+
end := i + searchContextLineCount
844+
if end >= len(candidates) {
845+
end = len(candidates) - 1
846+
}
847+
for j := start; j <= end; j++ {
848+
include[j] = true
849+
}
850+
}
851+
852+
for i, candidate := range candidates {
853+
if include[i] {
854+
filtered = append(filtered, candidate.line)
855+
}
856+
}
815857
}
816858

817859
// Stack Trace Folding Logic

internal/ui/model_test.go

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package ui
22

33
import (
4+
"fmt"
45
"testing"
56
)
67

@@ -52,21 +53,21 @@ func TestApplyFilters(t *testing.T) {
5253
}
5354

5455
m := InitialModel("test.log", lines, nil)
55-
56+
5657
// Test 1: No filters
5758
m.applyFilters(true)
5859
if len(m.filteredLines) != 4 {
5960
t.Errorf("Expected 4 lines, got %d", len(m.filteredLines))
6061
}
6162

62-
// Test 2: Filter Text
63+
// Test 2: Filter Text includes matching lines with surrounding context
6364
m.filterText = "Error"
6465
m.applyFilters(true)
65-
if len(m.filteredLines) != 1 {
66-
t.Errorf("Expected 1 error line, got %d", len(m.filteredLines))
66+
if len(m.filteredLines) != 4 {
67+
t.Errorf("Expected 4 context lines, got %d", len(m.filteredLines))
6768
}
68-
if len(m.filteredLines) > 0 && m.filteredLines[0] != lines[2] {
69-
t.Errorf("Expected line to be '%s', got '%s'", lines[2], m.filteredLines[0])
69+
if len(m.filteredLines) > 2 && m.filteredLines[2] != lines[2] {
70+
t.Errorf("Expected line to be '%s', got '%s'", lines[2], m.filteredLines[2])
7071
}
7172

7273
// Test 3: Level Filtering (Toggle off INFO)
@@ -79,6 +80,54 @@ func TestApplyFilters(t *testing.T) {
7980
}
8081
}
8182

83+
func TestApplyFiltersAddsKeywordContext(t *testing.T) {
84+
lines := make([]string, 35)
85+
for i := range lines {
86+
lines[i] = fmt.Sprintf("2023-01-01 10:00:%02d INFO line %02d", i, i)
87+
}
88+
lines[20] = "2023-01-01 10:00:20 ERROR target keyword"
89+
90+
m := InitialModel("test.log", lines, nil)
91+
m.filterText = "keyword"
92+
m.applyFilters(true)
93+
94+
if len(m.filteredLines) != 21 {
95+
t.Fatalf("expected 21 lines with context, got %d", len(m.filteredLines))
96+
}
97+
if m.filteredLines[0] != lines[10] {
98+
t.Fatalf("expected context to start at line 10, got %q", m.filteredLines[0])
99+
}
100+
if m.filteredLines[10] != lines[20] {
101+
t.Fatalf("expected match at context index 10, got %q", m.filteredLines[10])
102+
}
103+
if m.filteredLines[20] != lines[30] {
104+
t.Fatalf("expected context to end at line 30, got %q", m.filteredLines[20])
105+
}
106+
}
107+
108+
func TestApplyFiltersMergesOverlappingKeywordContext(t *testing.T) {
109+
lines := make([]string, 40)
110+
for i := range lines {
111+
lines[i] = fmt.Sprintf("2023-01-01 10:00:%02d INFO line %02d", i, i)
112+
}
113+
lines[12] = "2023-01-01 10:00:12 ERROR target keyword"
114+
lines[18] = "2023-01-01 10:00:18 ERROR another keyword"
115+
116+
m := InitialModel("test.log", lines, nil)
117+
m.filterText = "keyword"
118+
m.applyFilters(true)
119+
120+
if len(m.filteredLines) != 27 {
121+
t.Fatalf("expected merged context range without duplicates, got %d lines", len(m.filteredLines))
122+
}
123+
if m.filteredLines[0] != lines[2] {
124+
t.Fatalf("expected merged context to start at line 2, got %q", m.filteredLines[0])
125+
}
126+
if m.filteredLines[26] != lines[28] {
127+
t.Fatalf("expected merged context to end at line 28, got %q", m.filteredLines[26])
128+
}
129+
}
130+
82131
func TestResolvePos(t *testing.T) {
83132
// Setup a model with forced width
84133
lines := []string{

0 commit comments

Comments
 (0)