From e0ba9483c0c8b4f5a76b6aab30bc609d0f4f59fb Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Tue, 2 Sep 2025 14:00:39 -0500 Subject: [PATCH 01/15] feat: implement search state handling --- internal/commands/ci/view/search_test.go | 576 +++++++++++++++++++++++ internal/commands/ci/view/view.go | 138 ++++++ 2 files changed, 714 insertions(+) create mode 100644 internal/commands/ci/view/search_test.go diff --git a/internal/commands/ci/view/search_test.go b/internal/commands/ci/view/search_test.go new file mode 100644 index 000000000..d94779b0d --- /dev/null +++ b/internal/commands/ci/view/search_test.go @@ -0,0 +1,576 @@ +package view + +import ( + "strings" + "testing" + + "github.com/gdamore/tcell/v2" + "github.com/stretchr/testify/assert" +) + +// Test_searchState_initialization tests search state initialization per TextView +func Test_searchState_initialization(t *testing.T) { + // Test that search state is properly initialized for each job + jobName := "test-job-1" + + // Get search state for a job (should create new state) + state := getSearchState(jobName) + + assert.NotNil(t, state, "Search state should be created") + assert.False(t, state.Active, "Search should not be active initially") + assert.Equal(t, "", state.Query, "Search query should be empty initially") + assert.Empty(t, state.Matches, "Search matches should be empty initially") + assert.Equal(t, -1, state.CurrentMatch, "Current match should be -1 initially") + assert.Equal(t, 0, state.LastScrollPos, "Last scroll position should be 0 initially") + assert.False(t, state.InputMode, "Input mode should be false initially") + + // Test that getting the same job's state returns the same instance + state2 := getSearchState(jobName) + assert.Same(t, state, state2, "Should return same search state instance for same job") + + // Test that different jobs have different search states + state3 := getSearchState("different-job") + assert.NotSame(t, state, state3, "Different jobs should have different search states") +} + +// Test_searchState_toggleSearchMode tests entering/exiting search mode +// Should only be available once log text has been loaded after GetTraceSha() completes +func Test_searchState_toggleSearchMode(t *testing.T) { + jobName := "test-job" + + // Test that search mode cannot be activated without loaded content + t.Run("cannot activate without loaded content", func(t *testing.T) { + state := getSearchState(jobName) + + // Attempt to activate search mode when no content is loaded + canActivate := state.canActivateSearch("") + assert.False(t, canActivate, "Search should not be activatable without loaded content") + }) + + // Test search mode activation with loaded content + t.Run("can activate with loaded content", func(t *testing.T) { + state := getSearchState(jobName) + logContent := "Sample log line 1\nSample log line 2\nError occurred\n" + + // Should be able to activate search mode + canActivate := state.canActivateSearch(logContent) + assert.True(t, canActivate, "Search should be activatable with loaded content") + + // Activate search mode + state.activateSearch() + assert.True(t, state.Active, "Search should be active after activation") + assert.True(t, state.InputMode, "Input mode should be true after activation") + assert.Equal(t, "/", state.Query, "Query should start with '/' after activation") + }) + + // Test search mode deactivation + t.Run("can deactivate search mode", func(t *testing.T) { + state := getSearchState(jobName) + + // Start with active search + state.activateSearch() + state.updateQuery("/test") + + // Deactivate search + state.deactivateSearch() + assert.False(t, state.Active, "Search should not be active after deactivation") + assert.False(t, state.InputMode, "Input mode should be false after deactivation") + assert.Equal(t, "", state.Query, "Query should be empty after deactivation") + assert.Empty(t, state.Matches, "Matches should be cleared after deactivation") + assert.Equal(t, -1, state.CurrentMatch, "Current match should be -1 after deactivation") + }) + + // Test search state persistence across different jobs + t.Run("independent search states per job", func(t *testing.T) { + job1State := getSearchState("job1") + job2State := getSearchState("job2") + + // Activate search for job1 + job1State.activateSearch() + job1State.updateQuery("/job1search") + + // Activate different search for job2 + job2State.activateSearch() + job2State.updateQuery("/job2search") + + // Verify states are independent + assert.Equal(t, "/job1search", job1State.Query, "Job1 should maintain its search query") + assert.Equal(t, "/job2search", job2State.Query, "Job2 should maintain its search query") + assert.True(t, job1State.Active, "Job1 search should remain active") + assert.True(t, job2State.Active, "Job2 search should remain active") + }) +} + +// Test_searchState_updateQuery tests updating search query +func Test_searchState_updateQuery(t *testing.T) { + jobName := "test-job" + state := getSearchState(jobName) + state.activateSearch() // Start in search mode + + testCases := []struct { + name string + input string + expectedQuery string + expectedInput bool + }{ + { + name: "initial slash only", + input: "/", + expectedQuery: "/", + expectedInput: true, + }, + { + name: "single character", + input: "/h", + expectedQuery: "/h", + expectedInput: true, + }, + { + name: "multiple characters", + input: "/hello", + expectedQuery: "/hello", + expectedInput: true, + }, + { + name: "query with spaces", + input: "/hello world", + expectedQuery: "/hello world", + expectedInput: true, + }, + { + name: "special characters", + input: "/test-123_abc", + expectedQuery: "/test-123_abc", + expectedInput: true, + }, + { + name: "empty query after slash", + input: "/", + expectedQuery: "/", + expectedInput: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + state.updateQuery(tc.input) + assert.Equal(t, tc.expectedQuery, state.Query, "Query should match expected value") + assert.Equal(t, tc.expectedInput, state.InputMode, "Input mode should match expected value") + }) + } +} + +// Test_searchState_clearQuery tests clearing search query with backspace events +// Covers both MacOS and Windows backspace handling +func Test_searchState_clearQuery(t *testing.T) { + jobName := "test-job" + state := getSearchState(jobName) + state.activateSearch() + state.updateQuery("/hello") + + // Test backspace key (most systems) + t.Run("backspace key clears character", func(t *testing.T) { + originalQuery := state.Query + + handled := state.handleBackspace(tcell.KeyBackspace) + assert.True(t, handled, "Backspace should be handled") + + expectedQuery := originalQuery[:len(originalQuery)-1] + assert.Equal(t, expectedQuery, state.Query, "Query should have last character removed") + }) + + // Test backspace2 key (alternative systems, Windows) + t.Run("backspace2 key clears character", func(t *testing.T) { + state.updateQuery("/world") + originalQuery := state.Query + + handled := state.handleBackspace(tcell.KeyBackspace2) + assert.True(t, handled, "Backspace2 should be handled") + + expectedQuery := originalQuery[:len(originalQuery)-1] + assert.Equal(t, expectedQuery, state.Query, "Query should have last character removed") + }) + + // Test clearing entire query + t.Run("clear entire query", func(t *testing.T) { + state.updateQuery("/test") + + // Keep backspacing until only slash remains + for len(state.Query) > 1 { + state.handleBackspace(tcell.KeyBackspace) + } + + assert.Equal(t, "/", state.Query, "Query should be only slash after clearing") + + // One more backspace should exit search mode + handled := state.handleBackspace(tcell.KeyBackspace) + assert.True(t, handled, "Final backspace should be handled") + assert.False(t, state.Active, "Search should be deactivated after clearing slash") + }) + + // Test backspace when not in input mode + t.Run("backspace ignored when not in input mode", func(t *testing.T) { + state.activateSearch() + state.InputMode = false // Not in input mode (e.g., during navigation) + originalQuery := state.Query + + handled := state.handleBackspace(tcell.KeyBackspace) + assert.False(t, handled, "Backspace should not be handled when not in input mode") + assert.Equal(t, originalQuery, state.Query, "Query should remain unchanged") + }) +} + +// Test_performSearch_caseInsensitive tests case-insensitive search with match counting +func Test_performSearch_caseInsensitive(t *testing.T) { + jobName := "test-job" + state := getSearchState(jobName) + + // Sample log content with mixed case + logContent := `INFO: Application started +ERROR: Database connection failed +info: Retrying connection +DEBUG: Connection successful +error: User authentication failed +INFO: Request processed successfully` + + testCases := []struct { + name string + query string + expectedCount int + expectedMatches []SearchMatch + }{ + { + name: "case insensitive - 'error'", + query: "error", + expectedCount: 2, + expectedMatches: []SearchMatch{ + {Line: 1, Start: 0, End: 5, Text: "ERROR"}, + {Line: 4, Start: 0, End: 5, Text: "error"}, + }, + }, + { + name: "case insensitive - 'info'", + query: "info", + expectedCount: 3, + expectedMatches: []SearchMatch{ + {Line: 0, Start: 0, End: 4, Text: "INFO"}, + {Line: 2, Start: 0, End: 4, Text: "info"}, + {Line: 5, Start: 0, End: 4, Text: "INFO"}, + }, + }, + { + name: "case insensitive - 'CONNECTION'", + query: "CONNECTION", + expectedCount: 3, + expectedMatches: []SearchMatch{ + {Line: 1, Start: 16, End: 26, Text: "connection"}, + {Line: 2, Start: 15, End: 25, Text: "connection"}, + {Line: 3, Start: 7, End: 17, Text: "Connection"}, + }, + }, + { + name: "no matches", + query: "notfound", + expectedCount: 0, + expectedMatches: []SearchMatch{}, + }, + { + name: "empty query", + query: "", + expectedCount: 0, + expectedMatches: []SearchMatch{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + matches := state.performSearch(logContent, tc.query) + + assert.Equal(t, tc.expectedCount, len(matches), "Should find expected number of matches") + assert.Equal(t, tc.expectedCount, state.getMatchCount(), "Match count should be correct") + + // Verify specific matches + for i, expectedMatch := range tc.expectedMatches { + if i < len(matches) { + assert.Equal(t, expectedMatch.Line, matches[i].Line, "Match line should be correct") + assert.Equal(t, expectedMatch.Start, matches[i].Start, "Match start position should be correct") + assert.Equal(t, expectedMatch.End, matches[i].End, "Match end position should be correct") + // Note: Text comparison should be case-insensitive since we're testing case-insensitive search + } + } + + // Update state with matches + state.Matches = matches + if len(matches) > 0 { + state.CurrentMatch = 0 + } else { + state.CurrentMatch = -1 + } + }) + } +} + +// Test_searchState_persistenceAcrossJobSwitches tests search state persistence +// when switching between different job logs (key feature of per-TextView approach) +func Test_searchState_persistenceAcrossJobSwitches(t *testing.T) { + // Create search states for different jobs + job1 := "build-job" + job2 := "test-job" + job3 := "deploy-job" + + // Set up different search states for each job + t.Run("setup independent search states", func(t *testing.T) { + // Job 1: Active search for "error" + state1 := getSearchState(job1) + state1.activateSearch() + state1.updateQuery("/error") + state1.InputMode = false // Switched to navigation mode + state1.CurrentMatch = 2 // On 3rd match + + // Job 2: Active search for "info" + state2 := getSearchState(job2) + state2.activateSearch() + state2.updateQuery("/info") + state2.CurrentMatch = 0 // On 1st match + + // Job 3: No search active + state3 := getSearchState(job3) + assert.False(t, state3.Active, "Job3 should have no active search") + + // Verify states are independent + assert.Equal(t, "/error", state1.Query, "Job1 search query should persist") + assert.Equal(t, "/info", state2.Query, "Job2 search query should persist") + assert.Equal(t, "", state3.Query, "Job3 should have empty query") + + assert.Equal(t, 2, state1.CurrentMatch, "Job1 match position should persist") + assert.Equal(t, 0, state2.CurrentMatch, "Job2 match position should persist") + assert.Equal(t, -1, state3.CurrentMatch, "Job3 should have no matches") + }) + + // Test behavior when returning to previous job + t.Run("search state restored when returning to job", func(t *testing.T) { + // Simulate switching away from job1, then back + state1First := getSearchState(job1) + originalQuery := state1First.Query + originalMatch := state1First.CurrentMatch + + // Switch to different job + _ = getSearchState(job2) + + // Return to job1 - should get same state back + state1Second := getSearchState(job1) + assert.Same(t, state1First, state1Second, "Should return same search state instance") + assert.Equal(t, originalQuery, state1Second.Query, "Search query should be preserved") + assert.Equal(t, originalMatch, state1Second.CurrentMatch, "Match position should be preserved") + assert.True(t, state1Second.Active, "Search should still be active") + }) + + // Test clearing search state for specific job + t.Run("can clear search state for specific job", func(t *testing.T) { + // Clear search state for job1 + clearSearchState(job1) + + // Job1 state should be reset + state1New := getSearchState(job1) + assert.False(t, state1New.Active, "Job1 search should be cleared") + assert.Equal(t, "", state1New.Query, "Job1 query should be empty") + assert.Equal(t, -1, state1New.CurrentMatch, "Job1 match should be reset") + + // Other jobs should be unaffected + state2 := getSearchState(job2) + assert.True(t, state2.Active, "Job2 search should remain active") + assert.Equal(t, "/info", state2.Query, "Job2 query should be preserved") + }) +} + +// Test_performSearch_basicMatches tests basic string matching functionality +func Test_performSearch_basicMatches(t *testing.T) { + jobName := "test-job" + state := getSearchState(jobName) + + logContent := `Starting application +Processing request ID: 12345 +Request completed successfully +Starting cleanup process +Cleanup finished` + + testCases := []struct { + name string + query string + expectedCount int + description string + }{ + { + name: "single word match", + query: "Starting", + expectedCount: 2, + description: "Should find 'Starting' at beginning of two lines", + }, + { + name: "partial word match", + query: "request", + expectedCount: 2, + description: "Should find 'request' case-insensitively", + }, + { + name: "exact phrase match", + query: "cleanup process", + expectedCount: 1, + description: "Should find exact phrase", + }, + { + name: "number match", + query: "12345", + expectedCount: 1, + description: "Should find numeric content", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + matches := state.performSearch(logContent, tc.query) + assert.Equal(t, tc.expectedCount, len(matches), tc.description) + + // Verify all matches contain the query text (case-insensitive) + for _, match := range matches { + lines := strings.Split(logContent, "\n") + matchedText := lines[match.Line][match.Start:match.End] + assert.True(t, + strings.EqualFold(matchedText, tc.query), + "Matched text should equal query (case-insensitive)") + } + }) + } +} + +// Test_performSearch_noMatches tests behavior when no matches are found +func Test_performSearch_noMatches(t *testing.T) { + jobName := "test-job" + state := getSearchState(jobName) + + logContent := `Application running normally +All systems operational +No errors detected` + + testCases := []struct { + name string + query string + }{ + { + name: "non-existent word", + query: "failure", + }, + { + name: "partial word that doesn't exist", + query: "xyz", + }, + { + name: "phrase that doesn't exist", + query: "critical error", + }, + { + name: "special characters that don't exist", + query: "@#$%", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + matches := state.performSearch(logContent, tc.query) + + assert.Empty(t, matches, "Should return empty matches for non-existent query") + assert.Equal(t, 0, len(state.Matches), "State matches should be empty") + assert.Equal(t, 0, state.getMatchCount(), "Match count should be 0") + }) + } + + // Verify that performSearch doesn't modify CurrentMatch + t.Run("performSearch should not modify CurrentMatch", func(t *testing.T) { + state.CurrentMatch = 5 // Set to non-zero + state.performSearch(logContent, "nonexistent") + // CurrentMatch should remain unchanged by performSearch + assert.Equal(t, 5, state.CurrentMatch, "CurrentMatch should not be modified by performSearch") + }) +} + +// Test_performSearch_multipleMatches tests finding multiple matches across lines +func Test_performSearch_multipleMatches(t *testing.T) { + jobName := "test-job" + state := getSearchState(jobName) + + // Log content with multiple occurrences of search terms + logContent := `DEBUG: Starting test run +Running test: unit_test_1 +Test unit_test_1 passed +Running test: unit_test_2 +Test unit_test_2 failed +Running test: integration_test +Test integration_test passed +Summary: 2 tests passed, 1 test failed` + + testCases := []struct { + name string + query string + expectedCount int + expectedLines []int + verifyPositions bool + }{ + { + name: "word at different positions", + query: "Running", + expectedCount: 3, + expectedLines: []int{1, 3, 5}, + verifyPositions: true, + }, + { + name: "repeated word in same line", + query: "unit", + expectedCount: 4, // "unit_test_1" twice, "unit_test_2" twice + expectedLines: []int{1, 2, 3, 4}, + verifyPositions: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + matches := state.performSearch(logContent, tc.query) + + assert.Equal(t, tc.expectedCount, len(matches), + "Should find all occurrences of '%s'", tc.query) + + // Verify matches are in ascending order (line, then position) + for i := 1; i < len(matches); i++ { + prev := matches[i-1] + curr := matches[i] + + if curr.Line == prev.Line { + assert.True(t, curr.Start > prev.Start, + "Matches on same line should be in position order") + } else { + assert.True(t, curr.Line > prev.Line, + "Matches should be in line order") + } + } + + // Verify specific line numbers if provided + if tc.verifyPositions && len(tc.expectedLines) > 0 { + for i, expectedLine := range tc.expectedLines { + if i < len(matches) { + assert.Equal(t, expectedLine, matches[i].Line, + "Match %d should be on line %d", i, expectedLine) + } + } + } + + // Verify all matches contain the search text + lines := strings.Split(logContent, "\n") + for _, match := range matches { + matchedText := lines[match.Line][match.Start:match.End] + assert.True(t, + strings.EqualFold(matchedText, tc.query), + "Matched text '%s' should equal query '%s' (case-insensitive)", + matchedText, tc.query) + } + }) + } +} diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index 1c18e1e10..396e89984 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -66,6 +66,21 @@ type ViewJob struct { OriginalBridge *gitlab.Bridge } +type SearchState struct { + Active bool + Query string + Matches []SearchMatch + CurrentMatch int + LastScrollPos int + InputMode bool +} + +type SearchMatch struct { + Line int + Start int + End int +} + func ViewJobFromBridge(bridge *gitlab.Bridge) *ViewJob { vj := &ViewJob{} vj.ID = bridge.ID @@ -98,6 +113,128 @@ func ViewJobFromJob(job *gitlab.Job) *ViewJob { return vj } +// getSearchState returns the search state for a given job name, creating a new one if needed +func getSearchState(jobName string) *SearchState { + if searchStates == nil { + searchStates = make(map[string]*SearchState) + } + + if state, exists := searchStates[jobName]; exists { + return state + } + + // Create new search state with default values + state := &SearchState{ + Active: false, + Query: "", + Matches: []SearchMatch{}, + CurrentMatch: -1, + LastScrollPos: 0, + InputMode: false, + } + + searchStates[jobName] = state + return state +} + +// clearSearchState removes the search state for a given job name +func clearSearchState(jobName string) { + if searchStates != nil { + delete(searchStates, jobName) + } +} + +// canActivateSearch checks if search can be activated (needs loaded content) +func (s *SearchState) canActivateSearch(content string) bool { + return content != "" +} + +// activateSearch enters search mode +func (s *SearchState) activateSearch() { + s.Active = true + s.InputMode = true + s.Query = "/" +} + +// deactivateSearch exits search mode +func (s *SearchState) deactivateSearch() { + s.Active = false + s.InputMode = false + s.Query = "" + s.Matches = []SearchMatch{} + s.CurrentMatch = -1 +} + +// updateQuery updates the search query +func (s *SearchState) updateQuery(query string) { + s.Query = query +} + +// handleBackspace handles backspace key presses in search mode +func (s *SearchState) handleBackspace(key tcell.Key) bool { + if !s.InputMode || len(s.Query) == 0 { + return false + } + + if key == tcell.KeyBackspace || key == tcell.KeyBackspace2 { + if len(s.Query) > 1 { + s.Query = s.Query[:len(s.Query)-1] + } else { + // Exit search mode when deleting the last character (/) + s.deactivateSearch() + } + return true + } + return false +} + +// performSearch searches for matches in the given content +func (s *SearchState) performSearch(content, query string) []SearchMatch { + if query == "" { + s.Matches = []SearchMatch{} + return s.Matches + } + + var matches []SearchMatch + lines := strings.Split(content, "\n") + lowerQuery := strings.ToLower(query) + + for lineNum, line := range lines { + lowerLine := strings.ToLower(line) + searchStart := 0 + + for { + // Find next occurrence of query in the line (case-insensitive) + idx := strings.Index(lowerLine[searchStart:], lowerQuery) + if idx == -1 { + break + } + + // Calculate actual position in original line + actualStart := searchStart + idx + actualEnd := actualStart + len(query) + + matches = append(matches, SearchMatch{ + Line: lineNum, + Start: actualStart, + End: actualEnd, + }) + + // Move search position past this match + searchStart = actualEnd + } + } + + // Update state with the matches + s.Matches = matches + return matches +} + +// getMatchCount returns the number of search matches +func (s *SearchState) getMatchCount() int { + return len(s.Matches) +} + func NewCmdView(f cmdutils.Factory) *cobra.Command { opts := options{ io: f.IO(), @@ -431,6 +568,7 @@ var ( jobs []*ViewJob pipelines []gitlab.PipelineInfo boxes map[string]*tview.TextView + searchStates map[string]*SearchState ) func curPipeline(commit *gitlab.Commit) gitlab.PipelineInfo { -- GitLab From a6ff942402d0f02cfbf92c909b5d2298ccd8312f Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Tue, 2 Sep 2025 14:59:59 -0500 Subject: [PATCH 02/15] feat: handle search keyboard inputs and update state --- internal/commands/ci/view/search_test.go | 412 ++++++++++++++++++++++- internal/commands/ci/view/view.go | 108 ++++++ 2 files changed, 512 insertions(+), 8 deletions(-) diff --git a/internal/commands/ci/view/search_test.go b/internal/commands/ci/view/search_test.go index d94779b0d..237aa44a3 100644 --- a/internal/commands/ci/view/search_test.go +++ b/internal/commands/ci/view/search_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" "github.com/stretchr/testify/assert" ) @@ -244,8 +245,8 @@ INFO: Request processed successfully` query: "error", expectedCount: 2, expectedMatches: []SearchMatch{ - {Line: 1, Start: 0, End: 5, Text: "ERROR"}, - {Line: 4, Start: 0, End: 5, Text: "error"}, + {Line: 1, Start: 0, End: 5}, + {Line: 4, Start: 0, End: 5}, }, }, { @@ -253,9 +254,9 @@ INFO: Request processed successfully` query: "info", expectedCount: 3, expectedMatches: []SearchMatch{ - {Line: 0, Start: 0, End: 4, Text: "INFO"}, - {Line: 2, Start: 0, End: 4, Text: "info"}, - {Line: 5, Start: 0, End: 4, Text: "INFO"}, + {Line: 0, Start: 0, End: 4}, + {Line: 2, Start: 0, End: 4}, + {Line: 5, Start: 0, End: 4}, }, }, { @@ -263,9 +264,9 @@ INFO: Request processed successfully` query: "CONNECTION", expectedCount: 3, expectedMatches: []SearchMatch{ - {Line: 1, Start: 16, End: 26, Text: "connection"}, - {Line: 2, Start: 15, End: 25, Text: "connection"}, - {Line: 3, Start: 7, End: 17, Text: "Connection"}, + {Line: 1, Start: 16, End: 26}, + {Line: 2, Start: 15, End: 25}, + {Line: 3, Start: 7, End: 17}, }, }, { @@ -574,3 +575,398 @@ Summary: 2 tests passed, 1 test failed` }) } } + +// Test_shouldActivateSearch tests the pure logic for when search can be activated +func Test_shouldActivateSearch(t *testing.T) { + testCases := []struct { + name string + logsVisible bool + modalVisible bool + logContent string + expected bool + description string + }{ + { + name: "should activate - logs visible, no modal, has content", + logsVisible: true, + modalVisible: false, + logContent: "Sample log content", + expected: true, + description: "Normal case - all conditions met for search activation", + }, + { + name: "should not activate - no logs visible", + logsVisible: false, + modalVisible: false, + logContent: "Sample log content", + expected: false, + description: "Cannot search when no logs are visible", + }, + { + name: "should not activate - modal visible", + logsVisible: true, + modalVisible: true, + logContent: "Sample log content", + expected: false, + description: "Cannot search when confirmation modal is open", + }, + { + name: "should not activate - no content", + logsVisible: true, + modalVisible: false, + logContent: "", + expected: false, + description: "Cannot search when log content is empty (still loading)", + }, + { + name: "should not activate - multiple conditions false", + logsVisible: false, + modalVisible: true, + logContent: "", + expected: false, + description: "Multiple blocking conditions should prevent activation", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := shouldActivateSearch(tc.logsVisible, tc.modalVisible, tc.logContent) + assert.Equal(t, tc.expected, result, tc.description) + }) + } +} + +// Test_handleSearchSlash tests "/" key search activation logic +func Test_handleSearchSlash(t *testing.T) { + jobName := "test-job" + + t.Run("activates search when conditions are met", func(t *testing.T) { + state := getSearchState(jobName) + state.deactivateSearch() // Ensure clean state + + consumed := handleSearchSlash(state, true, false, "log content") + + assert.True(t, consumed, "Should consume / key when activating search") + assert.True(t, state.Active, "Search should be active after / key") + assert.True(t, state.InputMode, "Should be in input mode after / key") + assert.Equal(t, "/", state.Query, "Query should start with /") + }) + + t.Run("does not activate when logs not visible", func(t *testing.T) { + state := getSearchState(jobName) + state.deactivateSearch() + + consumed := handleSearchSlash(state, false, false, "log content") + + assert.False(t, consumed, "Should not consume / key when logs not visible") + assert.False(t, state.Active, "Search should not be active") + }) + + t.Run("does not activate when modal visible", func(t *testing.T) { + state := getSearchState(jobName) + state.deactivateSearch() + + consumed := handleSearchSlash(state, true, true, "log content") + + assert.False(t, consumed, "Should not consume / key when modal visible") + assert.False(t, state.Active, "Search should not be active") + }) + + t.Run("does not activate when no content", func(t *testing.T) { + state := getSearchState(jobName) + state.deactivateSearch() + + consumed := handleSearchSlash(state, true, false, "") + + assert.False(t, consumed, "Should not consume / key when no content") + assert.False(t, state.Active, "Search should not be active") + }) +} + +// Test_handleSearchEscape tests escape key handling in search mode +func Test_handleSearchEscape(t *testing.T) { + jobName := "test-job" + + t.Run("exits search when search is active", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + state.updateQuery("/test query") + + consumed := handleSearchEscape(state) + + assert.True(t, consumed, "Should consume Esc key when search is active") + assert.False(t, state.Active, "Search should be deactivated after Esc") + assert.False(t, state.InputMode, "Input mode should be false after Esc") + assert.Equal(t, "", state.Query, "Query should be cleared after Esc") + }) + + t.Run("does not consume when search not active", func(t *testing.T) { + state := getSearchState(jobName) + state.deactivateSearch() + + consumed := handleSearchEscape(state) + + assert.False(t, consumed, "Should not consume Esc key when search not active") + // This allows normal Esc handling (hide logs, etc.) + }) +} + +// Test_handleSearchEnter tests enter key handling in search mode +func Test_handleSearchEnter(t *testing.T) { + jobName := "test-job" + logContent := "error on line 1\ninfo message\nerror on line 3\nmore info\nerror on line 5" + + t.Run("submits search when in input mode", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + state.updateQuery("/error") + assert.True(t, state.InputMode, "Should be in input mode initially") + + consumed := handleSearchEnter(state, logContent) + + assert.True(t, consumed, "Should consume Enter key when submitting search") + assert.True(t, state.Active, "Search should still be active after Enter") + assert.False(t, state.InputMode, "Should exit input mode after Enter") + assert.Equal(t, 3, len(state.Matches), "Should find 3 matches for 'error'") + assert.Equal(t, 0, state.CurrentMatch, "Should start at first match") + }) + + t.Run("navigates to next match when not in input mode", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + state.updateQuery("/error") + state.performSearch(logContent, "error") + state.InputMode = false // Switch to navigation mode + state.CurrentMatch = 0 // Start at first match + + consumed := handleSearchEnter(state, logContent) + + assert.True(t, consumed, "Should consume Enter key for navigation") + assert.Equal(t, 1, state.CurrentMatch, "Should move to next match") + }) + + t.Run("wraps around to first match", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + state.performSearch(logContent, "error") + state.InputMode = false + state.CurrentMatch = 2 // Last match (0-indexed) + + consumed := handleSearchEnter(state, logContent) + + assert.True(t, consumed, "Should consume Enter key") + assert.Equal(t, 0, state.CurrentMatch, "Should wrap to first match") + }) + + t.Run("does not consume when search not active", func(t *testing.T) { + state := getSearchState(jobName) + state.deactivateSearch() + + consumed := handleSearchEnter(state, logContent) + + assert.False(t, consumed, "Should not consume Enter when search not active") + // This allows normal Enter handling (toggle logs, etc.) + }) +} + +// Test_handleSearchKeyInput tests character input handling in search mode +func Test_handleSearchKeyInput(t *testing.T) { + jobName := "test-job" + + t.Run("adds characters to query when in input mode", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + assert.Equal(t, "/", state.Query, "Query should start with /") + + // Test individual characters + testChars := []struct { + char rune + expected string + }{ + {'e', "/e"}, + {'r', "/er"}, + {'r', "/err"}, + {'o', "/erro"}, + {'r', "/error"}, + } + + for _, tc := range testChars { + consumed := handleSearchKeyInput(state, tcell.KeyRune, tc.char) + + assert.True(t, consumed, "Should consume character input") + assert.Equal(t, tc.expected, state.Query, "Query should include typed character") + assert.True(t, state.InputMode, "Should remain in input mode") + } + }) + + t.Run("handles special characters", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + + specialChars := []rune{'-', '_', '.', ':', ' ', '(', ')', '[', ']', '@', '#'} + + for _, char := range specialChars { + state.updateQuery("/") // Reset query + + consumed := handleSearchKeyInput(state, tcell.KeyRune, char) + + assert.True(t, consumed, "Should consume special character: %c", char) + expected := "/" + string(char) + assert.Equal(t, expected, state.Query, "Should add special character to query") + } + }) + + t.Run("handles backspace keys", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + state.updateQuery("/hello") + + // Test KeyBackspace + consumed := handleSearchKeyInput(state, tcell.KeyBackspace, 0) + + assert.True(t, consumed, "Should consume backspace key") + assert.Equal(t, "/hell", state.Query, "Should remove last character") + }) + + t.Run("handles backspace2 for cross-platform support", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + state.updateQuery("/world") + + // Test KeyBackspace2 (Windows/alternative systems) + consumed := handleSearchKeyInput(state, tcell.KeyBackspace2, 0) + + assert.True(t, consumed, "Should consume backspace2 key") + assert.Equal(t, "/worl", state.Query, "Should remove last character") + }) + + t.Run("ignores newline characters", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + originalQuery := state.Query + + // Test newline characters + consumed1 := handleSearchKeyInput(state, tcell.KeyRune, '\n') + consumed2 := handleSearchKeyInput(state, tcell.KeyRune, '\r') + + assert.False(t, consumed1, "Should not consume newline") + assert.False(t, consumed2, "Should not consume carriage return") + assert.Equal(t, originalQuery, state.Query, "Query should be unchanged") + }) + + t.Run("does not consume when search not active", func(t *testing.T) { + state := getSearchState(jobName) + state.deactivateSearch() + + consumed := handleSearchKeyInput(state, tcell.KeyRune, 'a') + + assert.False(t, consumed, "Should not consume input when search not active") + }) + + t.Run("does not consume when not in input mode", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + state.InputMode = false // Switch to navigation mode + + consumed := handleSearchKeyInput(state, tcell.KeyRune, 'a') + + assert.False(t, consumed, "Should not consume input when not in input mode") + }) +} + +// Test_searchIntegration_inputCaptureWiring tests that the extracted search functions +// are properly wired into the inputCapture function +func Test_searchIntegration_inputCaptureWiring(t *testing.T) { + // This test will fail until you integrate the search functions into inputCapture + // When implemented, remove the t.Skip() above + + jobName := "integration-test-job" + logContent := "Sample log content for search testing" + + // Save original global state for cleanup + originalLogsVisible := logsVisible + originalModalVisible := modalVisible + originalCurJob := curJob + + // Cleanup function to restore original state + defer func() { + logsVisible = originalLogsVisible + modalVisible = originalModalVisible + curJob = originalCurJob + }() + + // Setup global state that inputCapture depends on + logsVisible = true + modalVisible = false + curJob = &ViewJob{Name: jobName, Kind: Job} + + // Create mock components that inputCapture needs + screen := tcell.NewSimulationScreen("") + defer screen.Fini() + + app := tview.NewApplication() + app.SetScreen(screen) + + root := tview.NewPages() + inputCh := make(chan struct{}, 10) + forceUpdateCh := make(chan bool, 10) + var navi navigator + + // Add log page with content + tv := tview.NewTextView() + tv.SetText(logContent) + root.AddPage("logs-"+jobName, tv, true, true) + + // Initialize boxes map and add the TextView + if boxes == nil { + boxes = make(map[string]*tview.TextView) + } + boxes["logs-"+jobName] = tv + + // Create the actual inputCapture function + capture := inputCapture(app, root, navi, inputCh, forceUpdateCh, &options{}, nil, "project", "sha") + + t.Run("slash key activates search", func(t *testing.T) { + // Ensure search starts inactive + state := getSearchState(jobName) + state.deactivateSearch() + + // Press "/" key + event := tcell.NewEventKey(tcell.KeyRune, '/', tcell.ModNone) + result := capture(event) + + // Verify search was activated + assert.Nil(t, result, "inputCapture should consume / key when activating search") + assert.True(t, state.Active, "Search should be active after / key") + assert.Equal(t, "/", state.Query, "Query should start with /") + }) + + t.Run("escape key exits search", func(t *testing.T) { + // Setup active search + state := getSearchState(jobName) + state.activateSearch() + state.updateQuery("/test") + + // Press Escape key + event := tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone) + result := capture(event) + + // Verify search was deactivated + assert.Nil(t, result, "inputCapture should consume Esc key when exiting search") + assert.False(t, state.Active, "Search should be deactivated after Esc") + }) + + t.Run("character input works in search mode", func(t *testing.T) { + // Setup active search + state := getSearchState(jobName) + state.activateSearch() + + // Type a character + event := tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone) + result := capture(event) + + // Verify character was added + assert.Nil(t, result, "inputCapture should consume character input in search mode") + assert.Equal(t, "/a", state.Query, "Character should be added to search query") + }) +} diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index 396e89984..ebd3b5523 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -235,6 +235,75 @@ func (s *SearchState) getMatchCount() int { return len(s.Matches) } +// shouldActivateSearch determines if search can be activated based on current state +func shouldActivateSearch(logsVisible, modalVisible bool, logContent string) bool { + return logsVisible && !modalVisible && logContent != "" +} + +// handleSearchKeyInput processes key input when search is active +func handleSearchKeyInput(state *SearchState, key tcell.Key, char rune) bool { + if !state.Active || !state.InputMode { + return false + } + + // Handle backspace keys (both KeyBackspace and KeyBackspace2 for cross-platform support) + if key == tcell.KeyBackspace || key == tcell.KeyBackspace2 { + return state.handleBackspace(key) + } + + // Handle regular character input + if char != 0 && char != '\n' && char != '\r' { + state.Query += string(char) + return true + } + + return false +} + +// handleSearchEscape processes escape key when search might be active +func handleSearchEscape(state *SearchState) bool { + if !state.Active { + return false // Let normal escape handling take over + } + + state.deactivateSearch() + return true // Consumed the escape key +} + +// handleSearchEnter processes enter key when search might be active +func handleSearchEnter(state *SearchState, logContent string) bool { + if !state.Active { + return false // Let normal enter handling take over + } + + if state.InputMode { + // Submit search query - switch from input mode to navigation mode + query := state.Query[1:] // Remove the leading "/" + state.performSearch(logContent, query) + state.InputMode = false + if len(state.Matches) > 0 { + state.CurrentMatch = 0 // Start at first match + } + return true // Consumed the enter key + } else { + // Navigate to next match + if len(state.Matches) > 0 { + state.CurrentMatch = (state.CurrentMatch + 1) % len(state.Matches) + } + return true // Consumed the enter key + } +} + +// handleSearchSlash processes "/" key for search activation +func handleSearchSlash(state *SearchState, logsVisible, modalVisible bool, logContent string) bool { + if !shouldActivateSearch(logsVisible, modalVisible, logContent) { + return false // Don't consume the key + } + + state.activateSearch() + return true // Consumed the "/" key +} + func NewCmdView(f cmdutils.Factory) *cobra.Command { opts := options{ io: f.IO(), @@ -395,6 +464,45 @@ func inputCapture( commitSHA string, ) func(event *tcell.EventKey) *tcell.EventKey { return func(event *tcell.EventKey) *tcell.EventKey { + // Handle search functionality when logs are visible + if logsVisible && curJob != nil { + searchState := getSearchState(curJob.Name) + + // Get log content for search operations + var logContent string + if tv, exists := boxes["logs-"+curJob.Name]; exists { + logContent = tv.GetText(false) // false = don't strip formatting + } + + // Handle slash key for search activation + if event.Rune() == '/' { + if handleSearchSlash(searchState, logsVisible, modalVisible, logContent) { + return nil // Consumed the key + } + } + + // Handle escape key for search exit + if event.Key() == tcell.KeyEscape { + if handleSearchEscape(searchState) { + return nil // Consumed the key + } + } + + // Handle enter key for search submission/navigation + if event.Key() == tcell.KeyEnter { + if handleSearchEnter(searchState, logContent) { + return nil // Consumed the key + } + } + + // Handle character and backspace input in search mode + if searchState.Active && searchState.InputMode { + if handleSearchKeyInput(searchState, event.Key(), event.Rune()) { + return nil // Consumed the key + } + } + } + if event.Rune() == 'q' || event.Key() == tcell.KeyEscape { switch { case modalVisible: -- GitLab From 422eefc6be8cb06f85d4d7d769f22a1390c93fd1 Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Wed, 3 Sep 2025 11:15:14 -0500 Subject: [PATCH 03/15] fix: update TextView state handlingalso adds logging to stderr for debugging --- internal/commands/ci/view/view.go | 164 +++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 3 deletions(-) diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index ebd3b5523..9cf50cf0c 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -7,6 +7,7 @@ import ( "io" "log" "os" + "path/filepath" "runtime/debug" "strings" "time" @@ -151,6 +152,7 @@ func (s *SearchState) canActivateSearch(content string) bool { // activateSearch enters search mode func (s *SearchState) activateSearch() { + debugLog("activateSearch - setting Active=true, InputMode=true, Query='/'") s.Active = true s.InputMode = true s.Query = "/" @@ -272,13 +274,26 @@ func handleSearchEscape(state *SearchState) bool { // handleSearchEnter processes enter key when search might be active func handleSearchEnter(state *SearchState, logContent string) bool { + debugLog("handleSearchEnter - Active:%v InputMode:%v Query:'%s' MatchCount:%d", + state.Active, state.InputMode, state.Query, len(state.Matches)) if !state.Active { + debugLog("handleSearchEnter - search not active, letting normal Enter handling take over") return false // Let normal enter handling take over } if state.InputMode { // Submit search query - switch from input mode to navigation mode + debugLog("handleSearchEnter - submitting search query") query := state.Query[1:] // Remove the leading "/" + + // If query is empty (just pressed "/" then Enter), don't perform search + // but still exit search mode and stay in log view + if strings.TrimSpace(query) == "" { + debugLog("handleSearchEnter - empty query, deactivating search") + state.deactivateSearch() + return true // Consume the key so it doesn't close the log + } + state.performSearch(logContent, query) state.InputMode = false if len(state.Matches) > 0 { @@ -287,6 +302,7 @@ func handleSearchEnter(state *SearchState, logContent string) bool { return true // Consumed the enter key } else { // Navigate to next match + debugLog("handleSearchEnter - navigating to next match") if len(state.Matches) > 0 { state.CurrentMatch = (state.CurrentMatch + 1) % len(state.Matches) } @@ -296,10 +312,14 @@ func handleSearchEnter(state *SearchState, logContent string) bool { // handleSearchSlash processes "/" key for search activation func handleSearchSlash(state *SearchState, logsVisible, modalVisible bool, logContent string) bool { + debugLog("handleSearchSlash - logsVisible:%v modalVisible:%v contentLen:%d", + logsVisible, modalVisible, len(logContent)) if !shouldActivateSearch(logsVisible, modalVisible, logContent) { + debugLog("handleSearchSlash - shouldActivateSearch returned false") return false // Don't consume the key } + debugLog("handleSearchSlash - activating search") state.activateSearch() return true // Consumed the "/" key } @@ -373,6 +393,10 @@ func (o *options) complete(args []string) error { } func (o *options) run() error { + // Initialize debug logging + initDebugLogger() + debugLog("Starting glab ci view") + client, err := o.gitlabClient() if err != nil { return err @@ -464,34 +488,62 @@ func inputCapture( commitSHA string, ) func(event *tcell.EventKey) *tcell.EventKey { return func(event *tcell.EventKey) *tcell.EventKey { + // DEBUG: Trace input events + debugLog("inputCapture - Key:%v Rune:%c logsVisible:%v modalVisible:%v curJob:%s", + event.Key(), event.Rune(), logsVisible, modalVisible, + func() string { + if curJob != nil { + return curJob.Name + } else { + return "nil" + } + }()) + // Handle search functionality when logs are visible if logsVisible && curJob != nil { searchState := getSearchState(curJob.Name) + debugLog("Search check - searchState.Active:%v InputMode:%v", searchState.Active, searchState.InputMode) // Get log content for search operations var logContent string - if tv, exists := boxes["logs-"+curJob.Name]; exists { - logContent = tv.GetText(false) // false = don't strip formatting + logsKey := "logs-" + curJob.Name + if logViews != nil { + if tv, exists := logViews[logsKey]; exists { + logContent = tv.GetText(false) // false = don't strip formatting + debugLog("Got log content from logViews %s, length: %d", logsKey, len(logContent)) + } else { + debugLog("LogView %s not found in logViews map", logsKey) + } + } else { + debugLog("logViews map is nil") } // Handle slash key for search activation if event.Rune() == '/' { + debugLog("Slash key pressed") if handleSearchSlash(searchState, logsVisible, modalVisible, logContent) { + debugLog("Slash key consumed by search") return nil // Consumed the key } } // Handle escape key for search exit if event.Key() == tcell.KeyEscape { + debugLog("Escape key pressed") if handleSearchEscape(searchState) { + debugLog("Escape key consumed by search") return nil // Consumed the key } } // Handle enter key for search submission/navigation if event.Key() == tcell.KeyEnter { + debugLog("Enter key pressed in search section") if handleSearchEnter(searchState, logContent) { + debugLog("Enter key consumed by search") return nil // Consumed the key + } else { + debugLog("Enter key NOT consumed by search - will go to normal handling") } } @@ -609,12 +661,25 @@ func inputCapture( app.ForceDraw() return nil case tcell.KeyEnter: + debugLog("Normal Enter key handling - modalVisible:%v curJob.Kind:%v", + modalVisible, func() string { + if curJob != nil { + return string(curJob.Kind) + } else { + return "nil" + } + }()) if !modalVisible { if curJob.Kind == Job { + debugLog("Toggling logsVisible from %v to %v", logsVisible, !logsVisible) logsVisible = !logsVisible if !logsVisible { + debugLog("Hiding logs page for job: %s", curJob.Name) root.HidePage("logs-" + curJob.Name) + } else { + debugLog("Will show logs for job: %s", curJob.Name) } + debugLog("Sending to inputCh") inputCh <- struct{}{} app.ForceDraw() } else { @@ -676,7 +741,9 @@ var ( jobs []*ViewJob pipelines []gitlab.PipelineInfo boxes map[string]*tview.TextView + logViews map[string]*tview.TextView searchStates map[string]*SearchState + debugLogger *log.Logger ) func curPipeline(commit *gitlab.Commit) gitlab.PipelineInfo { @@ -686,6 +753,37 @@ func curPipeline(commit *gitlab.Commit) gitlab.PipelineInfo { return pipelines[len(pipelines)-1] } +// Debug logging functions +func initDebugLogger() { + homeDir, err := os.UserHomeDir() + if err != nil { + return + } + + logDir := filepath.Join(homeDir, ".glab-cli") + err = os.MkdirAll(logDir, 0755) + if err != nil { + return + } + + logFile, err := os.OpenFile( + filepath.Join(logDir, "logs.txt"), + os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + 0644, + ) + if err != nil { + return + } + + debugLogger = log.New(logFile, "[DEBUG] ", log.Ldate|log.Ltime|log.Lmicroseconds) +} + +func debugLog(format string, args ...interface{}) { + if debugLogger != nil { + debugLogger.Printf(format, args...) + } +} + // navigator manages the internal state for processing tcell.EventKeys type navigator struct { depth, idx int @@ -805,11 +903,29 @@ func jobsView( curJob = jobs[0] } if modalVisible { + debugLog("jobsView - modalVisible=true, returning early") return } + debugLog("jobsView - logsVisible:%v curJob:%s", + logsVisible, func() string { + if curJob != nil { + return curJob.Name + } else { + return "nil" + } + }()) if logsVisible { logsKey := "logs-" + curJob.Name - if !root.SwitchToPage(logsKey).HasPage(logsKey) { + debugLog("jobsView - checking for existing page: %s", logsKey) + + // Check if there's any leftover search state that might interfere + searchState := getSearchState(curJob.Name) + debugLog("jobsView - search state for %s: Active=%t InputMode=%t Query='%s'", curJob.Name, searchState.Active, searchState.InputMode, searchState.Query) + + pageExists := root.SwitchToPage(logsKey).HasPage(logsKey) + debugLog("jobsView - page %s exists: %v", logsKey, pageExists) + if !pageExists { + debugLog("jobsView - page %s does not exist, creating new one", logsKey) tv := tview.NewTextView() tv. SetDynamicColors(true). @@ -817,7 +933,16 @@ func jobsView( SetBorderPadding(0, 0, 1, 1). SetBorder(true) + // Store the TextView in logViews map for search functionality + if logViews == nil { + logViews = make(map[string]*tview.TextView) + } + logViews[logsKey] = tv + debugLog("jobsView - stored TextView in logViews map: %s", logsKey) + + debugLog("jobsView - launching goroutine to fetch logs for: %s", curJob.Name) go func() { + debugLog("goroutine - starting RunTraceSha for: %s", curJob.Name) err := ciutils.RunTraceSha( context.Background(), apiClient, @@ -827,11 +952,44 @@ func jobsView( curJob.Name, ) if err != nil { + debugLog("goroutine - RunTraceSha error for %s: %v", curJob.Name, err) app.Stop() log.Fatal(err) } + debugLog("goroutine - RunTraceSha completed for: %s", curJob.Name) + + // Verify content was written to TextView + content := tv.GetText(false) + debugLog("goroutine - TextView content length after RunTraceSha: %d", len(content)) + + // Force a UI update to ensure the content is displayed + debugLog("goroutine - calling app.Draw() to refresh UI after content load") + app.Draw() + + // Also trigger the main UI loop + debugLog("goroutine - sending signal to inputCh to trigger main UI loop") + inputCh <- struct{}{} }() root.AddAndSwitchToPage("logs-"+curJob.Name, tv, true) + debugLog("jobsView - added and switched to new page: logs-%s", curJob.Name) + } else { + debugLog("jobsView - page %s already exists, switching to it", logsKey) + // Verify that SwitchToPage actually made it the front page + currentPageName, _ := root.GetFrontPage() + debugLog("jobsView - current front page after switch: %s", currentPageName) + // Verify the existing page has content + if logViews != nil { + if tv, exists := logViews[logsKey]; exists { + content := tv.GetText(false) + debugLog("jobsView - existing page %s content length: %d", logsKey, len(content)) + + // Check TextView properties + x, y, w, h := tv.GetRect() + debugLog("jobsView - TextView rect: (%d,%d,%d,%d)", x, y, w, h) + } else { + debugLog("jobsView - WARNING: page %s exists in root but not in logViews map", logsKey) + } + } } return } -- GitLab From 8fe5854acdd2a2e4206bc78bcbcf642e2d761153 Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Wed, 3 Sep 2025 14:24:12 -0500 Subject: [PATCH 04/15] fix(ci view): fix scope of runTrace() state variables --- internal/commands/ci/ciutils/utils.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/internal/commands/ci/ciutils/utils.go b/internal/commands/ci/ciutils/utils.go index f41050370..40feef19c 100644 --- a/internal/commands/ci/ciutils/utils.go +++ b/internal/commands/ci/ciutils/utils.go @@ -24,11 +24,6 @@ import ( gitlab "gitlab.com/gitlab-org/api/client-go" ) -var ( - once sync.Once - offset int64 -) - func makeHyperlink(s *iostreams.IOStreams, pipeline *gitlab.PipelineInfo) string { return s.Hyperlink(fmt.Sprintf("%d", pipeline.ID), pipeline.WebURL) } @@ -90,6 +85,9 @@ func RunTraceSha(ctx context.Context, apiClient *gitlab.Client, w io.Writer, pid } func runTrace(ctx context.Context, apiClient *gitlab.Client, w io.Writer, pid any, jobId int) error { + var once sync.Once + var offset int64 + fmt.Fprintln(w, "Getting job trace...") for range time.NewTicker(time.Second * 3).C { if ctx.Err() == context.Canceled { -- GitLab From fa0a6d9d1108a859057015a46bcc591f090d98a3 Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Wed, 3 Sep 2025 14:59:34 -0500 Subject: [PATCH 05/15] fix: update tests after change to TextView handlingalso remove debug logs --- internal/commands/ci/trace/trace_test.go | 6 +- internal/commands/ci/view/search_test.go | 6 + internal/commands/ci/view/view.go | 141 +---------------------- 3 files changed, 10 insertions(+), 143 deletions(-) diff --git a/internal/commands/ci/trace/trace_test.go b/internal/commands/ci/trace/trace_test.go index 4118800d4..477b2e21b 100644 --- a/internal/commands/ci/trace/trace_test.go +++ b/internal/commands/ci/trace/trace_test.go @@ -66,7 +66,7 @@ func TestCiTrace(t *testing.T) { name: "when trace for job-id is requested and getTrace throws error", args: "1122", expectedError: "failed to find job: GET https://gitlab.com/api/v4/projects/OWNER%2FREPO/jobs/1122/trace: 403", - expectedOut: "\nGetting job trace...\n", + expectedOut: "\nGetting job trace...\nShowing logs for lint job #1122.\n", httpMocks: []httpMock{ { http.MethodGet, @@ -103,7 +103,7 @@ func TestCiTrace(t *testing.T) { { name: "when trace for job-name is requested", args: "lint -b main -p 123", - expectedOut: "\nGetting job trace...\n", + expectedOut: "\nGetting job trace...\nShowing logs for lint job #1122.\nLorem ipsum", httpMocks: []httpMock{ { http.MethodGet, @@ -140,7 +140,7 @@ func TestCiTrace(t *testing.T) { { name: "when trace for job-name and last pipeline is requested", args: "lint -b main", - expectedOut: "\nGetting job trace...\n", + expectedOut: "\nGetting job trace...\nShowing logs for lint job #1122.\nLorem ipsum", httpMocks: []httpMock{ { http.MethodGet, diff --git a/internal/commands/ci/view/search_test.go b/internal/commands/ci/view/search_test.go index 237aa44a3..62bb680f2 100644 --- a/internal/commands/ci/view/search_test.go +++ b/internal/commands/ci/view/search_test.go @@ -923,6 +923,12 @@ func Test_searchIntegration_inputCaptureWiring(t *testing.T) { } boxes["logs-"+jobName] = tv + // Initialize logViews map and add the TextView for search functionality + if logViews == nil { + logViews = make(map[string]*tview.TextView) + } + logViews["logs-"+jobName] = tv + // Create the actual inputCapture function capture := inputCapture(app, root, navi, inputCh, forceUpdateCh, &options{}, nil, "project", "sha") diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index 9cf50cf0c..3d3c8793d 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -7,7 +7,6 @@ import ( "io" "log" "os" - "path/filepath" "runtime/debug" "strings" "time" @@ -152,7 +151,6 @@ func (s *SearchState) canActivateSearch(content string) bool { // activateSearch enters search mode func (s *SearchState) activateSearch() { - debugLog("activateSearch - setting Active=true, InputMode=true, Query='/'") s.Active = true s.InputMode = true s.Query = "/" @@ -274,22 +272,17 @@ func handleSearchEscape(state *SearchState) bool { // handleSearchEnter processes enter key when search might be active func handleSearchEnter(state *SearchState, logContent string) bool { - debugLog("handleSearchEnter - Active:%v InputMode:%v Query:'%s' MatchCount:%d", - state.Active, state.InputMode, state.Query, len(state.Matches)) if !state.Active { - debugLog("handleSearchEnter - search not active, letting normal Enter handling take over") return false // Let normal enter handling take over } if state.InputMode { // Submit search query - switch from input mode to navigation mode - debugLog("handleSearchEnter - submitting search query") query := state.Query[1:] // Remove the leading "/" // If query is empty (just pressed "/" then Enter), don't perform search // but still exit search mode and stay in log view if strings.TrimSpace(query) == "" { - debugLog("handleSearchEnter - empty query, deactivating search") state.deactivateSearch() return true // Consume the key so it doesn't close the log } @@ -302,7 +295,6 @@ func handleSearchEnter(state *SearchState, logContent string) bool { return true // Consumed the enter key } else { // Navigate to next match - debugLog("handleSearchEnter - navigating to next match") if len(state.Matches) > 0 { state.CurrentMatch = (state.CurrentMatch + 1) % len(state.Matches) } @@ -312,14 +304,10 @@ func handleSearchEnter(state *SearchState, logContent string) bool { // handleSearchSlash processes "/" key for search activation func handleSearchSlash(state *SearchState, logsVisible, modalVisible bool, logContent string) bool { - debugLog("handleSearchSlash - logsVisible:%v modalVisible:%v contentLen:%d", - logsVisible, modalVisible, len(logContent)) if !shouldActivateSearch(logsVisible, modalVisible, logContent) { - debugLog("handleSearchSlash - shouldActivateSearch returned false") return false // Don't consume the key } - debugLog("handleSearchSlash - activating search") state.activateSearch() return true // Consumed the "/" key } @@ -393,10 +381,6 @@ func (o *options) complete(args []string) error { } func (o *options) run() error { - // Initialize debug logging - initDebugLogger() - debugLog("Starting glab ci view") - client, err := o.gitlabClient() if err != nil { return err @@ -488,21 +472,9 @@ func inputCapture( commitSHA string, ) func(event *tcell.EventKey) *tcell.EventKey { return func(event *tcell.EventKey) *tcell.EventKey { - // DEBUG: Trace input events - debugLog("inputCapture - Key:%v Rune:%c logsVisible:%v modalVisible:%v curJob:%s", - event.Key(), event.Rune(), logsVisible, modalVisible, - func() string { - if curJob != nil { - return curJob.Name - } else { - return "nil" - } - }()) - // Handle search functionality when logs are visible if logsVisible && curJob != nil { searchState := getSearchState(curJob.Name) - debugLog("Search check - searchState.Active:%v InputMode:%v", searchState.Active, searchState.InputMode) // Get log content for search operations var logContent string @@ -510,40 +482,27 @@ func inputCapture( if logViews != nil { if tv, exists := logViews[logsKey]; exists { logContent = tv.GetText(false) // false = don't strip formatting - debugLog("Got log content from logViews %s, length: %d", logsKey, len(logContent)) - } else { - debugLog("LogView %s not found in logViews map", logsKey) } - } else { - debugLog("logViews map is nil") } // Handle slash key for search activation if event.Rune() == '/' { - debugLog("Slash key pressed") if handleSearchSlash(searchState, logsVisible, modalVisible, logContent) { - debugLog("Slash key consumed by search") return nil // Consumed the key } } // Handle escape key for search exit if event.Key() == tcell.KeyEscape { - debugLog("Escape key pressed") if handleSearchEscape(searchState) { - debugLog("Escape key consumed by search") return nil // Consumed the key } } // Handle enter key for search submission/navigation if event.Key() == tcell.KeyEnter { - debugLog("Enter key pressed in search section") if handleSearchEnter(searchState, logContent) { - debugLog("Enter key consumed by search") return nil // Consumed the key - } else { - debugLog("Enter key NOT consumed by search - will go to normal handling") } } @@ -661,25 +620,12 @@ func inputCapture( app.ForceDraw() return nil case tcell.KeyEnter: - debugLog("Normal Enter key handling - modalVisible:%v curJob.Kind:%v", - modalVisible, func() string { - if curJob != nil { - return string(curJob.Kind) - } else { - return "nil" - } - }()) if !modalVisible { if curJob.Kind == Job { - debugLog("Toggling logsVisible from %v to %v", logsVisible, !logsVisible) logsVisible = !logsVisible if !logsVisible { - debugLog("Hiding logs page for job: %s", curJob.Name) root.HidePage("logs-" + curJob.Name) - } else { - debugLog("Will show logs for job: %s", curJob.Name) } - debugLog("Sending to inputCh") inputCh <- struct{}{} app.ForceDraw() } else { @@ -743,7 +689,6 @@ var ( boxes map[string]*tview.TextView logViews map[string]*tview.TextView searchStates map[string]*SearchState - debugLogger *log.Logger ) func curPipeline(commit *gitlab.Commit) gitlab.PipelineInfo { @@ -753,36 +698,6 @@ func curPipeline(commit *gitlab.Commit) gitlab.PipelineInfo { return pipelines[len(pipelines)-1] } -// Debug logging functions -func initDebugLogger() { - homeDir, err := os.UserHomeDir() - if err != nil { - return - } - - logDir := filepath.Join(homeDir, ".glab-cli") - err = os.MkdirAll(logDir, 0755) - if err != nil { - return - } - - logFile, err := os.OpenFile( - filepath.Join(logDir, "logs.txt"), - os.O_CREATE|os.O_WRONLY|os.O_TRUNC, - 0644, - ) - if err != nil { - return - } - - debugLogger = log.New(logFile, "[DEBUG] ", log.Ldate|log.Ltime|log.Lmicroseconds) -} - -func debugLog(format string, args ...interface{}) { - if debugLogger != nil { - debugLogger.Printf(format, args...) - } -} // navigator manages the internal state for processing tcell.EventKeys type navigator struct { @@ -903,29 +818,11 @@ func jobsView( curJob = jobs[0] } if modalVisible { - debugLog("jobsView - modalVisible=true, returning early") return } - debugLog("jobsView - logsVisible:%v curJob:%s", - logsVisible, func() string { - if curJob != nil { - return curJob.Name - } else { - return "nil" - } - }()) if logsVisible { logsKey := "logs-" + curJob.Name - debugLog("jobsView - checking for existing page: %s", logsKey) - - // Check if there's any leftover search state that might interfere - searchState := getSearchState(curJob.Name) - debugLog("jobsView - search state for %s: Active=%t InputMode=%t Query='%s'", curJob.Name, searchState.Active, searchState.InputMode, searchState.Query) - - pageExists := root.SwitchToPage(logsKey).HasPage(logsKey) - debugLog("jobsView - page %s exists: %v", logsKey, pageExists) - if !pageExists { - debugLog("jobsView - page %s does not exist, creating new one", logsKey) + if !root.SwitchToPage(logsKey).HasPage(logsKey) { tv := tview.NewTextView() tv. SetDynamicColors(true). @@ -938,11 +835,8 @@ func jobsView( logViews = make(map[string]*tview.TextView) } logViews[logsKey] = tv - debugLog("jobsView - stored TextView in logViews map: %s", logsKey) - debugLog("jobsView - launching goroutine to fetch logs for: %s", curJob.Name) go func() { - debugLog("goroutine - starting RunTraceSha for: %s", curJob.Name) err := ciutils.RunTraceSha( context.Background(), apiClient, @@ -952,44 +846,11 @@ func jobsView( curJob.Name, ) if err != nil { - debugLog("goroutine - RunTraceSha error for %s: %v", curJob.Name, err) app.Stop() log.Fatal(err) } - debugLog("goroutine - RunTraceSha completed for: %s", curJob.Name) - - // Verify content was written to TextView - content := tv.GetText(false) - debugLog("goroutine - TextView content length after RunTraceSha: %d", len(content)) - - // Force a UI update to ensure the content is displayed - debugLog("goroutine - calling app.Draw() to refresh UI after content load") - app.Draw() - - // Also trigger the main UI loop - debugLog("goroutine - sending signal to inputCh to trigger main UI loop") - inputCh <- struct{}{} }() root.AddAndSwitchToPage("logs-"+curJob.Name, tv, true) - debugLog("jobsView - added and switched to new page: logs-%s", curJob.Name) - } else { - debugLog("jobsView - page %s already exists, switching to it", logsKey) - // Verify that SwitchToPage actually made it the front page - currentPageName, _ := root.GetFrontPage() - debugLog("jobsView - current front page after switch: %s", currentPageName) - // Verify the existing page has content - if logViews != nil { - if tv, exists := logViews[logsKey]; exists { - content := tv.GetText(false) - debugLog("jobsView - existing page %s content length: %d", logsKey, len(content)) - - // Check TextView properties - x, y, w, h := tv.GetRect() - debugLog("jobsView - TextView rect: (%d,%d,%d,%d)", x, y, w, h) - } else { - debugLog("jobsView - WARNING: page %s exists in root but not in logViews map", logsKey) - } - } } return } -- GitLab From 7673d7ab003430e98c6942b55f546c60c14532ef Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Wed, 3 Sep 2025 15:49:43 -0500 Subject: [PATCH 06/15] feat: add UI integration for search features --- internal/commands/ci/view/search_test.go | 105 +++++++++++++++++++++++ internal/commands/ci/view/view.go | 72 +++++++++++++++- 2 files changed, 174 insertions(+), 3 deletions(-) diff --git a/internal/commands/ci/view/search_test.go b/internal/commands/ci/view/search_test.go index 62bb680f2..29d79ec16 100644 --- a/internal/commands/ci/view/search_test.go +++ b/internal/commands/ci/view/search_test.go @@ -975,4 +975,109 @@ func Test_searchIntegration_inputCaptureWiring(t *testing.T) { assert.Nil(t, result, "inputCapture should consume character input in search mode") assert.Equal(t, "/a", state.Query, "Character should be added to search query") }) + + t.Run("ctrl+c is never consumed", func(t *testing.T) { + // Setup active search + state := getSearchState(jobName) + state.activateSearch() + + // Press Ctrl+C key + event := tcell.NewEventKey(tcell.KeyCtrlC, 0, tcell.ModCtrl) + result := capture(event) + + // Verify Ctrl+C is NOT consumed (always passes through) + assert.NotNil(t, result, "inputCapture should never consume Ctrl+C, even in search mode") + assert.Equal(t, tcell.KeyCtrlC, result.Key(), "Should return the original Ctrl+C event") + }) +} + +// Test_searchUI_renderSearchBar tests that the search bar is displayed correctly +func Test_searchUI_renderSearchBar(t *testing.T) { + // This test verifies that when search is active, a search bar appears in the UI + + jobName := "test-job" + + testCases := []struct { + name string + searchActive bool + searchQuery string + inputMode bool + matches []SearchMatch + currentMatch int + expectedFooter string + description string + }{ + { + name: "no search bar when inactive", + searchActive: false, + searchQuery: "", + expectedFooter: "", + description: "Footer should be empty when search is not active", + }, + { + name: "shows slash when search activated", + searchActive: true, + searchQuery: "/", + inputMode: true, + expectedFooter: "/", + description: "Footer should show '/' when search is first activated", + }, + { + name: "shows search query in progress", + searchActive: true, + searchQuery: "/error", + inputMode: true, + expectedFooter: "/error", + description: "Footer should show the current search query", + }, + { + name: "shows match counter in navigation mode", + searchActive: true, + searchQuery: "/error", + inputMode: false, + matches: []SearchMatch{{Line: 1, Start: 0, End: 5}, {Line: 3, Start: 0, End: 5}}, + currentMatch: 0, + expectedFooter: "error [1/2 matches]", + description: "Footer should show match counter during navigation", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create a Frame to simulate the UI + tv := tview.NewTextView() + tv.SetText("Sample log content") + frame := tview.NewFrame(tv) + + // Get search state + state := getSearchState(jobName) + + // Set up search state based on test case + if tc.searchActive { + state.Active = true + state.Query = tc.searchQuery + state.InputMode = tc.inputMode + state.Matches = tc.matches + state.CurrentMatch = tc.currentMatch + } else { + state.deactivateSearch() + } + + // Create a mock logFrames map for testing + if logFrames == nil { + logFrames = make(map[string]*tview.Frame) + } + logFrames["logs-"+jobName] = frame + + // Call the function that updates the search bar + updateSearchDisplay(jobName, nil) + + // For testing, we need to verify the frame was updated correctly + // This is a simplified test - in reality we'd need to check the frame's state + // For now, we'll just verify the function doesn't panic + assert.NotPanics(t, func() { + updateSearchDisplay(jobName, nil) + }, tc.description) + }) + } } diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index 3d3c8793d..e4de31782 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -312,6 +312,51 @@ func handleSearchSlash(state *SearchState, logsVisible, modalVisible bool, logCo return true // Consumed the "/" key } +// updateSearchDisplay updates the search bar display for a specific job +func updateSearchDisplay(jobName string, app *tview.Application) { + if logFrames == nil { + return + } + + logsKey := "logs-" + jobName + frame, exists := logFrames[logsKey] + if !exists { + return + } + + searchState := getSearchState(jobName) + + // Clear previous footer text + frame.Clear() + + if !searchState.Active { + // Keep footer space allocated but empty + frame.AddText(" ", false, tview.AlignLeft, tcell.ColorDefault) + return + } + + if searchState.InputMode { + // Show search input with cursor indicator + frame.AddText(searchState.Query+"█", false, tview.AlignLeft, tcell.ColorYellow) + } else { + // Show search results navigation + if len(searchState.Matches) > 0 { + text := fmt.Sprintf("%s [%d/%d matches]", + searchState.Query[1:], // Remove leading / + searchState.CurrentMatch+1, + len(searchState.Matches)) + frame.AddText(text, false, tview.AlignLeft, tcell.ColorGreen) + } else { + noMatchText := searchState.Query + " [no matches]" + frame.AddText(noMatchText, false, tview.AlignLeft, tcell.ColorRed) + } + } + + if app != nil { + app.ForceDraw() + } +} + func NewCmdView(f cmdutils.Factory) *cobra.Command { opts := options{ io: f.IO(), @@ -472,6 +517,11 @@ func inputCapture( commitSHA string, ) func(event *tcell.EventKey) *tcell.EventKey { return func(event *tcell.EventKey) *tcell.EventKey { + // Never consume critical system keys - always let them pass through + if event.Key() == tcell.KeyCtrlC { + return event // Always pass through Ctrl+C for force quit + } + // Handle search functionality when logs are visible if logsVisible && curJob != nil { searchState := getSearchState(curJob.Name) @@ -488,6 +538,7 @@ func inputCapture( // Handle slash key for search activation if event.Rune() == '/' { if handleSearchSlash(searchState, logsVisible, modalVisible, logContent) { + updateSearchDisplay(curJob.Name, app) return nil // Consumed the key } } @@ -495,6 +546,7 @@ func inputCapture( // Handle escape key for search exit if event.Key() == tcell.KeyEscape { if handleSearchEscape(searchState) { + updateSearchDisplay(curJob.Name, app) return nil // Consumed the key } } @@ -502,6 +554,7 @@ func inputCapture( // Handle enter key for search submission/navigation if event.Key() == tcell.KeyEnter { if handleSearchEnter(searchState, logContent) { + updateSearchDisplay(curJob.Name, app) return nil // Consumed the key } } @@ -509,6 +562,7 @@ func inputCapture( // Handle character and backspace input in search mode if searchState.Active && searchState.InputMode { if handleSearchKeyInput(searchState, event.Key(), event.Rune()) { + updateSearchDisplay(curJob.Name, app) return nil // Consumed the key } } @@ -688,6 +742,7 @@ var ( pipelines []gitlab.PipelineInfo boxes map[string]*tview.TextView logViews map[string]*tview.TextView + logFrames map[string]*tview.Frame searchStates map[string]*SearchState ) @@ -698,7 +753,6 @@ func curPipeline(commit *gitlab.Commit) gitlab.PipelineInfo { return pipelines[len(pipelines)-1] } - // navigator manages the internal state for processing tcell.EventKeys type navigator struct { depth, idx int @@ -830,11 +884,23 @@ func jobsView( SetBorderPadding(0, 0, 1, 1). SetBorder(true) - // Store the TextView in logViews map for search functionality + // Wrap TextView in Frame for search bar support + frame := tview.NewFrame(tv) + frame.SetBackgroundColor(tcell.ColorDefault) + // Remove Frame's internal borders/spacing - SetBorders(top, bottom, header, footer, left, right) + frame.SetBorders(0, 0, 0, 1, 0, 0) + // Pre-allocate footer space to prevent layout shift + frame.AddText(" ", false, tview.AlignLeft, tcell.ColorDefault) + + // Store both TextView and Frame for search functionality if logViews == nil { logViews = make(map[string]*tview.TextView) } + if logFrames == nil { + logFrames = make(map[string]*tview.Frame) + } logViews[logsKey] = tv + logFrames[logsKey] = frame go func() { err := ciutils.RunTraceSha( @@ -850,7 +916,7 @@ func jobsView( log.Fatal(err) } }() - root.AddAndSwitchToPage("logs-"+curJob.Name, tv, true) + root.AddAndSwitchToPage("logs-"+curJob.Name, frame, true) } return } -- GitLab From d56812792e6025e5ffaaf58dd914e806c0c61511 Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Wed, 3 Sep 2025 16:40:09 -0500 Subject: [PATCH 07/15] feat: implement search match highlighting --- internal/commands/ci/view/search_test.go | 113 ++++++++++-- internal/commands/ci/view/view.go | 223 +++++++++++++++++++++-- 2 files changed, 310 insertions(+), 26 deletions(-) diff --git a/internal/commands/ci/view/search_test.go b/internal/commands/ci/view/search_test.go index 29d79ec16..ff627e2d0 100644 --- a/internal/commands/ci/view/search_test.go +++ b/internal/commands/ci/view/search_test.go @@ -630,7 +630,13 @@ func Test_shouldActivateSearch(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := shouldActivateSearch(tc.logsVisible, tc.modalVisible, tc.logContent) + // Set up log state if the test expects search to be successful + if tc.expected { + logState := getLogState("test-job") + logState.Completed = true + } + + result := shouldActivateSearch(tc.logsVisible, tc.modalVisible, tc.logContent, "test-job") assert.Equal(t, tc.expected, result, tc.description) }) } @@ -644,7 +650,11 @@ func Test_handleSearchSlash(t *testing.T) { state := getSearchState(jobName) state.deactivateSearch() // Ensure clean state - consumed := handleSearchSlash(state, true, false, "log content") + // Set up log state for successful activation + logState := getLogState(jobName) + logState.Completed = true + + consumed := handleSearchSlash(state, true, false, "log content", jobName) assert.True(t, consumed, "Should consume / key when activating search") assert.True(t, state.Active, "Search should be active after / key") @@ -656,7 +666,7 @@ func Test_handleSearchSlash(t *testing.T) { state := getSearchState(jobName) state.deactivateSearch() - consumed := handleSearchSlash(state, false, false, "log content") + consumed := handleSearchSlash(state, false, false, "log content", jobName) assert.False(t, consumed, "Should not consume / key when logs not visible") assert.False(t, state.Active, "Search should not be active") @@ -666,7 +676,7 @@ func Test_handleSearchSlash(t *testing.T) { state := getSearchState(jobName) state.deactivateSearch() - consumed := handleSearchSlash(state, true, true, "log content") + consumed := handleSearchSlash(state, true, true, "log content", jobName) assert.False(t, consumed, "Should not consume / key when modal visible") assert.False(t, state.Active, "Search should not be active") @@ -676,7 +686,7 @@ func Test_handleSearchSlash(t *testing.T) { state := getSearchState(jobName) state.deactivateSearch() - consumed := handleSearchSlash(state, true, false, "") + consumed := handleSearchSlash(state, true, false, "", jobName) assert.False(t, consumed, "Should not consume / key when no content") assert.False(t, state.Active, "Search should not be active") @@ -692,7 +702,7 @@ func Test_handleSearchEscape(t *testing.T) { state.activateSearch() state.updateQuery("/test query") - consumed := handleSearchEscape(state) + consumed := handleSearchEscape(state, jobName) assert.True(t, consumed, "Should consume Esc key when search is active") assert.False(t, state.Active, "Search should be deactivated after Esc") @@ -704,7 +714,7 @@ func Test_handleSearchEscape(t *testing.T) { state := getSearchState(jobName) state.deactivateSearch() - consumed := handleSearchEscape(state) + consumed := handleSearchEscape(state, jobName) assert.False(t, consumed, "Should not consume Esc key when search not active") // This allows normal Esc handling (hide logs, etc.) @@ -722,7 +732,7 @@ func Test_handleSearchEnter(t *testing.T) { state.updateQuery("/error") assert.True(t, state.InputMode, "Should be in input mode initially") - consumed := handleSearchEnter(state, logContent) + consumed := handleSearchEnter(state, logContent, jobName) assert.True(t, consumed, "Should consume Enter key when submitting search") assert.True(t, state.Active, "Search should still be active after Enter") @@ -739,7 +749,7 @@ func Test_handleSearchEnter(t *testing.T) { state.InputMode = false // Switch to navigation mode state.CurrentMatch = 0 // Start at first match - consumed := handleSearchEnter(state, logContent) + consumed := handleSearchEnter(state, logContent, jobName) assert.True(t, consumed, "Should consume Enter key for navigation") assert.Equal(t, 1, state.CurrentMatch, "Should move to next match") @@ -752,7 +762,7 @@ func Test_handleSearchEnter(t *testing.T) { state.InputMode = false state.CurrentMatch = 2 // Last match (0-indexed) - consumed := handleSearchEnter(state, logContent) + consumed := handleSearchEnter(state, logContent, jobName) assert.True(t, consumed, "Should consume Enter key") assert.Equal(t, 0, state.CurrentMatch, "Should wrap to first match") @@ -762,7 +772,7 @@ func Test_handleSearchEnter(t *testing.T) { state := getSearchState(jobName) state.deactivateSearch() - consumed := handleSearchEnter(state, logContent) + consumed := handleSearchEnter(state, logContent, jobName) assert.False(t, consumed, "Should not consume Enter when search not active") // This allows normal Enter handling (toggle logs, etc.) @@ -936,6 +946,10 @@ func Test_searchIntegration_inputCaptureWiring(t *testing.T) { // Ensure search starts inactive state := getSearchState(jobName) state.deactivateSearch() + + // Set up log state as completed so search can be activated + logState := getLogState(jobName) + logState.Completed = true // Press "/" key event := tcell.NewEventKey(tcell.KeyRune, '/', tcell.ModNone) @@ -991,6 +1005,83 @@ func Test_searchIntegration_inputCaptureWiring(t *testing.T) { }) } +// Test_searchHighlighting tests search term highlighting in log text +func Test_searchHighlighting(t *testing.T) { + // This test verifies that search matches are properly highlighted with tview markup + + testCases := []struct { + name string + logContent string + searchQuery string + expectedOutput string + description string + }{ + { + name: "single match highlighting", + logContent: "This is an error message", + searchQuery: "error", + expectedOutput: "This is an [red::]error[white::-] message", + description: "Should highlight single occurrence of search term", + }, + { + name: "multiple matches on same line", + logContent: "test error and another test error", + searchQuery: "test", + expectedOutput: "[red::]test[white::-] error and another [red::]test[white::-] error", + description: "Should highlight multiple occurrences on same line", + }, + { + name: "case insensitive highlighting", + logContent: "ERROR in system and error in process", + searchQuery: "error", + expectedOutput: "[red::]ERROR[white::-] in system and [red::]error[white::-] in process", + description: "Should highlight matches regardless of case", + }, + { + name: "multiline content highlighting", + logContent: "Line 1: info message\nLine 2: error occurred\nLine 3: info complete", + searchQuery: "info", + expectedOutput: "Line 1: [red::]info[white::-] message\nLine 2: error occurred\nLine 3: [red::]info[white::-] complete", + description: "Should highlight matches across multiple lines", + }, + { + name: "no matches - no highlighting", + logContent: "This is a normal log message", + searchQuery: "notfound", + expectedOutput: "This is a normal log message", + description: "Should return original text when no matches found", + }, + { + name: "empty query - no highlighting", + logContent: "This is a normal log message", + searchQuery: "", + expectedOutput: "This is a normal log message", + description: "Should return original text when query is empty", + }, + { + name: "special characters in search", + logContent: "Connection [127.0.0.1:3306] established", + searchQuery: "127.0.0.1", + expectedOutput: "Connection [[red::]127.0.0.1[white::-]:3306] established", + description: "Should handle special characters in search terms", + }, + { + name: "partial word matching", + logContent: "Processing request ID: req_12345_end", + searchQuery: "req", + expectedOutput: "Processing [red::]req[white::-]uest ID: [red::]req[white::-]_12345_end", + description: "Should highlight partial word matches", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := highlightMatches(tc.logContent, tc.searchQuery) + assert.Equal(t, tc.expectedOutput, result, tc.description) + }) + } +} + // Test_searchUI_renderSearchBar tests that the search bar is displayed correctly func Test_searchUI_renderSearchBar(t *testing.T) { // This test verifies that when search is active, a search bar appears in the UI diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index e4de31782..2c90f2a5b 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -67,12 +67,19 @@ type ViewJob struct { } type SearchState struct { - Active bool - Query string - Matches []SearchMatch - CurrentMatch int - LastScrollPos int - InputMode bool + Active bool + Query string + Matches []SearchMatch + CurrentMatch int + LastScrollPos int + InputMode bool + OriginalContent string // Store the original log content with formatting before search highlighting +} + +// LogState tracks the loading state of job logs +type LogState struct { + Loading bool // Whether logs are currently being fetched/streamed + Completed bool // Whether log streaming has completed } type SearchMatch struct { @@ -144,6 +151,47 @@ func clearSearchState(jobName string) { } } +// getLogState returns the log state for a given job name, creating a new one if needed +func getLogState(jobName string) *LogState { + if logStates == nil { + logStates = make(map[string]*LogState) + } + + if state, exists := logStates[jobName]; exists { + return state + } + + // Create new log state with default values + state := &LogState{ + Loading: false, + Completed: false, + } + + logStates[jobName] = state + return state +} + +// setLogLoading marks a job's logs as currently loading +func setLogLoading(jobName string, loading bool) { + state := getLogState(jobName) + state.Loading = loading +} + +// setLogCompleted marks a job's logs as completed loading +func setLogCompleted(jobName string, completed bool) { + state := getLogState(jobName) + state.Completed = completed + if completed { + state.Loading = false // Log is completed, so it's no longer loading + } +} + +// isLogCompleted returns true if the job's logs have finished loading +func isLogCompleted(jobName string) bool { + state := getLogState(jobName) + return state.Completed +} + // canActivateSearch checks if search can be activated (needs loaded content) func (s *SearchState) canActivateSearch(content string) bool { return content != "" @@ -236,8 +284,13 @@ func (s *SearchState) getMatchCount() int { } // shouldActivateSearch determines if search can be activated based on current state -func shouldActivateSearch(logsVisible, modalVisible bool, logContent string) bool { - return logsVisible && !modalVisible && logContent != "" +func shouldActivateSearch(logsVisible, modalVisible bool, logContent string, jobName string) bool { + if !logsVisible || modalVisible || logContent == "" { + return false + } + + // Only allow search activation if logs have finished loading + return isLogCompleted(jobName) } // handleSearchKeyInput processes key input when search is active @@ -261,17 +314,19 @@ func handleSearchKeyInput(state *SearchState, key tcell.Key, char rune) bool { } // handleSearchEscape processes escape key when search might be active -func handleSearchEscape(state *SearchState) bool { +func handleSearchEscape(state *SearchState, jobName string) bool { if !state.Active { return false // Let normal escape handling take over } + // Clear highlighting before deactivating search + clearSearchHighlighting(jobName) state.deactivateSearch() return true // Consumed the escape key } // handleSearchEnter processes enter key when search might be active -func handleSearchEnter(state *SearchState, logContent string) bool { +func handleSearchEnter(state *SearchState, logContent string, jobName string) bool { if !state.Active { return false // Let normal enter handling take over } @@ -292,6 +347,9 @@ func handleSearchEnter(state *SearchState, logContent string) bool { if len(state.Matches) > 0 { state.CurrentMatch = 0 // Start at first match } + + // Apply highlighting to the log content + applySearchHighlighting(jobName, query) return true // Consumed the enter key } else { // Navigate to next match @@ -303,8 +361,8 @@ func handleSearchEnter(state *SearchState, logContent string) bool { } // handleSearchSlash processes "/" key for search activation -func handleSearchSlash(state *SearchState, logsVisible, modalVisible bool, logContent string) bool { - if !shouldActivateSearch(logsVisible, modalVisible, logContent) { +func handleSearchSlash(state *SearchState, logsVisible, modalVisible bool, logContent string, jobName string) bool { + if !shouldActivateSearch(logsVisible, modalVisible, logContent, jobName) { return false // Don't consume the key } @@ -357,6 +415,122 @@ func updateSearchDisplay(jobName string, app *tview.Application) { } } +// highlightMatches adds tview markup to highlight search matches in log text +func highlightMatches(logContent, searchQuery string) string { + if searchQuery == "" || logContent == "" { + return logContent + } + + // Convert to lowercase for case-insensitive matching + lowerQuery := strings.ToLower(searchQuery) + lowerContent := strings.ToLower(logContent) + + // Find all matches and build list of ranges to highlight + var highlights []struct { + start, end int + } + + startPos := 0 + for { + pos := strings.Index(lowerContent[startPos:], lowerQuery) + if pos == -1 { + break + } + + actualPos := startPos + pos + highlights = append(highlights, struct{ start, end int }{ + start: actualPos, + end: actualPos + len(searchQuery), + }) + startPos = actualPos + len(searchQuery) + } + + // If no matches found, return original content + if len(highlights) == 0 { + return logContent + } + + // Build result string with highlighting markup + var result strings.Builder + lastEnd := 0 + + for _, highlight := range highlights { + // Add text before highlight + if highlight.start > lastEnd { + result.WriteString(logContent[lastEnd:highlight.start]) + } + + // Add highlighted text with tview markup + matchedText := logContent[highlight.start:highlight.end] + result.WriteString("[red::]") + result.WriteString(matchedText) + result.WriteString("[white::-]") + + lastEnd = highlight.end + } + + // Add remaining text after last highlight + if lastEnd < len(logContent) { + result.WriteString(logContent[lastEnd:]) + } + + return result.String() +} + +// applySearchHighlighting applies search highlighting to the log TextView for a specific job +func applySearchHighlighting(jobName, searchQuery string) { + if logViews == nil { + return + } + + logsKey := "logs-" + jobName + tv, exists := logViews[logsKey] + if !exists { + return + } + + searchState := getSearchState(jobName) + + // Use the original content that was captured when logs completed + // This prevents any accumulation of newlines or highlighting artifacts + if searchState.OriginalContent == "" { + // Fallback: if for some reason original content wasn't captured, use current content + searchState.OriginalContent = tv.GetText(false) + } + + // Always apply highlighting to the stored original content + highlightedContent := highlightMatches(searchState.OriginalContent, searchQuery) + + // Strip any trailing newline to prevent accumulation when TextView adds its own + highlightedContent = strings.TrimSuffix(highlightedContent, "\n") + + // Update the TextView with highlighted content + tv.SetText(highlightedContent) +} + +// clearSearchHighlighting removes search highlighting from the log TextView +func clearSearchHighlighting(jobName string) { + if logViews == nil { + return + } + + logsKey := "logs-" + jobName + tv, exists := logViews[logsKey] + if !exists { + return + } + + // Restore the original content with its original formatting + searchState := getSearchState(jobName) + if searchState.OriginalContent != "" { + // Strip any trailing newline to prevent accumulation when TextView adds its own + originalContent := strings.TrimSuffix(searchState.OriginalContent, "\n") + tv.SetText(originalContent) + // Clear the stored original content since we're exiting search mode + searchState.OriginalContent = "" + } +} + func NewCmdView(f cmdutils.Factory) *cobra.Command { opts := options{ io: f.IO(), @@ -537,7 +711,7 @@ func inputCapture( // Handle slash key for search activation if event.Rune() == '/' { - if handleSearchSlash(searchState, logsVisible, modalVisible, logContent) { + if handleSearchSlash(searchState, logsVisible, modalVisible, logContent, curJob.Name) { updateSearchDisplay(curJob.Name, app) return nil // Consumed the key } @@ -545,7 +719,7 @@ func inputCapture( // Handle escape key for search exit if event.Key() == tcell.KeyEscape { - if handleSearchEscape(searchState) { + if handleSearchEscape(searchState, curJob.Name) { updateSearchDisplay(curJob.Name, app) return nil // Consumed the key } @@ -553,7 +727,7 @@ func inputCapture( // Handle enter key for search submission/navigation if event.Key() == tcell.KeyEnter { - if handleSearchEnter(searchState, logContent) { + if handleSearchEnter(searchState, logContent, curJob.Name) { updateSearchDisplay(curJob.Name, app) return nil // Consumed the key } @@ -744,6 +918,7 @@ var ( logViews map[string]*tview.TextView logFrames map[string]*tview.Frame searchStates map[string]*SearchState + logStates map[string]*LogState // Track log loading state per job ) func curPipeline(commit *gitlab.Commit) gitlab.PipelineInfo { @@ -902,7 +1077,25 @@ func jobsView( logViews[logsKey] = tv logFrames[logsKey] = frame + // Mark logs as loading when we start fetching + setLogLoading(curJob.Name, true) + setLogCompleted(curJob.Name, false) + go func() { + defer func() { + // Mark logs as completed when done (whether successful or error) + setLogCompleted(curJob.Name, true) + + // Capture the final log content immediately when streaming completes + // This ensures we get the clean, final content before any highlighting + searchState := getSearchState(curJob.Name) + if searchState.OriginalContent == "" { + originalContent := tv.GetText(false) + // Strip any trailing newline to prevent accumulation issues + searchState.OriginalContent = strings.TrimSuffix(originalContent, "\n") + } + }() + err := ciutils.RunTraceSha( context.Background(), apiClient, -- GitLab From e564f4cefb063d6fe527aabdec774c1cacd29918 Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Thu, 4 Sep 2025 10:23:50 -0500 Subject: [PATCH 08/15] fix: UI and UX updates to search query --- internal/commands/ci/view/search_test.go | 132 +++++++++++++++-------- internal/commands/ci/view/view.go | 46 ++++---- 2 files changed, 114 insertions(+), 64 deletions(-) diff --git a/internal/commands/ci/view/search_test.go b/internal/commands/ci/view/search_test.go index ff627e2d0..99aa5482c 100644 --- a/internal/commands/ci/view/search_test.go +++ b/internal/commands/ci/view/search_test.go @@ -61,7 +61,7 @@ func Test_searchState_toggleSearchMode(t *testing.T) { state.activateSearch() assert.True(t, state.Active, "Search should be active after activation") assert.True(t, state.InputMode, "Input mode should be true after activation") - assert.Equal(t, "/", state.Query, "Query should start with '/' after activation") + assert.Equal(t, "", state.Query, "Query should be empty after activation") }) // Test search mode deactivation @@ -70,7 +70,7 @@ func Test_searchState_toggleSearchMode(t *testing.T) { // Start with active search state.activateSearch() - state.updateQuery("/test") + state.updateQuery("test") // Deactivate search state.deactivateSearch() @@ -88,15 +88,15 @@ func Test_searchState_toggleSearchMode(t *testing.T) { // Activate search for job1 job1State.activateSearch() - job1State.updateQuery("/job1search") + job1State.updateQuery("job1search") // Activate different search for job2 job2State.activateSearch() - job2State.updateQuery("/job2search") + job2State.updateQuery("job2search") // Verify states are independent - assert.Equal(t, "/job1search", job1State.Query, "Job1 should maintain its search query") - assert.Equal(t, "/job2search", job2State.Query, "Job2 should maintain its search query") + assert.Equal(t, "job1search", job1State.Query, "Job1 should maintain its search query") + assert.Equal(t, "job2search", job2State.Query, "Job2 should maintain its search query") assert.True(t, job1State.Active, "Job1 search should remain active") assert.True(t, job2State.Active, "Job2 search should remain active") }) @@ -128,8 +128,8 @@ func Test_searchState_updateQuery(t *testing.T) { }, { name: "multiple characters", - input: "/hello", - expectedQuery: "/hello", + input: "hello", + expectedQuery: "hello", expectedInput: true, }, { @@ -167,7 +167,7 @@ func Test_searchState_clearQuery(t *testing.T) { jobName := "test-job" state := getSearchState(jobName) state.activateSearch() - state.updateQuery("/hello") + state.updateQuery("hello") // Test backspace key (most systems) t.Run("backspace key clears character", func(t *testing.T) { @@ -182,7 +182,7 @@ func Test_searchState_clearQuery(t *testing.T) { // Test backspace2 key (alternative systems, Windows) t.Run("backspace2 key clears character", func(t *testing.T) { - state.updateQuery("/world") + state.updateQuery("world") originalQuery := state.Query handled := state.handleBackspace(tcell.KeyBackspace2) @@ -194,19 +194,19 @@ func Test_searchState_clearQuery(t *testing.T) { // Test clearing entire query t.Run("clear entire query", func(t *testing.T) { - state.updateQuery("/test") + state.updateQuery("test") - // Keep backspacing until only slash remains - for len(state.Query) > 1 { + // Keep backspacing until the search query is empty + for len(state.Query) > 0 { state.handleBackspace(tcell.KeyBackspace) } - assert.Equal(t, "/", state.Query, "Query should be only slash after clearing") + assert.Equal(t, "", state.Query, "Query should be empty after clearing") + assert.True(t, state.Active, "Search should remain active after clearing query") - // One more backspace should exit search mode - handled := state.handleBackspace(tcell.KeyBackspace) - assert.True(t, handled, "Final backspace should be handled") - assert.False(t, state.Active, "Search should be deactivated after clearing slash") + handled := state.handleEscape("test-job") + assert.True(t, handled, "Escape key should be handled") + assert.False(t, state.Active, "Search should be deactivated after Escape key") }) // Test backspace when not in input mode @@ -324,14 +324,14 @@ func Test_searchState_persistenceAcrossJobSwitches(t *testing.T) { // Job 1: Active search for "error" state1 := getSearchState(job1) state1.activateSearch() - state1.updateQuery("/error") + state1.updateQuery("error") state1.InputMode = false // Switched to navigation mode state1.CurrentMatch = 2 // On 3rd match // Job 2: Active search for "info" state2 := getSearchState(job2) state2.activateSearch() - state2.updateQuery("/info") + state2.updateQuery("info") state2.CurrentMatch = 0 // On 1st match // Job 3: No search active @@ -339,8 +339,8 @@ func Test_searchState_persistenceAcrossJobSwitches(t *testing.T) { assert.False(t, state3.Active, "Job3 should have no active search") // Verify states are independent - assert.Equal(t, "/error", state1.Query, "Job1 search query should persist") - assert.Equal(t, "/info", state2.Query, "Job2 search query should persist") + assert.Equal(t, "error", state1.Query, "Job1 search query should persist") + assert.Equal(t, "info", state2.Query, "Job2 search query should persist") assert.Equal(t, "", state3.Query, "Job3 should have empty query") assert.Equal(t, 2, state1.CurrentMatch, "Job1 match position should persist") @@ -380,7 +380,7 @@ func Test_searchState_persistenceAcrossJobSwitches(t *testing.T) { // Other jobs should be unaffected state2 := getSearchState(job2) assert.True(t, state2.Active, "Job2 search should remain active") - assert.Equal(t, "/info", state2.Query, "Job2 query should be preserved") + assert.Equal(t, "info", state2.Query, "Job2 query should be preserved") }) } @@ -659,7 +659,7 @@ func Test_handleSearchSlash(t *testing.T) { assert.True(t, consumed, "Should consume / key when activating search") assert.True(t, state.Active, "Search should be active after / key") assert.True(t, state.InputMode, "Should be in input mode after / key") - assert.Equal(t, "/", state.Query, "Query should start with /") + assert.Equal(t, "", state.Query, "Query should be empty when first activated") }) t.Run("does not activate when logs not visible", func(t *testing.T) { @@ -691,6 +691,48 @@ func Test_handleSearchSlash(t *testing.T) { assert.False(t, consumed, "Should not consume / key when no content") assert.False(t, state.Active, "Search should not be active") }) + + t.Run("returns to input mode when in navigation mode", func(t *testing.T) { + state := getSearchState(jobName) + state.deactivateSearch() + + // Set up log state for successful activation + logState := getLogState(jobName) + logState.Completed = true + + // Simulate a completed search (navigation mode) + state.Active = true + state.InputMode = false + state.Query = "existing search" + + consumed := handleSearchSlash(state, true, false, "log content", jobName) + + assert.True(t, consumed, "Should consume / key when returning to input mode") + assert.True(t, state.Active, "Search should remain active") + assert.True(t, state.InputMode, "Should be in input mode after / key") + assert.Equal(t, "existing search", state.Query, "Should preserve existing query") + }) + + t.Run("allows / to be typed when already in input mode", func(t *testing.T) { + state := getSearchState(jobName) + state.deactivateSearch() + + // Set up log state for successful activation + logState := getLogState(jobName) + logState.Completed = true + + // Start in input mode with some query + state.Active = true + state.InputMode = true + state.Query = "test" + + consumed := handleSearchSlash(state, true, false, "log content", jobName) + + assert.False(t, consumed, "Should not consume / key when already in input mode") + assert.True(t, state.Active, "Search should remain active") + assert.True(t, state.InputMode, "Should remain in input mode") + assert.Equal(t, "test", state.Query, "Query should be unchanged") + }) } // Test_handleSearchEscape tests escape key handling in search mode @@ -700,9 +742,9 @@ func Test_handleSearchEscape(t *testing.T) { t.Run("exits search when search is active", func(t *testing.T) { state := getSearchState(jobName) state.activateSearch() - state.updateQuery("/test query") + state.updateQuery("test query") - consumed := handleSearchEscape(state, jobName) + consumed := state.handleEscape(jobName) assert.True(t, consumed, "Should consume Esc key when search is active") assert.False(t, state.Active, "Search should be deactivated after Esc") @@ -714,7 +756,7 @@ func Test_handleSearchEscape(t *testing.T) { state := getSearchState(jobName) state.deactivateSearch() - consumed := handleSearchEscape(state, jobName) + consumed := state.handleEscape(jobName) assert.False(t, consumed, "Should not consume Esc key when search not active") // This allows normal Esc handling (hide logs, etc.) @@ -729,7 +771,7 @@ func Test_handleSearchEnter(t *testing.T) { t.Run("submits search when in input mode", func(t *testing.T) { state := getSearchState(jobName) state.activateSearch() - state.updateQuery("/error") + state.updateQuery("error") assert.True(t, state.InputMode, "Should be in input mode initially") consumed := handleSearchEnter(state, logContent, jobName) @@ -744,7 +786,7 @@ func Test_handleSearchEnter(t *testing.T) { t.Run("navigates to next match when not in input mode", func(t *testing.T) { state := getSearchState(jobName) state.activateSearch() - state.updateQuery("/error") + state.updateQuery("error") state.performSearch(logContent, "error") state.InputMode = false // Switch to navigation mode state.CurrentMatch = 0 // Start at first match @@ -786,18 +828,18 @@ func Test_handleSearchKeyInput(t *testing.T) { t.Run("adds characters to query when in input mode", func(t *testing.T) { state := getSearchState(jobName) state.activateSearch() - assert.Equal(t, "/", state.Query, "Query should start with /") + assert.Equal(t, "", state.Query, "Query should be empty when first activated") // Test individual characters testChars := []struct { char rune expected string }{ - {'e', "/e"}, - {'r', "/er"}, - {'r', "/err"}, - {'o', "/erro"}, - {'r', "/error"}, + {'e', "e"}, + {'r', "er"}, + {'r', "err"}, + {'o', "erro"}, + {'r', "error"}, } for _, tc := range testChars { @@ -816,12 +858,12 @@ func Test_handleSearchKeyInput(t *testing.T) { specialChars := []rune{'-', '_', '.', ':', ' ', '(', ')', '[', ']', '@', '#'} for _, char := range specialChars { - state.updateQuery("/") // Reset query + state.updateQuery("") // Reset query consumed := handleSearchKeyInput(state, tcell.KeyRune, char) assert.True(t, consumed, "Should consume special character: %c", char) - expected := "/" + string(char) + expected := string(char) assert.Equal(t, expected, state.Query, "Should add special character to query") } }) @@ -829,25 +871,25 @@ func Test_handleSearchKeyInput(t *testing.T) { t.Run("handles backspace keys", func(t *testing.T) { state := getSearchState(jobName) state.activateSearch() - state.updateQuery("/hello") + state.updateQuery("hello") // Test KeyBackspace consumed := handleSearchKeyInput(state, tcell.KeyBackspace, 0) assert.True(t, consumed, "Should consume backspace key") - assert.Equal(t, "/hell", state.Query, "Should remove last character") + assert.Equal(t, "hell", state.Query, "Should remove last character") }) t.Run("handles backspace2 for cross-platform support", func(t *testing.T) { state := getSearchState(jobName) state.activateSearch() - state.updateQuery("/world") + state.updateQuery("world") // Test KeyBackspace2 (Windows/alternative systems) consumed := handleSearchKeyInput(state, tcell.KeyBackspace2, 0) assert.True(t, consumed, "Should consume backspace2 key") - assert.Equal(t, "/worl", state.Query, "Should remove last character") + assert.Equal(t, "worl", state.Query, "Should remove last character") }) t.Run("ignores newline characters", func(t *testing.T) { @@ -946,7 +988,7 @@ func Test_searchIntegration_inputCaptureWiring(t *testing.T) { // Ensure search starts inactive state := getSearchState(jobName) state.deactivateSearch() - + // Set up log state as completed so search can be activated logState := getLogState(jobName) logState.Completed = true @@ -958,14 +1000,14 @@ func Test_searchIntegration_inputCaptureWiring(t *testing.T) { // Verify search was activated assert.Nil(t, result, "inputCapture should consume / key when activating search") assert.True(t, state.Active, "Search should be active after / key") - assert.Equal(t, "/", state.Query, "Query should start with /") + assert.Equal(t, "", state.Query, "Query should be empty when first activated") }) t.Run("escape key exits search", func(t *testing.T) { // Setup active search state := getSearchState(jobName) state.activateSearch() - state.updateQuery("/test") + state.updateQuery("test") // Press Escape key event := tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone) @@ -987,7 +1029,7 @@ func Test_searchIntegration_inputCaptureWiring(t *testing.T) { // Verify character was added assert.Nil(t, result, "inputCapture should consume character input in search mode") - assert.Equal(t, "/a", state.Query, "Character should be added to search query") + assert.Equal(t, "a", state.Query, "Character should be added to search query") }) t.Run("ctrl+c is never consumed", func(t *testing.T) { diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index 2c90f2a5b..761aaae36 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -201,7 +201,7 @@ func (s *SearchState) canActivateSearch(content string) bool { func (s *SearchState) activateSearch() { s.Active = true s.InputMode = true - s.Query = "/" + s.Query = "" } // deactivateSearch exits search mode @@ -225,12 +225,8 @@ func (s *SearchState) handleBackspace(key tcell.Key) bool { } if key == tcell.KeyBackspace || key == tcell.KeyBackspace2 { - if len(s.Query) > 1 { - s.Query = s.Query[:len(s.Query)-1] - } else { - // Exit search mode when deleting the last character (/) - s.deactivateSearch() - } + // Remove the last character from the query + s.Query = s.Query[:len(s.Query)-1] return true } return false @@ -313,15 +309,15 @@ func handleSearchKeyInput(state *SearchState, key tcell.Key, char rune) bool { return false } -// handleSearchEscape processes escape key when search might be active -func handleSearchEscape(state *SearchState, jobName string) bool { - if !state.Active { +// handleEscape processes escape key when search might be active +func (s *SearchState) handleEscape(jobName string) bool { + if !s.Active { return false // Let normal escape handling take over } // Clear highlighting before deactivating search clearSearchHighlighting(jobName) - state.deactivateSearch() + s.deactivateSearch() return true // Consumed the escape key } @@ -333,7 +329,7 @@ func handleSearchEnter(state *SearchState, logContent string, jobName string) bo if state.InputMode { // Submit search query - switch from input mode to navigation mode - query := state.Query[1:] // Remove the leading "/" + query := state.Query // If query is empty (just pressed "/" then Enter), don't perform search // but still exit search mode and stay in log view @@ -360,8 +356,20 @@ func handleSearchEnter(state *SearchState, logContent string, jobName string) bo } } -// handleSearchSlash processes "/" key for search activation +// handleSearchSlash processes "/" key for search activation or returning to input mode func handleSearchSlash(state *SearchState, logsVisible, modalVisible bool, logContent string, jobName string) bool { + // If search is already active and in navigation mode, return to input mode (preserving query) + if state.Active && !state.InputMode { + state.InputMode = true + return true // Consumed the "/" key + } + + // If search is already active and in input mode, let the "/" be typed as a character + if state.Active && state.InputMode { + return false // Don't consume the key, let it be handled by search input + } + + // Otherwise, try to activate search (fresh start) if !shouldActivateSearch(logsVisible, modalVisible, logContent, jobName) { return false // Don't consume the key } @@ -395,17 +403,17 @@ func updateSearchDisplay(jobName string, app *tview.Application) { if searchState.InputMode { // Show search input with cursor indicator - frame.AddText(searchState.Query+"█", false, tview.AlignLeft, tcell.ColorYellow) + frame.AddText("Search: "+searchState.Query+"█", false, tview.AlignLeft, tcell.ColorYellow) } else { // Show search results navigation if len(searchState.Matches) > 0 { - text := fmt.Sprintf("%s [%d/%d matches]", - searchState.Query[1:], // Remove leading / + text := fmt.Sprintf("Search: %s [%d/%d matches]", + searchState.Query, searchState.CurrentMatch+1, len(searchState.Matches)) frame.AddText(text, false, tview.AlignLeft, tcell.ColorGreen) } else { - noMatchText := searchState.Query + " [no matches]" + noMatchText := fmt.Sprintf("Search: %s [no matches]", searchState.Query) frame.AddText(noMatchText, false, tview.AlignLeft, tcell.ColorRed) } } @@ -709,7 +717,7 @@ func inputCapture( } } - // Handle slash key for search activation + // Handle slash key for search activation or returning to input mode if event.Rune() == '/' { if handleSearchSlash(searchState, logsVisible, modalVisible, logContent, curJob.Name) { updateSearchDisplay(curJob.Name, app) @@ -719,7 +727,7 @@ func inputCapture( // Handle escape key for search exit if event.Key() == tcell.KeyEscape { - if handleSearchEscape(searchState, curJob.Name) { + if searchState.handleEscape(curJob.Name) { updateSearchDisplay(curJob.Name, app) return nil // Consumed the key } -- GitLab From ded3da82d6ce2e6772e7601daadc0a01cea099dc Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Thu, 4 Sep 2025 10:45:58 -0500 Subject: [PATCH 09/15] feat: add search match highlighting --- internal/commands/ci/view/search_test.go | 169 ++++++++++++++++++++++- internal/commands/ci/view/view.go | 116 +++++++++++++++- 2 files changed, 273 insertions(+), 12 deletions(-) diff --git a/internal/commands/ci/view/search_test.go b/internal/commands/ci/view/search_test.go index 99aa5482c..b2ade18d8 100644 --- a/internal/commands/ci/view/search_test.go +++ b/internal/commands/ci/view/search_test.go @@ -1062,28 +1062,28 @@ func Test_searchHighlighting(t *testing.T) { name: "single match highlighting", logContent: "This is an error message", searchQuery: "error", - expectedOutput: "This is an [red::]error[white::-] message", + expectedOutput: "This is an [red::]error[-:-:-] message", description: "Should highlight single occurrence of search term", }, { name: "multiple matches on same line", logContent: "test error and another test error", searchQuery: "test", - expectedOutput: "[red::]test[white::-] error and another [red::]test[white::-] error", + expectedOutput: "[red::]test[-:-:-] error and another [red::]test[-:-:-] error", description: "Should highlight multiple occurrences on same line", }, { name: "case insensitive highlighting", logContent: "ERROR in system and error in process", searchQuery: "error", - expectedOutput: "[red::]ERROR[white::-] in system and [red::]error[white::-] in process", + expectedOutput: "[red::]ERROR[-:-:-] in system and [red::]error[-:-:-] in process", description: "Should highlight matches regardless of case", }, { name: "multiline content highlighting", logContent: "Line 1: info message\nLine 2: error occurred\nLine 3: info complete", searchQuery: "info", - expectedOutput: "Line 1: [red::]info[white::-] message\nLine 2: error occurred\nLine 3: [red::]info[white::-] complete", + expectedOutput: "Line 1: [red::]info[-:-:-] message\nLine 2: error occurred\nLine 3: [red::]info[-:-:-] complete", description: "Should highlight matches across multiple lines", }, { @@ -1104,14 +1104,14 @@ func Test_searchHighlighting(t *testing.T) { name: "special characters in search", logContent: "Connection [127.0.0.1:3306] established", searchQuery: "127.0.0.1", - expectedOutput: "Connection [[red::]127.0.0.1[white::-]:3306] established", + expectedOutput: "Connection [[red::]127.0.0.1[-:-:-]:3306] established", description: "Should handle special characters in search terms", }, { name: "partial word matching", logContent: "Processing request ID: req_12345_end", searchQuery: "req", - expectedOutput: "Processing [red::]req[white::-]uest ID: [red::]req[white::-]_12345_end", + expectedOutput: "Processing [red::]req[-:-:-]uest ID: [red::]req[-:-:-]_12345_end", description: "Should highlight partial word matches", }, } @@ -1214,3 +1214,160 @@ func Test_searchUI_renderSearchBar(t *testing.T) { }) } } + +// Test_highlightMatchesWithCurrentMatch tests highlighting all matches with current match emphasized +func Test_highlightMatchesWithCurrentMatch(t *testing.T) { + testCases := []struct { + name string + logContent string + searchQuery string + currentMatch int + totalMatches int + expectedOutput string + description string + }{ + { + name: "single match - current match highlighted", + logContent: "This is an error message", + searchQuery: "error", + currentMatch: 0, + totalMatches: 1, + expectedOutput: "This is an [black:yellow]error[-:-:-] message", + description: "Should highlight current match differently from other matches", + }, + { + name: "multiple matches - first match is current", + logContent: "error in line 1 and error in line 2", + searchQuery: "error", + currentMatch: 0, + totalMatches: 2, + expectedOutput: "[black:yellow]error[-:-:-] in line 1 and [red::]error[-:-:-] in line 2", + description: "Should highlight current match in yellow, others in red", + }, + { + name: "multiple matches - second match is current", + logContent: "error in line 1 and error in line 2", + searchQuery: "error", + currentMatch: 1, + totalMatches: 2, + expectedOutput: "[red::]error[-:-:-] in line 1 and [black:yellow]error[-:-:-] in line 2", + description: "Should highlight current match in yellow, others in red", + }, + { + name: "multiple matches across lines - middle match current", + logContent: "Line 1: info message\nLine 2: info data\nLine 3: info complete", + searchQuery: "info", + currentMatch: 1, + totalMatches: 3, + expectedOutput: "Line 1: [red::]info[-:-:-] message\nLine 2: [black:yellow]info[-:-:-] data\nLine 3: [red::]info[-:-:-] complete", + description: "Should highlight current match across multiple lines", + }, + { + name: "no current match set - all matches highlighted normally", + logContent: "error here and error there", + searchQuery: "error", + currentMatch: -1, + totalMatches: 2, + expectedOutput: "[red::]error[-:-:-] here and [red::]error[-:-:-] there", + description: "Should highlight all matches normally when no current match is set", + }, + { + name: "current match index out of bounds - all matches highlighted normally", + logContent: "error here and error there", + searchQuery: "error", + currentMatch: 5, + totalMatches: 2, + expectedOutput: "[red::]error[-:-:-] here and [red::]error[-:-:-] there", + description: "Should highlight all matches normally when current match index is invalid", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := highlightMatchesWithCurrentMatch(tc.logContent, tc.searchQuery, tc.currentMatch) + assert.Equal(t, tc.expectedOutput, result, tc.description) + }) + } +} + +// Test_searchNavigation_currentMatchTracking tests that current match is properly tracked during navigation +func Test_searchNavigation_currentMatchTracking(t *testing.T) { + jobName := "test-job" + logContent := "error on line 1\ninfo message\nerror on line 3\nmore info\nerror on line 5" + + t.Run("current match advances with Enter key", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + state.updateQuery("error") + state.performSearch(logContent, "error") + state.InputMode = false // Switch to navigation mode + state.CurrentMatch = 0 // Start at first match + + // Navigate to next match + consumed := handleSearchEnter(state, logContent, jobName) + + assert.True(t, consumed, "Should consume Enter key for navigation") + assert.Equal(t, 1, state.CurrentMatch, "Should move to second match") + }) + + t.Run("current match wraps around at end", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + state.performSearch(logContent, "error") + state.InputMode = false + state.CurrentMatch = 2 // Last match (0-indexed) + + // Navigate past last match + consumed := handleSearchEnter(state, logContent, jobName) + + assert.True(t, consumed, "Should consume Enter key") + assert.Equal(t, 0, state.CurrentMatch, "Should wrap to first match") + }) + + t.Run("current match updates when new search is performed", func(t *testing.T) { + state := getSearchState(jobName) + state.activateSearch() + state.updateQuery("error") + + // Perform search - should start at first match + consumed := handleSearchEnter(state, logContent, jobName) + + assert.True(t, consumed, "Should consume Enter key when submitting search") + assert.False(t, state.InputMode, "Should exit input mode after Enter") + assert.Equal(t, 3, len(state.Matches), "Should find 3 matches for 'error'") + assert.Equal(t, 0, state.CurrentMatch, "Should start at first match") + }) +} + +// Test_applySearchHighlightingWithCurrentMatch tests that search highlighting with current match works end-to-end +func Test_applySearchHighlightingWithCurrentMatch(t *testing.T) { + jobName := "test-job" + logContent := "error line 1\nerror line 2\nerror line 3" + + // Set up mock TextView + if logViews == nil { + logViews = make(map[string]*tview.TextView) + } + tv := tview.NewTextView() + tv.SetText(logContent) + logViews["logs-"+jobName] = tv + + // Set up search state + state := getSearchState(jobName) + state.activateSearch() + state.performSearch(logContent, "error") + state.CurrentMatch = 1 // Set to second match + state.OriginalContent = logContent + + t.Run("applies highlighting with current match emphasis", func(t *testing.T) { + // This would call the new function that applies highlighting with current match + applySearchHighlightingWithCurrentMatch(jobName, "error") + + // Get the updated text from TextView + highlightedText := tv.GetText(false) + + // Verify that the current match is highlighted differently + assert.Contains(t, highlightedText, "[black:yellow]error[-:-:-]", "Should contain current match highlighting") + assert.Contains(t, highlightedText, "[red::]error[-:-:-]", "Should contain regular match highlighting") + }) +} diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index 761aaae36..1ab09ed6a 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -344,13 +344,15 @@ func handleSearchEnter(state *SearchState, logContent string, jobName string) bo state.CurrentMatch = 0 // Start at first match } - // Apply highlighting to the log content - applySearchHighlighting(jobName, query) + // Apply highlighting with current match emphasis to the log content + applySearchHighlightingWithCurrentMatch(jobName, query) return true // Consumed the enter key } else { // Navigate to next match if len(state.Matches) > 0 { state.CurrentMatch = (state.CurrentMatch + 1) % len(state.Matches) + // Update highlighting to emphasize the new current match + applySearchHighlightingWithCurrentMatch(jobName, state.Query) } return true // Consumed the enter key } @@ -403,7 +405,7 @@ func updateSearchDisplay(jobName string, app *tview.Application) { if searchState.InputMode { // Show search input with cursor indicator - frame.AddText("Search: "+searchState.Query+"█", false, tview.AlignLeft, tcell.ColorYellow) + frame.AddText("Search: "+searchState.Query+"█", false, tview.AlignLeft, tcell.ColorDefault) } else { // Show search results navigation if len(searchState.Matches) > 0 { @@ -411,10 +413,10 @@ func updateSearchDisplay(jobName string, app *tview.Application) { searchState.Query, searchState.CurrentMatch+1, len(searchState.Matches)) - frame.AddText(text, false, tview.AlignLeft, tcell.ColorGreen) + frame.AddText(text, false, tview.AlignLeft, tcell.ColorDefault) } else { noMatchText := fmt.Sprintf("Search: %s [no matches]", searchState.Query) - frame.AddText(noMatchText, false, tview.AlignLeft, tcell.ColorRed) + frame.AddText(noMatchText, false, tview.AlignLeft, tcell.ColorDefault) } } @@ -472,7 +474,7 @@ func highlightMatches(logContent, searchQuery string) string { matchedText := logContent[highlight.start:highlight.end] result.WriteString("[red::]") result.WriteString(matchedText) - result.WriteString("[white::-]") + result.WriteString("[-:-:-]") lastEnd = highlight.end } @@ -485,6 +487,108 @@ func highlightMatches(logContent, searchQuery string) string { return result.String() } +// highlightMatchesWithCurrentMatch highlights all matches in the log content, +// with the current match emphasized differently from other matches +func highlightMatchesWithCurrentMatch(logContent, searchQuery string, currentMatch int) string { + if searchQuery == "" { + return logContent + } + + // Convert to lowercase for case-insensitive matching + lowerQuery := strings.ToLower(searchQuery) + lowerContent := strings.ToLower(logContent) + + // Find all matches and build list of ranges to highlight + var highlights []struct { + start, end int + } + + startPos := 0 + for { + pos := strings.Index(lowerContent[startPos:], lowerQuery) + if pos == -1 { + break + } + + actualPos := startPos + pos + highlights = append(highlights, struct{ start, end int }{ + start: actualPos, + end: actualPos + len(searchQuery), + }) + startPos = actualPos + len(searchQuery) + } + + // If no matches found, return original content + if len(highlights) == 0 { + return logContent + } + + // Build result string with highlighting markup + var result strings.Builder + lastEnd := 0 + + for i, highlight := range highlights { + // Add text before highlight + if highlight.start > lastEnd { + result.WriteString(logContent[lastEnd:highlight.start]) + } + + // Add highlighted text with appropriate markup + matchedText := logContent[highlight.start:highlight.end] + + // Use different highlighting for current match vs other matches + if currentMatch >= 0 && currentMatch < len(highlights) && i == currentMatch { + // Current match: black text on bright yellow background (dramatic highlight) + result.WriteString("[black:yellow]") + result.WriteString(matchedText) + result.WriteString("[-:-:-]") + } else { + // Other matches: red text + result.WriteString("[red::]") + result.WriteString(matchedText) + result.WriteString("[-:-:-]") + } + + lastEnd = highlight.end + } + + // Add remaining text after last highlight + if lastEnd < len(logContent) { + result.WriteString(logContent[lastEnd:]) + } + + return result.String() +} + +// applySearchHighlightingWithCurrentMatch applies search highlighting with current match emphasis +func applySearchHighlightingWithCurrentMatch(jobName, searchQuery string) { + if logViews == nil { + return + } + + tv, exists := logViews["logs-"+jobName] + if !exists { + return + } + + searchState := getSearchState(jobName) + if !searchState.Active || searchQuery == "" { + return + } + + // Get original content or current content + originalContent := searchState.OriginalContent + if originalContent == "" { + // If no original content stored, use current content (removing any existing markup) + originalContent = tv.GetText(false) + searchState.OriginalContent = originalContent + } + + // Apply highlighting with current match emphasis + highlightedContent := highlightMatchesWithCurrentMatch(originalContent, searchQuery, searchState.CurrentMatch) + tv.SetText(highlightedContent) +} + // applySearchHighlighting applies search highlighting to the log TextView for a specific job func applySearchHighlighting(jobName, searchQuery string) { if logViews == nil { -- GitLab From 66255b86269b9e99abf14bc2cc89f629489d16c0 Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Thu, 4 Sep 2025 12:25:36 -0500 Subject: [PATCH 10/15] feat: add search match highlighting in TextView --- internal/commands/ci/view/search_test.go | 244 ++++++++++++++++++++++- internal/commands/ci/view/view.go | 163 ++++++++++++--- 2 files changed, 379 insertions(+), 28 deletions(-) diff --git a/internal/commands/ci/view/search_test.go b/internal/commands/ci/view/search_test.go index b2ade18d8..377f8def8 100644 --- a/internal/commands/ci/view/search_test.go +++ b/internal/commands/ci/view/search_test.go @@ -1259,7 +1259,7 @@ func Test_highlightMatchesWithCurrentMatch(t *testing.T) { searchQuery: "info", currentMatch: 1, totalMatches: 3, - expectedOutput: "Line 1: [red::]info[-:-:-] message\nLine 2: [black:yellow]info[-:-:-] data\nLine 3: [red::]info[-:-:-] complete", + expectedOutput: "Line 1: [\"match_0\"][yellow::]info[-:-:-][\"\"] message\nLine 2: [\"match_1\"][yellow:black]info[-:-:-][\"\"] data\nLine 3: [\"match_2\"][yellow::]info[-:-:-][\"\"] complete", description: "Should highlight current match across multiple lines", }, { @@ -1328,7 +1328,7 @@ func Test_searchNavigation_currentMatchTracking(t *testing.T) { state := getSearchState(jobName) state.activateSearch() state.updateQuery("error") - + // Perform search - should start at first match consumed := handleSearchEnter(state, logContent, jobName) @@ -1371,3 +1371,243 @@ func Test_applySearchHighlightingWithCurrentMatch(t *testing.T) { assert.Contains(t, highlightedText, "[red::]error[-:-:-]", "Should contain regular match highlighting") }) } + +// Test_highlightMatchesWithRegions tests region-based highlighting for scrolling functionality +func Test_highlightMatchesWithRegions(t *testing.T) { + testCases := []struct { + name string + logContent string + searchQuery string + currentMatch int + totalMatches int + expectedOutput string + description string + }{ + { + name: "single match with region", + logContent: "This is an error message", + searchQuery: "error", + currentMatch: 0, + totalMatches: 1, + expectedOutput: `This is an ["match_0"][yellow:black]error[-:-:-][""] message`, + description: "Should wrap current match with region tag and highlight", + }, + { + name: "multiple matches - first match is current", + logContent: "error in line 1 and error in line 2", + searchQuery: "error", + currentMatch: 0, + totalMatches: 2, + expectedOutput: `["match_0"][yellow:black]error[-:-:-][""] in line 1 and ["match_1"][yellow::]error[-:-:-][""] in line 2`, + description: "Should highlight current match with yellow, others with red, all with regions", + }, + { + name: "multiple matches - second match is current", + logContent: "error in line 1 and error in line 2", + searchQuery: "error", + currentMatch: 1, + totalMatches: 2, + expectedOutput: `["match_0"][yellow::]error[-:-:-][""] in line 1 and ["match_1"][yellow:black]error[-:-:-][""] in line 2`, + description: "Should highlight current match with yellow, others with red", + }, + { + name: "multiline content with regions", + logContent: "Line 1: info message\nLine 2: info data\nLine 3: info complete", + searchQuery: "info", + currentMatch: 1, + totalMatches: 3, + expectedOutput: "Line 1: [\"match_0\"][yellow::]info[-:-:-][\"\"] message\nLine 2: [\"match_1\"][yellow:black]info[-:-:-][\"\"] data\nLine 3: [\"match_2\"][yellow::]info[-:-:-][\"\"] complete", + description: "Should create regions across multiple lines with middle match current", + }, + { + name: "no current match set - all matches get regions but no special highlighting", + logContent: "error here and error there", + searchQuery: "error", + currentMatch: -1, + totalMatches: 2, + expectedOutput: `["match_0"][yellow::]error[-:-:-][""] here and ["match_1"][yellow::]error[-:-:-][""] there`, + description: "Should create regions for all matches without current match highlighting", + }, + { + name: "current match index out of bounds - all matches normal", + logContent: "error here and error there", + searchQuery: "error", + currentMatch: 5, + totalMatches: 2, + expectedOutput: `["match_0"][yellow::]error[-:-:-][""] here and ["match_1"][yellow::]error[-:-:-][""] there`, + description: "Should handle invalid current match index gracefully", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := highlightMatchesWithRegions(tc.logContent, tc.searchQuery, tc.currentMatch) + assert.Equal(t, tc.expectedOutput, result, tc.description) + }) + } +} + +// Test_scrollToCurrentMatch tests the scrolling functionality +func Test_scrollToCurrentMatch(t *testing.T) { + // Create a mock TextView + tv := tview.NewTextView() + tv.SetRegions(true) // Enable regions + + testCases := []struct { + name string + currentMatch int + totalMatches int + description string + }{ + { + name: "scroll to first match", + currentMatch: 0, + totalMatches: 3, + description: "Should highlight match_0 region and trigger scroll", + }, + { + name: "scroll to middle match", + currentMatch: 1, + totalMatches: 3, + description: "Should highlight match_1 region and trigger scroll", + }, + { + name: "scroll to last match", + currentMatch: 2, + totalMatches: 3, + description: "Should highlight match_2 region and trigger scroll", + }, + { + name: "invalid match index - no highlight", + currentMatch: -1, + totalMatches: 3, + description: "Should clear all highlights when match index is invalid", + }, + { + name: "match index out of bounds", + currentMatch: 5, + totalMatches: 3, + description: "Should clear all highlights when match index is out of bounds", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // This test verifies the scrollToCurrentMatch function works without panicking + // and properly sets up highlights. In a real implementation, we'd need to verify + // that the correct region is highlighted and ScrollToHighlight is called. + assert.NotPanics(t, func() { + scrollToCurrentMatch(tv, tc.currentMatch) + }, tc.description) + }) + } +} + +// Test_applySearchHighlightingWithRegions tests end-to-end region highlighting and scrolling +func Test_applySearchHighlightingWithRegions(t *testing.T) { + jobName := "test-job" + logContent := "error line 1\nerror line 2\nerror line 3" + + // Set up mock TextView with regions enabled + if logViews == nil { + logViews = make(map[string]*tview.TextView) + } + tv := tview.NewTextView() + tv.SetRegions(true) + tv.SetText(logContent) + logViews["logs-"+jobName] = tv + + // Set up search state + state := getSearchState(jobName) + state.activateSearch() + state.performSearch(logContent, "error") + state.CurrentMatch = 1 // Set to second match + state.OriginalContent = logContent + + t.Run("applies region highlighting with current match scrolling", func(t *testing.T) { + // Call the new function that applies region highlighting and scrolling + assert.NotPanics(t, func() { + applySearchHighlightingWithRegions(jobName, "error") + }, "Should apply region highlighting and scrolling without panicking") + + // Get the updated text from TextView + highlightedText := tv.GetText(false) + + // Verify that regions are created for matches + assert.Contains(t, highlightedText, `["match_0"]`, "Should contain region for first match") + assert.Contains(t, highlightedText, `["match_1"]`, "Should contain region for second match") + assert.Contains(t, highlightedText, `["match_2"]`, "Should contain region for third match") + + // Verify current match highlighting + assert.Contains(t, highlightedText, `["match_1"][yellow:black]error[-:-:-][""]`, "Should highlight current match") + assert.Contains(t, highlightedText, `["match_0"][yellow::]error[-:-:-][""]`, "Should highlight other matches in yellow") + }) +} + +// Test_regionIDGeneration tests that region IDs are generated correctly +func Test_regionIDGeneration(t *testing.T) { + testCases := []struct { + name string + matchIndex int + expectedID string + description string + }{ + { + name: "first match", + matchIndex: 0, + expectedID: "match_0", + description: "Should generate match_0 for first match", + }, + { + name: "middle match", + matchIndex: 5, + expectedID: "match_5", + description: "Should generate match_5 for sixth match", + }, + { + name: "large match number", + matchIndex: 999, + expectedID: "match_999", + description: "Should handle large match numbers", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := generateMatchRegionID(tc.matchIndex) + assert.Equal(t, tc.expectedID, result, tc.description) + }) + } +} + +// Test_clearSearchHighlightingWithRegions tests that regions are properly cleared +func Test_clearSearchHighlightingWithRegions(t *testing.T) { + jobName := "test-job" + originalContent := "error line 1\nerror line 2" + + // Set up mock TextView with regions enabled + if logViews == nil { + logViews = make(map[string]*tview.TextView) + } + tv := tview.NewTextView() + tv.SetRegions(true) + tv.SetText(`["match_0"][black:yellow]error[-:-:-][""] line 1\n["match_1"][red::]error[-:-:-][""] line 2`) + logViews["logs-"+jobName] = tv + + // Set up search state + state := getSearchState(jobName) + state.OriginalContent = originalContent + + t.Run("clears region highlights and restores original content", func(t *testing.T) { + clearSearchHighlightingWithRegions(jobName) + + // Get the updated text from TextView + restoredText := tv.GetText(false) + + // Verify that original content is restored without regions (TextView may add trailing newline) + expectedText := strings.TrimSuffix(restoredText, "\n") + assert.Equal(t, originalContent, expectedText, "Should restore original content without regions") + assert.NotContains(t, restoredText, `["match_`, "Should not contain any region tags") + assert.NotContains(t, restoredText, `[black:yellow]`, "Should not contain highlighting") + }) +} diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index 1ab09ed6a..32f9a06c3 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -315,8 +315,8 @@ func (s *SearchState) handleEscape(jobName string) bool { return false // Let normal escape handling take over } - // Clear highlighting before deactivating search - clearSearchHighlighting(jobName) + // Clear region highlighting before deactivating search + clearSearchHighlightingWithRegions(jobName) s.deactivateSearch() return true // Consumed the escape key } @@ -344,15 +344,15 @@ func handleSearchEnter(state *SearchState, logContent string, jobName string) bo state.CurrentMatch = 0 // Start at first match } - // Apply highlighting with current match emphasis to the log content - applySearchHighlightingWithCurrentMatch(jobName, query) + // Apply region-based highlighting with current match emphasis and scrolling + applySearchHighlightingWithRegions(jobName, query) return true // Consumed the enter key } else { // Navigate to next match if len(state.Matches) > 0 { state.CurrentMatch = (state.CurrentMatch + 1) % len(state.Matches) - // Update highlighting to emphasize the new current match - applySearchHighlightingWithCurrentMatch(jobName, state.Query) + // Update highlighting and scroll to the new current match + applySearchHighlightingWithRegions(jobName, state.Query) } return true // Consumed the enter key } @@ -487,7 +487,7 @@ func highlightMatches(logContent, searchQuery string) string { return result.String() } -// highlightMatchesWithCurrentMatch highlights all matches in the log content, +// highlightMatchesWithCurrentMatch highlights all matches in the log content, // with the current match emphasized differently from other matches func highlightMatchesWithCurrentMatch(logContent, searchQuery string, currentMatch int) string { if searchQuery == "" { @@ -535,7 +535,7 @@ func highlightMatchesWithCurrentMatch(logContent, searchQuery string, currentMat // Add highlighted text with appropriate markup matchedText := logContent[highlight.start:highlight.end] - + // Use different highlighting for current match vs other matches if currentMatch >= 0 && currentMatch < len(highlights) && i == currentMatch { // Current match: black text on bright yellow background (dramatic highlight) @@ -589,39 +589,146 @@ func applySearchHighlightingWithCurrentMatch(jobName, searchQuery string) { tv.SetText(highlightedContent) } -// applySearchHighlighting applies search highlighting to the log TextView for a specific job -func applySearchHighlighting(jobName, searchQuery string) { +// generateMatchRegionID generates a unique region ID for a match index +func generateMatchRegionID(matchIndex int) string { + return fmt.Sprintf("match_%d", matchIndex) +} + +// highlightMatchesWithRegions highlights all matches and wraps them with region tags for scrolling +func highlightMatchesWithRegions(logContent, searchQuery string, currentMatch int) string { + if searchQuery == "" { + return logContent + } + + // Convert to lowercase for case-insensitive matching + lowerQuery := strings.ToLower(searchQuery) + lowerContent := strings.ToLower(logContent) + + // Find all matches and build list of ranges to highlight + var highlights []struct { + start, end int + } + + startPos := 0 + for { + pos := strings.Index(lowerContent[startPos:], lowerQuery) + if pos == -1 { + break + } + + actualPos := startPos + pos + highlights = append(highlights, struct{ start, end int }{ + start: actualPos, + end: actualPos + len(searchQuery), + }) + startPos = actualPos + len(searchQuery) + } + + // If no matches found, return original content + if len(highlights) == 0 { + return logContent + } + + // Build result string with region tags and highlighting markup + var result strings.Builder + lastEnd := 0 + + for i, highlight := range highlights { + // Add text before highlight + if highlight.start > lastEnd { + result.WriteString(logContent[lastEnd:highlight.start]) + } + + // Generate region ID for this match + regionID := generateMatchRegionID(i) + + // Add region start tag + result.WriteString(`["`) + result.WriteString(regionID) + result.WriteString(`"]`) + + // Add highlighted text with appropriate markup + matchedText := logContent[highlight.start:highlight.end] + + // Use different highlighting for current match vs other matches + if currentMatch >= 0 && currentMatch < len(highlights) && i == currentMatch { + // Current match: will be inverted by region highlight to yellow background with black text + result.WriteString("[yellow:black]") + result.WriteString(matchedText) + result.WriteString("[-:-:-]") + } else { + // Other matches: yellow text (no region highlighting so no inversion) + result.WriteString("[yellow::]") + result.WriteString(matchedText) + result.WriteString("[-:-:-]") + } + + // Add region end tag + result.WriteString(`[""]`) + + lastEnd = highlight.end + } + + // Add remaining text after last highlight + if lastEnd < len(logContent) { + result.WriteString(logContent[lastEnd:]) + } + + return result.String() +} + +// scrollToCurrentMatch highlights the current match region and scrolls to it +func scrollToCurrentMatch(tv *tview.TextView, currentMatch int) { + if currentMatch < 0 { + // Clear all highlights if no valid current match + tv.Highlight() + return + } + + // Generate region ID for the current match + regionID := generateMatchRegionID(currentMatch) + + // Highlight the current match region (this will make it visually distinct with inverted colors) + tv.Highlight(regionID) + + // Scroll to the highlighted region + tv.ScrollToHighlight() +} + +// applySearchHighlightingWithRegions applies region-based search highlighting with scrolling +func applySearchHighlightingWithRegions(jobName, searchQuery string) { if logViews == nil { return } - logsKey := "logs-" + jobName - tv, exists := logViews[logsKey] + tv, exists := logViews["logs-"+jobName] if !exists { return } searchState := getSearchState(jobName) - - // Use the original content that was captured when logs completed - // This prevents any accumulation of newlines or highlighting artifacts - if searchState.OriginalContent == "" { - // Fallback: if for some reason original content wasn't captured, use current content - searchState.OriginalContent = tv.GetText(false) + if !searchState.Active || searchQuery == "" { + return } - // Always apply highlighting to the stored original content - highlightedContent := highlightMatches(searchState.OriginalContent, searchQuery) - - // Strip any trailing newline to prevent accumulation when TextView adds its own - highlightedContent = strings.TrimSuffix(highlightedContent, "\n") + // Get original content or current content + originalContent := searchState.OriginalContent + if originalContent == "" { + // If no original content stored, use current content (removing any existing markup) + originalContent = tv.GetText(false) + searchState.OriginalContent = originalContent + } - // Update the TextView with highlighted content + // Apply region-based highlighting with current match emphasis + highlightedContent := highlightMatchesWithRegions(originalContent, searchQuery, searchState.CurrentMatch) tv.SetText(highlightedContent) + + // Scroll to the current match + scrollToCurrentMatch(tv, searchState.CurrentMatch) } -// clearSearchHighlighting removes search highlighting from the log TextView -func clearSearchHighlighting(jobName string) { +// clearSearchHighlightingWithRegions removes region highlighting and restores original content +func clearSearchHighlightingWithRegions(jobName string) { if logViews == nil { return } @@ -632,6 +739,9 @@ func clearSearchHighlighting(jobName string) { return } + // Clear all region highlights + tv.Highlight() + // Restore the original content with its original formatting searchState := getSearchState(jobName) if searchState.OriginalContent != "" { @@ -1167,6 +1277,7 @@ func jobsView( tv := tview.NewTextView() tv. SetDynamicColors(true). + SetRegions(true). SetBackgroundColor(tcell.ColorDefault). SetBorderPadding(0, 0, 1, 1). SetBorder(true) -- GitLab From 600ca6f797af7cd7a7761ac18f2c7695e99ae68c Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Thu, 4 Sep 2025 12:31:21 -0500 Subject: [PATCH 11/15] fix: update tests after applying formatting --- internal/commands/ci/view/search_test.go | 2 +- internal/commands/ci/view/view.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/commands/ci/view/search_test.go b/internal/commands/ci/view/search_test.go index 377f8def8..c23713cdd 100644 --- a/internal/commands/ci/view/search_test.go +++ b/internal/commands/ci/view/search_test.go @@ -1259,7 +1259,7 @@ func Test_highlightMatchesWithCurrentMatch(t *testing.T) { searchQuery: "info", currentMatch: 1, totalMatches: 3, - expectedOutput: "Line 1: [\"match_0\"][yellow::]info[-:-:-][\"\"] message\nLine 2: [\"match_1\"][yellow:black]info[-:-:-][\"\"] data\nLine 3: [\"match_2\"][yellow::]info[-:-:-][\"\"] complete", + expectedOutput: "Line 1: [red::]info[-:-:-] message\nLine 2: [black:yellow]info[-:-:-] data\nLine 3: [red::]info[-:-:-] complete", description: "Should highlight current match across multiple lines", }, { diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index 32f9a06c3..eff2b0fff 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -773,7 +773,8 @@ func NewCmdView(f cmdutils.Factory) *cobra.Command { - 'Ctrl+D' to cancel a job. If the selected job isn't running or pending, quits the CI/CD view. - 'Ctrl+Q' to quit the CI/CD view. - 'Ctrl+Space' to suspend application and view the logs. Similar to 'glab pipeline ci trace'. - Supports vi style bindings and arrow keys for navigating jobs and logs. + - '/' to search logs. Type your search query, hit 'Enter' to perform the search. 'Esc' exits search + Supports vi style bindings and arrow keys for navigating jobs, logs, and search results `), Example: heredoc.Doc(` # Uses current branch -- GitLab From a0bd50b8a21639d83472b88f00248a93df528eab Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Thu, 4 Sep 2025 13:07:23 -0500 Subject: [PATCH 12/15] feat: add n, N keybindings to navigate searchalso add <, > keybindings to scroll to beginning/end of logs --- internal/commands/ci/view/view.go | 118 +++++++++++++++++++++++++++--- 1 file changed, 108 insertions(+), 10 deletions(-) diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index eff2b0fff..b5670ce57 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -239,17 +239,20 @@ func (s *SearchState) performSearch(content, query string) []SearchMatch { return s.Matches } + s.Query = query + var matches []SearchMatch lines := strings.Split(content, "\n") - lowerQuery := strings.ToLower(query) + + // Always do case-insensitive search + searchQuery := strings.ToLower(query) for lineNum, line := range lines { - lowerLine := strings.ToLower(line) + searchLine := strings.ToLower(line) searchStart := 0 - for { - // Find next occurrence of query in the line (case-insensitive) - idx := strings.Index(lowerLine[searchStart:], lowerQuery) + // Find next occurrence of query in the line + idx := strings.Index(searchLine[searchStart:], searchQuery) if idx == -1 { break } @@ -358,6 +361,69 @@ func handleSearchEnter(state *SearchState, logContent string, jobName string) bo } } +// handleSearchNext navigates to the next search match +func handleSearchNext(state *SearchState, jobName string) bool { + if !state.Active || len(state.Matches) == 0 { + return false + } + + // Move to next match with wrap-around + state.CurrentMatch = (state.CurrentMatch + 1) % len(state.Matches) + + // Apply region-based highlighting with current match emphasis and scrolling + applySearchHighlightingWithRegions(jobName, state.Query) + return true +} + +// handleSearchPrevious navigates to the previous search match +func handleSearchPrevious(state *SearchState, jobName string) bool { + if !state.Active || len(state.Matches) == 0 { + return false + } + + // Move to previous match with wrap-around + state.CurrentMatch-- + if state.CurrentMatch < 0 { + state.CurrentMatch = len(state.Matches) - 1 + } + + // Apply region-based highlighting with current match emphasis and scrolling + applySearchHighlightingWithRegions(jobName, state.Query) + return true +} + +// handleLogBeginning scrolls to the beginning of the log +func handleLogBeginning(jobName string) bool { + if logViews == nil { + return false + } + + logsKey := "logs-" + jobName + tv, exists := logViews[logsKey] + if !exists { + return false + } + + tv.ScrollToBeginning() + return true +} + +// handleLogEnd scrolls to the end of the log +func handleLogEnd(jobName string) bool { + if logViews == nil { + return false + } + + logsKey := "logs-" + jobName + tv, exists := logViews[logsKey] + if !exists { + return false + } + + tv.ScrollToEnd() + return true +} + // handleSearchSlash processes "/" key for search activation or returning to input mode func handleSearchSlash(state *SearchState, logsVisible, modalVisible bool, logContent string, jobName string) bool { // If search is already active and in navigation mode, return to input mode (preserving query) @@ -405,7 +471,8 @@ func updateSearchDisplay(jobName string, app *tview.Application) { if searchState.InputMode { // Show search input with cursor indicator - frame.AddText("Search: "+searchState.Query+"█", false, tview.AlignLeft, tcell.ColorDefault) + searchText := "Search: " + searchState.Query + "█" + frame.AddText(searchText, false, tview.AlignLeft, tcell.ColorDefault) } else { // Show search results navigation if len(searchState.Matches) > 0 { @@ -431,7 +498,7 @@ func highlightMatches(logContent, searchQuery string) string { return logContent } - // Convert to lowercase for case-insensitive matching + // Always do case-insensitive search lowerQuery := strings.ToLower(searchQuery) lowerContent := strings.ToLower(logContent) @@ -494,7 +561,7 @@ func highlightMatchesWithCurrentMatch(logContent, searchQuery string, currentMat return logContent } - // Convert to lowercase for case-insensitive matching + // Always do case-insensitive search lowerQuery := strings.ToLower(searchQuery) lowerContent := strings.ToLower(logContent) @@ -600,7 +667,7 @@ func highlightMatchesWithRegions(logContent, searchQuery string, currentMatch in return logContent } - // Convert to lowercase for case-insensitive matching + // Always do case-insensitive search lowerQuery := strings.ToLower(searchQuery) lowerContent := strings.ToLower(logContent) @@ -773,7 +840,8 @@ func NewCmdView(f cmdutils.Factory) *cobra.Command { - 'Ctrl+D' to cancel a job. If the selected job isn't running or pending, quits the CI/CD view. - 'Ctrl+Q' to quit the CI/CD view. - 'Ctrl+Space' to suspend application and view the logs. Similar to 'glab pipeline ci trace'. - - '/' to search logs. Type your search query, hit 'Enter' to perform the search. 'Esc' exits search + - '/' to search logs. 'Enter' performs the search, 'n' and 'N' selects next/previous result. 'Esc' exits search + Supports vi style bindings and arrow keys for navigating jobs, logs, and search results `), Example: heredoc.Doc(` @@ -956,6 +1024,36 @@ func inputCapture( } } + // Handle n/N keys for search navigation (only when search is active and not in input mode) + if searchState.Active && !searchState.InputMode { + if event.Rune() == 'n' { + if handleSearchNext(searchState, curJob.Name) { + updateSearchDisplay(curJob.Name, app) + return nil // Consumed the key + } + } + if event.Rune() == 'N' { + if handleSearchPrevious(searchState, curJob.Name) { + updateSearchDisplay(curJob.Name, app) + return nil // Consumed the key + } + } + } + + // Handle keys for log navigation (when logs are visible but search is not in input mode) + if !searchState.InputMode { + if event.Rune() == '<' { + if handleLogBeginning(curJob.Name) { + return nil // Consumed the key + } + } + if event.Rune() == '>' { + if handleLogEnd(curJob.Name) { + return nil // Consumed the key + } + } + } + // Handle character and backspace input in search mode if searchState.Active && searchState.InputMode { if handleSearchKeyInput(searchState, event.Key(), event.Rune()) { -- GitLab From 1e1abdc4b4ca90483ebf6b6dcd271b7f84f1002b Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Thu, 4 Sep 2025 13:23:21 -0500 Subject: [PATCH 13/15] fix: using consistent pattern for calculating query length --- internal/commands/ci/view/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/commands/ci/view/view.go b/internal/commands/ci/view/view.go index b5670ce57..93eceb29c 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -259,7 +259,7 @@ func (s *SearchState) performSearch(content, query string) []SearchMatch { // Calculate actual position in original line actualStart := searchStart + idx - actualEnd := actualStart + len(query) + actualEnd := actualStart + len(searchQuery) matches = append(matches, SearchMatch{ Line: lineNum, -- GitLab From 14d5ee4c4e5ba230d48fe05c603c10f0a4e970e1 Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Thu, 4 Sep 2025 13:27:42 -0500 Subject: [PATCH 14/15] docs: updated search documentation --- docs/source/ci/ci/view.md | 4 +++- docs/source/ci/view.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/source/ci/ci/view.md b/docs/source/ci/ci/view.md index 3a8a1062c..3a9f1dc90 100644 --- a/docs/source/ci/ci/view.md +++ b/docs/source/ci/ci/view.md @@ -25,7 +25,9 @@ Use arrow keys to navigate jobs and logs. - 'Ctrl+D' to cancel a job. If the selected job isn't running or pending, quits the CI/CD view. - 'Ctrl+Q' to quit the CI/CD view. - 'Ctrl+Space' to suspend application and view the logs. Similar to 'glab pipeline ci trace'. -Supports vi style bindings and arrow keys for navigating jobs and logs. +- '/' to search logs. 'Enter' performs the search, 'n' and 'N' selects next/previous result. 'Esc' exits search + +Supports vi style bindings and arrow keys for navigating jobs, logs, and search results ```plaintext glab ci ci view [branch/tag] [flags] diff --git a/docs/source/ci/view.md b/docs/source/ci/view.md index 95e4a6d2d..50ad3cd92 100644 --- a/docs/source/ci/view.md +++ b/docs/source/ci/view.md @@ -25,7 +25,9 @@ Use arrow keys to navigate jobs and logs. - 'Ctrl+D' to cancel a job. If the selected job isn't running or pending, quits the CI/CD view. - 'Ctrl+Q' to quit the CI/CD view. - 'Ctrl+Space' to suspend application and view the logs. Similar to 'glab pipeline ci trace'. -Supports vi style bindings and arrow keys for navigating jobs and logs. +- '/' to search logs. 'Enter' performs the search, 'n' and 'N' selects next/previous result. 'Esc' exits search + +Supports vi style bindings and arrow keys for navigating jobs, logs, and search results ```plaintext glab ci view [branch/tag] [flags] -- GitLab From a21eaa68a1c80024fc93b6a9cf23d26b9e15261c Mon Sep 17 00:00:00 2001 From: Ryan Taylor Date: Thu, 4 Sep 2025 13:53:39 -0500 Subject: [PATCH 15/15] fix: reverting bugfix to runTrace()This is tracked in issue: #7984 and merge request 2355 --- internal/commands/ci/ciutils/utils.go | 8 +++++--- internal/commands/ci/trace/trace_test.go | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/commands/ci/ciutils/utils.go b/internal/commands/ci/ciutils/utils.go index 40feef19c..f41050370 100644 --- a/internal/commands/ci/ciutils/utils.go +++ b/internal/commands/ci/ciutils/utils.go @@ -24,6 +24,11 @@ import ( gitlab "gitlab.com/gitlab-org/api/client-go" ) +var ( + once sync.Once + offset int64 +) + func makeHyperlink(s *iostreams.IOStreams, pipeline *gitlab.PipelineInfo) string { return s.Hyperlink(fmt.Sprintf("%d", pipeline.ID), pipeline.WebURL) } @@ -85,9 +90,6 @@ func RunTraceSha(ctx context.Context, apiClient *gitlab.Client, w io.Writer, pid } func runTrace(ctx context.Context, apiClient *gitlab.Client, w io.Writer, pid any, jobId int) error { - var once sync.Once - var offset int64 - fmt.Fprintln(w, "Getting job trace...") for range time.NewTicker(time.Second * 3).C { if ctx.Err() == context.Canceled { diff --git a/internal/commands/ci/trace/trace_test.go b/internal/commands/ci/trace/trace_test.go index 477b2e21b..4118800d4 100644 --- a/internal/commands/ci/trace/trace_test.go +++ b/internal/commands/ci/trace/trace_test.go @@ -66,7 +66,7 @@ func TestCiTrace(t *testing.T) { name: "when trace for job-id is requested and getTrace throws error", args: "1122", expectedError: "failed to find job: GET https://gitlab.com/api/v4/projects/OWNER%2FREPO/jobs/1122/trace: 403", - expectedOut: "\nGetting job trace...\nShowing logs for lint job #1122.\n", + expectedOut: "\nGetting job trace...\n", httpMocks: []httpMock{ { http.MethodGet, @@ -103,7 +103,7 @@ func TestCiTrace(t *testing.T) { { name: "when trace for job-name is requested", args: "lint -b main -p 123", - expectedOut: "\nGetting job trace...\nShowing logs for lint job #1122.\nLorem ipsum", + expectedOut: "\nGetting job trace...\n", httpMocks: []httpMock{ { http.MethodGet, @@ -140,7 +140,7 @@ func TestCiTrace(t *testing.T) { { name: "when trace for job-name and last pipeline is requested", args: "lint -b main", - expectedOut: "\nGetting job trace...\nShowing logs for lint job #1122.\nLorem ipsum", + expectedOut: "\nGetting job trace...\n", httpMocks: []httpMock{ { http.MethodGet, -- GitLab