diff --git a/docs/source/ci/ci/view.md b/docs/source/ci/ci/view.md index 3a8a1062c6affef42f92d54135103d0e89368a39..3a9f1dc902379b319839c1a6a4cbab3139711892 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 95e4a6d2d052732ed42afcf0d5de63c7ee926b08..50ad3cd92b62240402cce2930facd0a851e1cdcf 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] diff --git a/internal/commands/ci/view/search_test.go b/internal/commands/ci/view/search_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c23713cdda7488ad62b1a122b5f66b0550f6b4a8 --- /dev/null +++ b/internal/commands/ci/view/search_test.go @@ -0,0 +1,1613 @@ +package view + +import ( + "strings" + "testing" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "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 be empty 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 the search query is empty + for len(state.Query) > 0 { + state.handleBackspace(tcell.KeyBackspace) + } + + assert.Equal(t, "", state.Query, "Query should be empty after clearing") + assert.True(t, state.Active, "Search should remain active after clearing query") + + 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 + 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}, + {Line: 4, Start: 0, End: 5}, + }, + }, + { + name: "case insensitive - 'info'", + query: "info", + expectedCount: 3, + expectedMatches: []SearchMatch{ + {Line: 0, Start: 0, End: 4}, + {Line: 2, Start: 0, End: 4}, + {Line: 5, Start: 0, End: 4}, + }, + }, + { + name: "case insensitive - 'CONNECTION'", + query: "CONNECTION", + expectedCount: 3, + expectedMatches: []SearchMatch{ + {Line: 1, Start: 16, End: 26}, + {Line: 2, Start: 15, End: 25}, + {Line: 3, Start: 7, End: 17}, + }, + }, + { + 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) + } + }) + } +} + +// 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) { + // 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) + }) + } +} + +// 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 + + // 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") + assert.True(t, state.InputMode, "Should be in input mode after / key") + 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) { + state := getSearchState(jobName) + state.deactivateSearch() + + 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") + }) + + t.Run("does not activate when modal visible", func(t *testing.T) { + state := getSearchState(jobName) + state.deactivateSearch() + + 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") + }) + + t.Run("does not activate when no content", func(t *testing.T) { + state := getSearchState(jobName) + state.deactivateSearch() + + 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") + }) + + 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 +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 := 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") + 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 := state.handleEscape(jobName) + + 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, jobName) + + 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, jobName) + + 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, jobName) + + 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, jobName) + + 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 be empty when first activated") + + // 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 + + // 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") + + t.Run("slash key activates search", func(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) + 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 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") + + // 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") + }) + + 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_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[-:-:-] 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[-:-:-] 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[-:-:-] 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[-:-:-] message\nLine 2: error occurred\nLine 3: [red::]info[-:-:-] 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[-:-:-]: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[-:-:-]uest ID: [red::]req[-:-:-]_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 + + 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) + }) + } +} + +// 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") + }) +} + +// 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 1c18e1e106b88b4e9f528e96d270ddd00ddbdbc8..93eceb29c575565a3383c6f1c287829659ed7ff0 100644 --- a/internal/commands/ci/view/view.go +++ b/internal/commands/ci/view/view.go @@ -66,6 +66,28 @@ type ViewJob struct { OriginalBridge *gitlab.Bridge } +type SearchState struct { + 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 { + Line int + Start int + End int +} + func ViewJobFromBridge(bridge *gitlab.Bridge) *ViewJob { vj := &ViewJob{} vj.ID = bridge.ID @@ -98,6 +120,706 @@ 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) + } +} + +// 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 != "" +} + +// 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 { + // Remove the last character from the query + s.Query = s.Query[:len(s.Query)-1] + 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 + } + + s.Query = query + + var matches []SearchMatch + lines := strings.Split(content, "\n") + + // Always do case-insensitive search + searchQuery := strings.ToLower(query) + + for lineNum, line := range lines { + searchLine := strings.ToLower(line) + searchStart := 0 + for { + // Find next occurrence of query in the line + idx := strings.Index(searchLine[searchStart:], searchQuery) + if idx == -1 { + break + } + + // Calculate actual position in original line + actualStart := searchStart + idx + actualEnd := actualStart + len(searchQuery) + + 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) +} + +// shouldActivateSearch determines if search can be activated based on current state +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 +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 +} + +// 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 region highlighting before deactivating search + clearSearchHighlightingWithRegions(jobName) + s.deactivateSearch() + return true // Consumed the escape key +} + +// handleSearchEnter processes enter key when search might be active +func handleSearchEnter(state *SearchState, logContent string, jobName 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 + + // 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) == "" { + 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 { + state.CurrentMatch = 0 // Start at first match + } + + // 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 and scroll to the new current match + applySearchHighlightingWithRegions(jobName, state.Query) + } + return true // Consumed the enter key + } +} + +// 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) + 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 + } + + state.activateSearch() + 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 + searchText := "Search: " + searchState.Query + "█" + frame.AddText(searchText, false, tview.AlignLeft, tcell.ColorDefault) + } else { + // Show search results navigation + if len(searchState.Matches) > 0 { + text := fmt.Sprintf("Search: %s [%d/%d matches]", + searchState.Query, + searchState.CurrentMatch+1, + len(searchState.Matches)) + 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.ColorDefault) + } + } + + if app != nil { + app.ForceDraw() + } +} + +// highlightMatches adds tview markup to highlight search matches in log text +func highlightMatches(logContent, searchQuery string) string { + if searchQuery == "" || logContent == "" { + return logContent + } + + // Always do case-insensitive search + 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("[-:-:-]") + + lastEnd = highlight.end + } + + // Add remaining text after last highlight + if lastEnd < len(logContent) { + result.WriteString(logContent[lastEnd:]) + } + + 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 + } + + // Always do case-insensitive search + 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) +} + +// 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 + } + + // Always do case-insensitive search + 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 + } + + 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 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) +} + +// clearSearchHighlightingWithRegions removes region highlighting and restores original content +func clearSearchHighlightingWithRegions(jobName string) { + if logViews == nil { + return + } + + logsKey := "logs-" + jobName + tv, exists := logViews[logsKey] + if !exists { + return + } + + // Clear all region highlights + tv.Highlight() + + // 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(), @@ -118,7 +840,9 @@ 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. '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(` # Uses current branch @@ -258,6 +982,87 @@ 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) + + // Get log content for search operations + var logContent string + logsKey := "logs-" + curJob.Name + if logViews != nil { + if tv, exists := logViews[logsKey]; exists { + logContent = tv.GetText(false) // false = don't strip formatting + } + } + + // 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) + return nil // Consumed the key + } + } + + // Handle escape key for search exit + if event.Key() == tcell.KeyEscape { + if searchState.handleEscape(curJob.Name) { + updateSearchDisplay(curJob.Name, app) + return nil // Consumed the key + } + } + + // Handle enter key for search submission/navigation + if event.Key() == tcell.KeyEnter { + if handleSearchEnter(searchState, logContent, curJob.Name) { + updateSearchDisplay(curJob.Name, app) + return nil // Consumed the key + } + } + + // 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()) { + updateSearchDisplay(curJob.Name, app) + return nil // Consumed the key + } + } + } + if event.Rune() == 'q' || event.Key() == tcell.KeyEscape { switch { case modalVisible: @@ -431,6 +1236,10 @@ var ( jobs []*ViewJob pipelines []gitlab.PipelineInfo boxes map[string]*tview.TextView + 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 { @@ -567,11 +1376,48 @@ func jobsView( tv := tview.NewTextView() tv. SetDynamicColors(true). + SetRegions(true). SetBackgroundColor(tcell.ColorDefault). SetBorderPadding(0, 0, 1, 1). SetBorder(true) + // 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 + + // 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, @@ -585,7 +1431,7 @@ func jobsView( log.Fatal(err) } }() - root.AddAndSwitchToPage("logs-"+curJob.Name, tv, true) + root.AddAndSwitchToPage("logs-"+curJob.Name, frame, true) } return }