diff --git a/internal/cache/affinity.go b/internal/cache/affinity.go index 30d1bbc..690f039 100644 --- a/internal/cache/affinity.go +++ b/internal/cache/affinity.go @@ -370,3 +370,102 @@ func extractSearchPrefix(term string) string { return "" } + +// AddSearchHistory adds a search term to the search history. +func (c *Cache) AddSearchHistory(searchTerm string) error { + if c.isNoOp() { + return nil + } + + searchTerm = normalizeSearchTerm(searchTerm) + if searchTerm == "" { + return nil + } + + start := time.Now() + defer func() { + c.stats.recordOperation("AddSearchHistory", time.Since(start)) + }() + + now := time.Now().Unix() + + // Insert or update the search history + query := ` + INSERT INTO search_history (search_term, last_used, use_count) + VALUES (?, ?, 1) + ON CONFLICT(search_term) DO UPDATE SET + last_used = excluded.last_used, + use_count = use_count + 1` + + logSQL(query, searchTerm, now) + + _, err := c.db.Exec(query, searchTerm, now) + return err +} + +// GetSearchHistory returns recent search terms, ordered by most recent first. +// Limited to the specified count (default 10). +func (c *Cache) GetSearchHistory(limit int) ([]string, error) { + if c.isNoOp() { + return nil, nil + } + + if limit <= 0 { + limit = 10 + } + + start := time.Now() + defer func() { + c.stats.recordOperation("GetSearchHistory", time.Since(start)) + }() + + query := `SELECT search_term FROM search_history ORDER BY last_used DESC LIMIT ?` + logSQL(query, limit) + + rows, err := c.db.Query(query, limit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var history []string + for rows.Next() { + var term string + if err := rows.Scan(&term); err != nil { + logger.Log.Warnf("Failed to scan search history: %v", err) + continue + } + history = append(history, term) + } + + return history, rows.Err() +} + +// CleanSearchHistory removes old search history entries. +func (c *Cache) CleanSearchHistory(maxAge time.Duration) (int64, error) { + if c.isNoOp() { + return 0, nil + } + + start := time.Now() + defer func() { + c.stats.recordOperation("CleanSearchHistory", time.Since(start)) + }() + + cutoff := time.Now().Add(-maxAge).Unix() + + query := `DELETE FROM search_history WHERE last_used < ?` + logSQL(query, cutoff) + + result, err := c.db.Exec(query, cutoff) + if err != nil { + return 0, err + } + + deleted, _ := result.RowsAffected() + if deleted > 0 { + logger.Log.Debugf("Cleaned %d old search history entries", deleted) + } + + return deleted, nil +} diff --git a/internal/cache/migrations/v8_search_history.go b/internal/cache/migrations/v8_search_history.go new file mode 100644 index 0000000..e5bd077 --- /dev/null +++ b/internal/cache/migrations/v8_search_history.go @@ -0,0 +1,33 @@ +package migrations + +import ( + "database/sql" +) + +func init() { + Register(&v8SearchHistory{}) +} + +// v8SearchHistory adds search history table for storing recent searches. +type v8SearchHistory struct{} + +func (m *v8SearchHistory) Version() int { + return 8 +} + +func (m *v8SearchHistory) Description() string { + return "Add search history table for recent searches" +} + +func (m *v8SearchHistory) Up(db *sql.DB) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS search_history ( + search_term TEXT PRIMARY KEY, + last_used INTEGER NOT NULL, + use_count INTEGER DEFAULT 1 + )`, + `CREATE INDEX IF NOT EXISTS idx_search_history_last_used ON search_history(last_used DESC)`, + } + + return ExecStatements(db, statements) +} diff --git a/internal/gcp/search/connectivity_tests.go b/internal/gcp/search/connectivity_tests.go new file mode 100644 index 0000000..1ef458d --- /dev/null +++ b/internal/gcp/search/connectivity_tests.go @@ -0,0 +1,99 @@ +package search + +import ( + "context" + "fmt" + + "github.com/kedare/compass/internal/gcp" +) + +// ConnectivityTestClientFactory creates a connectivity test client scoped to a project. +type ConnectivityTestClientFactory func(ctx context.Context, project string) (ConnectivityTestClient, error) + +// ConnectivityTestClient exposes the subset of gcp.ConnectivityClient used by the searcher. +type ConnectivityTestClient interface { + ListTests(ctx context.Context, filter string) ([]*gcp.ConnectivityTestResult, error) +} + +// ConnectivityTestProvider searches connectivity tests for query matches. +type ConnectivityTestProvider struct { + NewClient ConnectivityTestClientFactory +} + +// Kind returns the resource kind this provider handles. +func (p *ConnectivityTestProvider) Kind() ResourceKind { + return KindConnectivityTest +} + +// Search implements the Provider interface. +func (p *ConnectivityTestProvider) Search(ctx context.Context, project string, query Query) ([]Result, error) { + if p == nil || p.NewClient == nil { + return nil, fmt.Errorf("%s: %w", project, ErrNoProviders) + } + + client, err := p.NewClient(ctx, project) + if err != nil { + return nil, fmt.Errorf("failed to create client for %s: %w", project, err) + } + + tests, err := client.ListTests(ctx, "") + if err != nil { + return nil, fmt.Errorf("failed to list connectivity tests in %s: %w", project, err) + } + + matches := make([]Result, 0, len(tests)) + for _, test := range tests { + if test == nil { + continue + } + + // Build searchable fields + searchFields := []string{test.Name, test.DisplayName, test.Description, test.Protocol} + + // Add source endpoint info + if test.Source != nil { + searchFields = append(searchFields, test.Source.Instance, test.Source.IPAddress, test.Source.Network) + } + + // Add destination endpoint info + if test.Destination != nil { + searchFields = append(searchFields, test.Destination.Instance, test.Destination.IPAddress, test.Destination.Network) + } + + if !query.MatchesAny(searchFields...) { + continue + } + + matches = append(matches, Result{ + Type: KindConnectivityTest, + Name: test.Name, + Project: test.ProjectID, + Location: "global", + Details: connectivityTestDetails(test), + }) + } + + return matches, nil +} + +// connectivityTestDetails extracts display metadata for a connectivity test. +func connectivityTestDetails(test *gcp.ConnectivityTestResult) map[string]string { + details := map[string]string{ + "displayName": test.DisplayName, + "protocol": test.Protocol, + } + + if test.ReachabilityDetails != nil { + details["result"] = test.ReachabilityDetails.Result + } + + if test.Source != nil && test.Source.IPAddress != "" { + details["source"] = test.Source.IPAddress + } + + if test.Destination != nil && test.Destination.IPAddress != "" { + details["destination"] = test.Destination.IPAddress + } + + return details +} diff --git a/internal/gcp/search/engine.go b/internal/gcp/search/engine.go index 7f9f3c1..88171ef 100644 --- a/internal/gcp/search/engine.go +++ b/internal/gcp/search/engine.go @@ -239,46 +239,75 @@ func (e *Engine) SearchStreaming(ctx context.Context, projects []string, query Q var results []Result var warnings []SearchWarning - var wg sync.WaitGroup - for _, project := range trimmed { - project := project - wg.Add(1) - go func() { - defer wg.Done() + // Split projects into priority batches for better affinity-based ordering + // Search top projects first to show relevant results faster + priorityBatchSize := 5 // Search top 5 projects first + if len(trimmed) < priorityBatchSize { + priorityBatchSize = len(trimmed) + } - sem <- struct{}{} - defer func() { <-sem }() + searchProjectBatch := func(projects []string) { + var wg sync.WaitGroup + for _, project := range projects { + project := project + wg.Add(1) + go func() { + defer wg.Done() - // Check if context was cancelled - if ctx.Err() != nil { - return - } + sem <- struct{}{} + defer func() { <-sem }() - for _, provider := range activeProviders { - // Check cancellation before each provider + // Check if context was cancelled if ctx.Err() != nil { return } - providerResults, err := provider.Search(ctx, project, query) + for _, provider := range activeProviders { + // Check cancellation before each provider + if ctx.Err() != nil { + return + } - // Update completed count regardless of result - mu.Lock() - completedRequests++ - currentCompleted := completedRequests - mu.Unlock() + providerResults, err := provider.Search(ctx, project, query) - if err != nil { - // Record warning but continue with other providers + // Update completed count regardless of result mu.Lock() - warnings = append(warnings, SearchWarning{ - Project: project, - Provider: provider.Kind(), - Err: err, - }) + completedRequests++ + currentCompleted := completedRequests + mu.Unlock() + + if err != nil { + // Record warning but continue with other providers + mu.Lock() + warnings = append(warnings, SearchWarning{ + Project: project, + Provider: provider.Kind(), + Err: err, + }) + mu.Unlock() + + // Still send progress update even on error + if callback != nil { + progress := SearchProgress{ + TotalRequests: totalRequests, + CompletedRequests: currentCompleted, + PendingRequests: totalRequests - currentCompleted, + CurrentProject: project, + CurrentProvider: string(provider.Kind()), + } + _ = callback(nil, progress) + } + continue + } + + // Call the callback with new results and progress + mu.Lock() + results = append(results, providerResults...) + currentResults := make([]Result, len(providerResults)) + copy(currentResults, providerResults) mu.Unlock() - // Still send progress update even on error + // Send progress update with new results if callback != nil { progress := SearchProgress{ TotalRequests: totalRequests, @@ -287,38 +316,27 @@ func (e *Engine) SearchStreaming(ctx context.Context, projects []string, query Q CurrentProject: project, CurrentProvider: string(provider.Kind()), } - _ = callback(nil, progress) + if err := callback(currentResults, progress); err != nil { + // Callback requested stop - just return + return + } } - continue } + }() + } + wg.Wait() + } - // Call the callback with new results and progress - mu.Lock() - results = append(results, providerResults...) - currentResults := make([]Result, len(providerResults)) - copy(currentResults, providerResults) - mu.Unlock() + // Search high-priority projects first + priorityBatch := trimmed[:priorityBatchSize] + searchProjectBatch(priorityBatch) - // Send progress update with new results - if callback != nil { - progress := SearchProgress{ - TotalRequests: totalRequests, - CompletedRequests: currentCompleted, - PendingRequests: totalRequests - currentCompleted, - CurrentProject: project, - CurrentProvider: string(provider.Kind()), - } - if err := callback(currentResults, progress); err != nil { - // Callback requested stop - just return - return - } - } - } - }() + // Then search remaining projects + if len(trimmed) > priorityBatchSize { + remainingBatch := trimmed[priorityBatchSize:] + searchProjectBatch(remainingBatch) } - wg.Wait() - // Sort results for consistent output sort.Slice(results, func(i, j int) bool { if results[i].Project != results[j].Project { diff --git a/internal/gcp/search/types.go b/internal/gcp/search/types.go index eb8d15e..01c45e3 100644 --- a/internal/gcp/search/types.go +++ b/internal/gcp/search/types.go @@ -58,6 +58,8 @@ const ( KindVPNTunnel ResourceKind = "compute.vpnTunnel" // KindRoute represents a VPC route. KindRoute ResourceKind = "compute.route" + // KindConnectivityTest represents a Network Connectivity Test. + KindConnectivityTest ResourceKind = "networkmanagement.connectivityTest" ) // AllResourceKinds returns all available resource kind values for use in validation and completion. @@ -86,6 +88,7 @@ func AllResourceKinds() []ResourceKind { KindVPNGateway, KindVPNTunnel, KindRoute, + KindConnectivityTest, } } diff --git a/internal/tui/actions.go b/internal/tui/actions.go index a8069ab..7f23f89 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -1057,6 +1057,9 @@ func GetCloudConsoleURL(resourceType, name, project, location string, details ma case string(search.KindVPNTunnel): return buildCloudConsoleURL(path.Join("hybrid/vpn/tunnels/details", location, name), project) + case string(search.KindConnectivityTest): + return buildCloudConsoleURL(path.Join("net-intelligence/connectivity/tests/details", name), project) + default: return buildCloudConsoleURL("home/dashboard", project) } diff --git a/internal/tui/connectivity_view.go b/internal/tui/connectivity_view.go index 8cde622..d2d5495 100644 --- a/internal/tui/connectivity_view.go +++ b/internal/tui/connectivity_view.go @@ -251,7 +251,7 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli // Status bar status := tview.NewTextView(). SetDynamicColors(true). - SetText(" [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") + SetText(" [yellow]b[-] browser [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") // Create pages for modal overlays pages := tview.NewPages() @@ -278,7 +278,7 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli if currentFilter != "" { status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) } else { - status.SetText(" [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") + status.SetText(" [yellow]b[-] browser [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") } case tcell.KeyEscape: // Cancel filter mode without applying @@ -292,7 +292,7 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli if currentFilter != "" { status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) } else { - status.SetText(" [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") + status.SetText(" [yellow]b[-] browser [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") } } }) @@ -305,76 +305,18 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli return } - test := entry.Test - details := fmt.Sprintf(`[yellow::b]Test Details[-:-:-] - -[white::b]Name:[-:-:-] %s -[white::b]Display Name:[-:-:-] %s -[white::b]Description:[-:-:-] %s -[white::b]Protocol:[-:-:-] %s -[white::b]State:[-:-:-] %s - -[yellow::b]Source Endpoint:[-:-:-] -%s - -[yellow::b]Destination Endpoint:[-:-:-] -%s - -[yellow::b]Reachability:[-:-:-] -%s - -[white::b]Created:[-:-:-] %s -[white::b]Updated:[-:-:-] %s - -[darkgray]Press Esc to go back[-]`, - test.Name, - test.DisplayName, - test.Description, - test.Protocol, - formatTestState(test), - formatEndpointDetails(test.Source), - formatEndpointDetails(test.Destination), - formatReachabilityDetails(test.ReachabilityDetails), - test.CreateTime.Format("2006-01-02 15:04:05"), - test.UpdateTime.Format("2006-01-02 15:04:05"), - ) - - detailView := tview.NewTextView(). - SetDynamicColors(true). - SetText(details). - SetScrollable(true). - SetWordWrap(true) - detailView.SetBorder(true).SetTitle(fmt.Sprintf(" Test: %s ", test.DisplayName)) - - // Create status bar for detail view - detailStatus := tview.NewTextView(). - SetDynamicColors(true). - SetText(" [yellow]Esc[-] back [yellow]up/down[-] scroll") - - // Create fullscreen detail layout - detailFlex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(detailView, 0, 1, true). - AddItem(detailStatus, 1, 0, false) - - // Set up input handler - detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - app.SetRoot(flex, true) - app.SetFocus(table) - modalOpen = false - if currentFilter != "" { - status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) - } else { - status.SetText(" [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") - } - return nil + ShowConnectivityTestDetails(app, entry.Test, func() { + // On close callback + app.SetRoot(flex, true) + app.SetFocus(table) + modalOpen = false + if currentFilter != "" { + status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) + } else { + status.SetText(" [yellow]b[-] browser [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") } - return event }) - modalOpen = true - app.SetRoot(detailFlex, true).SetFocus(detailView) } // Function to show help fullscreen @@ -385,6 +327,7 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli up/down, j/k Navigate list [yellow]Test Actions:[-] + b Open test in browser (Cloud Console) d Show test details r Rerun selected test Del Delete selected test @@ -426,7 +369,7 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli if currentFilter != "" { status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) } else { - status.SetText(" [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") + status.SetText(" [yellow]b[-] browser [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") } return nil } @@ -462,7 +405,7 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli if currentFilter != "" { status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) } else { - status.SetText(" [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") + status.SetText(" [yellow]b[-] browser [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") } }) }) @@ -505,7 +448,7 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli if currentFilter != "" { status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) } else { - status.SetText(" [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") + status.SetText(" [yellow]b[-] browser [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") } }) }) @@ -519,6 +462,28 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli app.SetFocus(confirmModal) } + // Function to get selected entry + getSelectedEntry := func() *connectivityEntry { + row, _ := table.GetSelection() + if row <= 0 || row > len(allEntries) { + return nil + } + + // Apply filter to get the correct entry + expr := parseFilter(currentFilter) + var filteredEntries []connectivityEntry + for _, entry := range allEntries { + if expr.matches(entry.DisplayName, entry.Name, entry.Source, entry.Destination, entry.Protocol, entry.Result) { + filteredEntries = append(filteredEntries, entry) + } + } + + if row-1 < len(filteredEntries) { + return &filteredEntries[row-1] + } + return nil + } + // Keyboard handler app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { // If in filter mode, let filter input handle it @@ -541,7 +506,7 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli status.SetText(" [yellow]Filter cleared[-]") time.AfterFunc(2*time.Second, func() { app.QueueUpdateDraw(func() { - status.SetText(" [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") + status.SetText(" [yellow]b[-] browser [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") }) }) return nil @@ -564,7 +529,7 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli if currentFilter != "" { status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) } else { - status.SetText(" [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") + status.SetText(" [yellow]b[-] browser [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") } }) }) @@ -588,7 +553,7 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli if currentFilter != "" { status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) } else { - status.SetText(" [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") + status.SetText(" [yellow]b[-] browser [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") } }) }) @@ -605,6 +570,30 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli flex.AddItem(status, 1, 0, false) app.SetFocus(filterInput) return nil + case 'b': + // Open in browser + entry := getSelectedEntry() + if entry == nil || entry.Test == nil { + return nil + } + + url := fmt.Sprintf("https://console.cloud.google.com/net-intelligence/connectivity/tests/details/%s?project=%s", + entry.Name, selectedProject) + if err := OpenInBrowser(url); err != nil { + status.SetText(fmt.Sprintf(" [yellow]URL: %s[-]", url)) + } else { + status.SetText(" [green]Opened in browser[-]") + } + time.AfterFunc(2*time.Second, func() { + app.QueueUpdateDraw(func() { + if currentFilter != "" { + status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) + } else { + status.SetText(" [yellow]b[-] browser [yellow]d[-] details [yellow]r[-] rerun [yellow]Del[-] delete [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]Esc[-] back [yellow]?[-] help") + } + }) + }) + return nil case 'd': // Show details row, _ := table.GetSelection() @@ -825,3 +814,78 @@ func formatReachabilityDetails(details *gcp.ReachabilityDetails) string { return result.String() } + +// ShowConnectivityTestDetails displays a fullscreen detail view for a connectivity test. +// This is a reusable function that can be called from different views. +func ShowConnectivityTestDetails(app *tview.Application, test *gcp.ConnectivityTestResult, onClose func()) { + // Build return reachability section if available + returnReachabilitySection := "" + if test.ReturnReachabilityDetails != nil { + returnReachabilitySection = fmt.Sprintf("\n[yellow::b]Return Reachability (Destination → Source):[-:-:-]\n%s\n", + formatReachabilityDetails(test.ReturnReachabilityDetails)) + } + + details := fmt.Sprintf(`[yellow::b]Test Details[-:-:-] + +[white::b]Name:[-:-:-] %s +[white::b]Display Name:[-:-:-] %s +[white::b]Description:[-:-:-] %s +[white::b]Protocol:[-:-:-] %s +[white::b]State:[-:-:-] %s + +[yellow::b]Source Endpoint:[-:-:-] +%s + +[yellow::b]Destination Endpoint:[-:-:-] +%s + +[yellow::b]Reachability (Source → Destination):[-:-:-] +%s%s +[white::b]Created:[-:-:-] %s +[white::b]Updated:[-:-:-] %s + +[darkgray]Press Esc to go back[-]`, + test.Name, + test.DisplayName, + test.Description, + test.Protocol, + formatTestState(test), + formatEndpointDetails(test.Source), + formatEndpointDetails(test.Destination), + formatReachabilityDetails(test.ReachabilityDetails), + returnReachabilitySection, + test.CreateTime.Format("2006-01-02 15:04:05"), + test.UpdateTime.Format("2006-01-02 15:04:05"), + ) + + detailView := tview.NewTextView(). + SetDynamicColors(true). + SetText(details). + SetScrollable(true). + SetWordWrap(true) + detailView.SetBorder(true).SetTitle(fmt.Sprintf(" Test: %s ", test.DisplayName)) + + // Create status bar for detail view + detailStatus := tview.NewTextView(). + SetDynamicColors(true). + SetText(" [yellow]Esc[-] back [yellow]up/down[-] scroll") + + // Create fullscreen detail layout + detailFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(detailView, 0, 1, true). + AddItem(detailStatus, 1, 0, false) + + // Set up input handler + detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + if onClose != nil { + onClose() + } + return nil + } + return event + }) + + app.SetRoot(detailFlex, true).SetFocus(detailView) +} diff --git a/internal/tui/search_actions.go b/internal/tui/search_actions.go new file mode 100644 index 0000000..65e93c9 --- /dev/null +++ b/internal/tui/search_actions.go @@ -0,0 +1,87 @@ +package tui + +import ( + "context" + + "github.com/kedare/compass/internal/cache" + "github.com/rivo/tview" +) + +// This file contains action handler registry and implementations extracted from search_view.go. +// +// Architecture Overview: +// --------------------- +// This file defines the foundation for a registry-based action handler system. +// Instead of large if/else chains in the main keyboard handler, resource-specific +// actions can be registered here and dispatched dynamically. +// +// Current State: +// - Foundation structures defined (SearchActionContext, SearchResourceHandler) +// - Registry pattern established (searchHandlerRegistry map) +// - Ready for handler implementations +// +// Future Implementation: +// - Create handler functions for each resource type (e.g., handleInstanceDetails) +// - Register handlers using RegisterHandlers() for each ResourceKind +// - Replace if/else chains in search_view.go with GetHandler() lookups +// - Benefits: Reduced complexity, easier testing, cleaner extension points +// +// Example usage (not yet implemented): +// RegisterHandlers(string(search.KindComputeInstance), map[rune]SearchActionHandler{ +// 'd': handleInstanceDetails, +// 's': handleInstanceSSH, +// 'b': handleBrowserOpen, +// }) + +// SearchActionContext extends ActionContext with search-specific state. +type SearchActionContext struct { + *ActionContext + State *SearchViewState + UI *SearchViewUI + Cache *cache.Cache + CurrentSearchTerm string + OnAffinityTouch func(project, resourceType string) +} + +// SearchViewUI encapsulates UI components for easier passing to handlers. +type SearchViewUI struct { + App *tview.Application + Table *tview.Table + SearchInput *tview.InputField + FilterInput *tview.InputField + Status *tview.TextView + ProgressText *tview.TextView + WarningsPane *tview.TextView + Flex *tview.Flex + Ctx context.Context +} + +// SearchActionHandler is a function that handles a specific action on a search entry. +type SearchActionHandler func(ctx *SearchActionContext, entry *searchEntry) error + +// SearchResourceHandler defines handlers for a specific resource type. +type SearchResourceHandler struct { + ResourceType string + Handlers map[rune]SearchActionHandler +} + +// Global registry for resource-specific action handlers. +var searchHandlerRegistry = make(map[string]*SearchResourceHandler) + +// RegisterHandlers registers handlers for a resource type. +func RegisterHandlers(resourceType string, handlers map[rune]SearchActionHandler) { + searchHandlerRegistry[resourceType] = &SearchResourceHandler{ + ResourceType: resourceType, + Handlers: handlers, + } +} + +// GetHandler retrieves the handler for a specific resource type and action key. +func GetHandler(resourceType string, key rune) SearchActionHandler { + if handler, ok := searchHandlerRegistry[resourceType]; ok { + if fn, ok := handler.Handlers[key]; ok { + return fn + } + } + return nil +} diff --git a/internal/tui/search_helpers.go b/internal/tui/search_helpers.go new file mode 100644 index 0000000..47011fb --- /dev/null +++ b/internal/tui/search_helpers.go @@ -0,0 +1,104 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/rivo/tview" +) + +// This file contains pure utility functions extracted from search_view.go +// for improved testability and reusability. + +// ErrorHandler provides consistent error handling for the search view. +type ErrorHandler struct { + Status *tview.TextView + App *tview.Application + OnRestoreStatus func() +} + +// NewErrorHandler creates a new error handler. +func NewErrorHandler(status *tview.TextView, app *tview.Application, onRestore func()) *ErrorHandler { + return &ErrorHandler{ + Status: status, + App: app, + OnRestoreStatus: onRestore, + } +} + +// ShowError displays an error message and restores status after a delay. +func (eh *ErrorHandler) ShowError(format string, args ...interface{}) { + eh.Status.SetText(fmt.Sprintf(" [red]"+format+"[-]", args...)) + if eh.OnRestoreStatus != nil { + time.AfterFunc(3*time.Second, func() { + eh.App.QueueUpdateDraw(func() { + eh.OnRestoreStatus() + }) + }) + } +} + +// ShowSuccess displays a success message and restores status after a delay. +func (eh *ErrorHandler) ShowSuccess(format string, args ...interface{}) { + eh.Status.SetText(fmt.Sprintf(" [green]"+format+"[-]", args...)) + if eh.OnRestoreStatus != nil { + time.AfterFunc(2*time.Second, func() { + eh.App.QueueUpdateDraw(func() { + eh.OnRestoreStatus() + }) + }) + } +} + +// ShowLoading displays a loading message. +func (eh *ErrorHandler) ShowLoading(format string, args ...interface{}) { + eh.Status.SetText(fmt.Sprintf(" [yellow]"+format+"[-]", args...)) +} + +// getTypeColor returns the display color for a given resource type. +// This provides consistent color coding across the search interface. +func getTypeColor(resourceType string) string { + switch { + case strings.HasPrefix(resourceType, "compute."): + return "blue" + case strings.HasPrefix(resourceType, "storage."): + return "green" + case strings.HasPrefix(resourceType, "container."): + return "cyan" + case strings.HasPrefix(resourceType, "sqladmin."): + return "magenta" + case strings.HasPrefix(resourceType, "run."): + return "yellow" + case strings.HasPrefix(resourceType, "secretmanager."): + return "red" + case strings.HasPrefix(resourceType, "networkmanagement."): + return "purple" + default: + return "white" + } +} + +// highlightMatch highlights the first occurrence of the search term in the text. +// Returns the text with tview color markup for highlighting. +func highlightMatch(text, term string) string { + if term == "" || text == "" { + return text + } + + termLower := strings.ToLower(strings.TrimSpace(term)) + if termLower == "" { + return text + } + + idx := strings.Index(strings.ToLower(text), termLower) + if idx < 0 { + return text + } + + before := text[:idx] + matched := text[idx : idx+len(termLower)] + after := text[idx+len(termLower):] + + return before + "[yellow::b]" + matched + "[-:-:-]" + after +} diff --git a/internal/tui/search_modals.go b/internal/tui/search_modals.go new file mode 100644 index 0000000..4d0a12f --- /dev/null +++ b/internal/tui/search_modals.go @@ -0,0 +1,211 @@ +package tui + +import ( + "fmt" + "sort" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// This file contains modal dialog management extracted from search_view.go. + +// ModalManager handles modal lifecycle for the search view. +type ModalManager struct { + App *tview.Application + MainFlex *tview.Flex + MainFocus tview.Primitive + ModalOpen *bool + OnClose func() +} + +// NewModalManager creates a new modal manager. +func NewModalManager(app *tview.Application, mainFlex *tview.Flex, mainFocus tview.Primitive, modalOpen *bool, onClose func()) *ModalManager { + return &ModalManager{ + App: app, + MainFlex: mainFlex, + MainFocus: mainFocus, + ModalOpen: modalOpen, + OnClose: onClose, + } +} + +// showSearchResultDetail displays details for a search result +func showSearchResultDetail(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, entry *searchEntry, modalOpen *bool, status *tview.TextView, currentFilter string, onRestoreStatus func()) { + var detailText string + + detailText += fmt.Sprintf("[yellow::b]%s[-:-:-]\n\n", entry.Type) + detailText += fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", entry.Name) + detailText += fmt.Sprintf("[white::b]Project:[-:-:-] %s\n", entry.Project) + detailText += fmt.Sprintf("[white::b]Location:[-:-:-] %s\n", entry.Location) + + if len(entry.Details) > 0 { + detailText += "\n[yellow::b]Details:[-:-:-]\n" + + // Sort keys for consistent display + keys := make([]string, 0, len(entry.Details)) + for k := range entry.Details { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + v := entry.Details[k] + if v != "" { + detailText += fmt.Sprintf(" [white::b]%s:[-:-:-] %s\n", k, v) + } + } + } + + detailText += "\n[darkgray]Press Esc to close[-]" + + detailView := tview.NewTextView(). + SetDynamicColors(true). + SetText(detailText). + SetScrollable(true). + SetWordWrap(true) + detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", entry.Name)) + + // Create status bar for detail view + detailStatus := tview.NewTextView(). + SetDynamicColors(true). + SetText(" [yellow]Esc[-] back [yellow]↑/↓[-] scroll") + + // Create fullscreen detail layout + detailFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(detailView, 0, 1, true). + AddItem(detailStatus, 1, 0, false) + + // Set up input handler + detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + *modalOpen = false + app.SetRoot(mainFlex, true) + app.SetFocus(table) + if onRestoreStatus != nil { + onRestoreStatus() + } + return nil + } + return event + }) + + *modalOpen = true + app.SetRoot(detailFlex, true).SetFocus(detailView) +} + +// showInstanceDetailModal displays instance details fetched from GCP +func showInstanceDetailModal(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, name string, details string, modalOpen *bool, status *tview.TextView, currentFilter string, onRestoreStatus func()) { + detailView := tview.NewTextView(). + SetDynamicColors(true). + SetText(details). + SetScrollable(true). + SetWordWrap(true) + detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", name)) + + // Create status bar for detail view + detailStatus := tview.NewTextView(). + SetDynamicColors(true). + SetText(" [yellow]Esc[-] back [yellow]↑/↓[-] scroll") + + // Create fullscreen detail layout + detailFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(detailView, 0, 1, true). + AddItem(detailStatus, 1, 0, false) + + // Set up input handler + detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + *modalOpen = false + app.SetRoot(mainFlex, true) + app.SetFocus(table) + if onRestoreStatus != nil { + onRestoreStatus() + } + return nil + } + return event + }) + + *modalOpen = true + app.SetRoot(detailFlex, true).SetFocus(detailView) +} + +// showSearchHelp displays help for the search view +func showSearchHelp(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, modalOpen *bool, currentFilter string, status *tview.TextView, onRestoreStatus func()) { + helpText := `[yellow::b]Search View - Keyboard Shortcuts[-:-:-] + +[yellow]Search[-] + [white]Enter[-] Start search / Focus search input + [white]↑/↓[-] Navigate search history (in search box) + [white]Tab[-] Toggle fuzzy matching + [white]Esc[-] Cancel search (if running) / Clear filter / Go back + +[yellow]Navigation[-] + [white]↑/k[-] Move selection up + [white]↓/j[-] Move selection down + [white]Home/g[-] Jump to first result + [white]End/G[-] Jump to last result + +[yellow]Actions[-] + [white]s[-] SSH to selected instance + [white]d[-] Show details for selected result + [white]b[-] Open in Cloud Console (browser) + [white]o[-] Open in browser (for buckets) + [white]/[-] Filter displayed results + +[yellow]Search Features[-] + • Results appear progressively as they're found + • High-priority projects (based on past searches) are searched first + • Progress shows current project being searched + • Result counts by resource type shown in title + • Search history: Use ↑/↓ to recall previous searches + • Search can be cancelled at any time with Esc + • Cancelled searches keep existing results + • Filter (/) narrows displayed results without new search + • Filter supports: spaces (AND), | (OR), - (NOT) + Example: "compute.instance prod" = instances in prod projects + Example: "web|api -dev" = web or api resources, excluding dev + • Tab toggles fuzzy mode (matches characters in order, e.g. "prd" matches "production") + • Context-aware actions based on resource type + +[darkgray]Press Esc or ? to close this help[-]` + + helpView := tview.NewTextView(). + SetDynamicColors(true). + SetText(helpText). + SetScrollable(true) + helpView.SetBorder(true). + SetTitle(" Search Help "). + SetTitleAlign(tview.AlignCenter) + + // Create status bar for help view + helpStatus := tview.NewTextView(). + SetDynamicColors(true). + SetText(" [yellow]Esc[-] back [yellow]?[-] close help") + + // Create fullscreen help layout + helpFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(helpView, 0, 1, true). + AddItem(helpStatus, 1, 0, false) + + // Set up input handler + helpView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape || (event.Key() == tcell.KeyRune && event.Rune() == '?') { + *modalOpen = false + app.SetRoot(mainFlex, true) + app.SetFocus(table) + if onRestoreStatus != nil { + onRestoreStatus() + } + return nil + } + return event + }) + + *modalOpen = true + app.SetRoot(helpFlex, true).SetFocus(helpView) +} diff --git a/internal/tui/search_state.go b/internal/tui/search_state.go new file mode 100644 index 0000000..59f3293 --- /dev/null +++ b/internal/tui/search_state.go @@ -0,0 +1,48 @@ +package tui + +import ( + "context" + "sync" + + "github.com/kedare/compass/internal/gcp/search" +) + +// This file contains state management structures for the search view. + +// SearchViewState encapsulates all state variables for the search view. +// This improves testability and makes state management explicit. +type SearchViewState struct { + // Core data + AllResults []searchEntry + AllWarnings []search.SearchWarning + SearchError error + + // UI state + ModalOpen bool + FilterMode bool + CurrentFilter string + FuzzyMode bool + + // Search context + CurrentSearchTerm string + RecordedAffinity map[string]struct{} + + // Search history + SearchHistory []string + HistoryIndex int + + // Sync primitives + ResultsMu sync.Mutex + IsSearching bool + SearchCancel context.CancelFunc +} + +// NewSearchViewState creates a new search view state with sensible defaults. +func NewSearchViewState() *SearchViewState { + return &SearchViewState{ + AllResults: []searchEntry{}, + AllWarnings: []search.SearchWarning{}, + RecordedAffinity: make(map[string]struct{}), + HistoryIndex: -1, + } +} diff --git a/internal/tui/search_table.go b/internal/tui/search_table.go new file mode 100644 index 0000000..faeb19e --- /dev/null +++ b/internal/tui/search_table.go @@ -0,0 +1,190 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + "github.com/rivo/tview" +) + +// This file contains table rendering and update logic extracted from search_view.go. +// The TableUpdater provides a clean separation of table rendering concerns from +// the main search orchestration logic. + +// TableUpdater handles all table-related operations for the search view. +// It manages row population, filtering, selection preservation, and title updates. +type TableUpdater struct { + Table *tview.Table // The tview table widget + CurrentSearchTerm string // Current search term for highlighting matches +} + +// NewTableUpdater creates a new table updater for the given table. +func NewTableUpdater(table *tview.Table) *TableUpdater { + return &TableUpdater{ + Table: table, + } +} + +// UpdateWithData updates the table with the given results and filter. +// This is the main entry point for updating the search results table. +func (tu *TableUpdater) UpdateWithData(filter string, results []searchEntry) { + // Capture current selection before clearing + selectedKey := tu.captureSelection() + + // Clear all rows except header + tu.clearRows() + + // Apply filter and populate rows + filterExpr := parseFilter(filter) + matchCount := tu.populateRows(results, filterExpr, selectedKey) + + // Update title with counts and type summary + tu.updateTitle(len(results), matchCount, filter, results) + + // Restore or set selection + tu.ensureSelection(matchCount) +} + +// captureSelection captures the currently selected row as a unique key. +// Returns empty string if no valid selection exists. +func (tu *TableUpdater) captureSelection() string { + currentSelectedRow, _ := tu.Table.GetSelection() + if currentSelectedRow <= 0 || currentSelectedRow >= tu.Table.GetRowCount() { + return "" + } + + nameCell := tu.Table.GetCell(currentSelectedRow, 1) + projectCell := tu.Table.GetCell(currentSelectedRow, 2) + locationCell := tu.Table.GetCell(currentSelectedRow, 3) + if nameCell != nil && projectCell != nil && locationCell != nil { + return nameCell.Text + "|" + projectCell.Text + "|" + locationCell.Text + } + return "" +} + +// clearRows removes all data rows from the table, keeping only the header. +func (tu *TableUpdater) clearRows() { + for row := tu.Table.GetRowCount() - 1; row > 0; row-- { + tu.Table.RemoveRow(row) + } +} + +// populateRows adds filtered entries to the table and attempts to restore selection. +// Returns the number of matched entries and the row index of the restored selection (-1 if not found). +func (tu *TableUpdater) populateRows(results []searchEntry, filterExpr filterExpr, selectedKey string) int { + currentRow := 1 + matchCount := 0 + newSelectedRow := -1 + + for _, entry := range results { + // Apply filter + if !filterExpr.matches(entry.Name, entry.Project, entry.Location, entry.Type) { + continue + } + + // Add row with colored type and highlighted matches + typeColor := getTypeColor(entry.Type) + tu.Table.SetCell(currentRow, 0, tview.NewTableCell(fmt.Sprintf("[%s]%s[-]", typeColor, entry.Type)).SetExpansion(1)) + tu.Table.SetCell(currentRow, 1, tview.NewTableCell(highlightMatch(entry.Name, tu.CurrentSearchTerm)).SetExpansion(1)) + tu.Table.SetCell(currentRow, 2, tview.NewTableCell(highlightMatch(entry.Project, tu.CurrentSearchTerm)).SetExpansion(1)) + tu.Table.SetCell(currentRow, 3, tview.NewTableCell(highlightMatch(entry.Location, tu.CurrentSearchTerm)).SetExpansion(1)) + + // Check if this row matches the previously selected key + if selectedKey != "" && newSelectedRow == -1 { + rowKey := entry.Name + "|" + entry.Project + "|" + entry.Location + if rowKey == selectedKey { + newSelectedRow = currentRow + } + } + + currentRow++ + matchCount++ + } + + // Restore selection if we found the previously selected item + if newSelectedRow > 0 { + tu.Table.Select(newSelectedRow, 0) + } + + return matchCount +} + +// updateTitle updates the table title with result counts and type summary. +func (tu *TableUpdater) updateTitle(totalCount, matchCount int, filter string, results []searchEntry) { + typeSummary := tu.buildTypeSummary(results) + + if filter != "" { + tu.Table.SetTitle(fmt.Sprintf(" Search Results (%d/%d matched)%s ", matchCount, totalCount, typeSummary)) + } else { + tu.Table.SetTitle(fmt.Sprintf(" Search Results (%d)%s ", totalCount, typeSummary)) + } +} + +// buildTypeSummary creates a summary string showing the top 3 resource types by count. +func (tu *TableUpdater) buildTypeSummary(results []searchEntry) string { + typeCounts := make(map[string]int) + for _, entry := range results { + typeCounts[entry.Type]++ + } + + if len(typeCounts) == 0 { + return "" + } + + // Sort types by count (descending) + type typeCount struct { + name string + count int + } + var sortedTypes []typeCount + for typeName, count := range typeCounts { + sortedTypes = append(sortedTypes, typeCount{name: typeName, count: count}) + } + sort.Slice(sortedTypes, func(i, j int) bool { + return sortedTypes[i].count > sortedTypes[j].count + }) + + // Build summary string with top 3 types + var parts []string + for i, tc := range sortedTypes { + if i >= 3 { + break + } + // Shorten type name for display + shortType := tc.name + if idx := strings.LastIndex(tc.name, "."); idx >= 0 { + shortType = tc.name[idx+1:] + } + parts = append(parts, fmt.Sprintf("%d %s", tc.count, shortType)) + } + if len(sortedTypes) > 3 { + parts = append(parts, fmt.Sprintf("%d other", len(sortedTypes)-3)) + } + + return " [" + strings.Join(parts, ", ") + "]" +} + +// ensureSelection ensures that a row is selected if any rows exist. +// This handles cases where the selection was lost or needs to be restored. +func (tu *TableUpdater) ensureSelection(matchCount int) { + if matchCount <= 0 || tu.Table.GetRowCount() <= 1 { + return + } + + currentSelectedRow, _ := tu.Table.GetSelection() + + // If already have a valid selection, keep it + if currentSelectedRow > 0 && currentSelectedRow < tu.Table.GetRowCount() { + return + } + + // If selection is beyond the end, select the last row + if currentSelectedRow >= tu.Table.GetRowCount() && tu.Table.GetRowCount() > 1 { + tu.Table.Select(tu.Table.GetRowCount()-1, 0) + return + } + + // Default to selecting the first data row + tu.Table.Select(1, 0) +} diff --git a/internal/tui/search_view.go b/internal/tui/search_view.go index 402087d..b2ce7db 100644 --- a/internal/tui/search_view.go +++ b/internal/tui/search_view.go @@ -3,7 +3,6 @@ package tui import ( "context" "fmt" - "sort" "strings" "sync" "time" @@ -26,19 +25,26 @@ type searchEntry struct { } // RunSearchView shows the search interface with progressive results +// RunSearchView displays an interactive search interface for finding GCP resources. +// It provides progressive search results, filtering, and resource-specific actions. +// +// The view supports: +// - Progressive search across multiple projects and resource types +// - Search history navigation with ↑/↓ keys +// - Fuzzy matching toggle with Tab key +// - Real-time filtering with '/' key +// - Context-aware actions (SSH, details, browser) based on resource type +// - Project affinity learning (searches high-priority projects first) +// +// Key bindings are resource-type aware and displayed in the status bar. func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, outputRedir *outputRedirector, parallelism int, onBack func()) error { - var allResults []searchEntry - var allWarnings []search.SearchWarning - var searchError error - var resultsMu sync.Mutex - var isSearching bool - var searchCancel context.CancelFunc - var modalOpen bool - var currentFilter string - var filterMode bool - var fuzzyMode bool - var currentSearchTerm string // Track current search term for affinity reinforcement - var recordedAffinity = make(map[string]struct{}) // Track what's been recorded this search session + // Initialize state management + state := NewSearchViewState() + + // Load search history from cache + if history, err := c.GetSearchHistory(20); err == nil { + state.SearchHistory = history + } // Get initial projects from cache (will be overridden per-search with affinity data) initialProjects := c.GetProjectsByUsage() @@ -55,7 +61,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, SetFieldWidth(0). SetFieldBackgroundColor(tcell.ColorBlack). SetLabelColor(tcell.ColorYellow). - SetPlaceholder("Enter search term and press Enter") + SetPlaceholder("Search by name, IP, or resource... (↑/↓ for history)") table := tview.NewTable(). SetBorders(false). @@ -75,6 +81,9 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, table.SetCell(0, col, cell) } + // Create table updater + tableUpdater := NewTableUpdater(table) + // Filter input filterInput := tview.NewInputField(). SetLabel(" Filter: "). @@ -123,11 +132,11 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, flex.AddItem(table, 0, 1, focusTable) // Show warnings pane if there are warnings or errors - hasIssues := len(allWarnings) > 0 || searchError != nil + hasIssues := len(state.AllWarnings) > 0 || state.SearchError != nil if hasIssues { // Calculate height based on number of issues (min 3, max 8 lines) - height := len(allWarnings) + 2 // +2 for border - if searchError != nil { + height := len(state.AllWarnings) + 2 // +2 for border + if state.SearchError != nil { height++ } if height < 3 { @@ -146,14 +155,14 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, updateWarningsPane := func() { var content strings.Builder - if searchError != nil { - content.WriteString(fmt.Sprintf("[red]Error:[-] %v\n", searchError)) + if state.SearchError != nil { + content.WriteString(fmt.Sprintf("[red]Error:[-] %v\n", state.SearchError)) } - if len(allWarnings) > 0 { + if len(state.AllWarnings) > 0 { // Group warnings by provider providerErrors := make(map[search.ResourceKind][]string) - for _, w := range allWarnings { + for _, w := range state.AllWarnings { providerErrors[w.Provider] = append(providerErrors[w.Provider], w.Project) } @@ -170,83 +179,16 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, } // Shared function to update table with given results - var updateTableWithData = func(filter string, results []searchEntry) { - - currentSelectedRow, _ := table.GetSelection() - var selectedKey string - if currentSelectedRow > 0 && currentSelectedRow < table.GetRowCount() { - - nameCell := table.GetCell(currentSelectedRow, 1) - projectCell := table.GetCell(currentSelectedRow, 2) - locationCell := table.GetCell(currentSelectedRow, 3) - if nameCell != nil && projectCell != nil && locationCell != nil { - selectedKey = nameCell.Text + "|" + projectCell.Text + "|" + locationCell.Text - } - } - - for row := table.GetRowCount() - 1; row > 0; row-- { - table.RemoveRow(row) - } - - filterExpr := parseFilter(filter) - currentRow := 1 - matchCount := 0 - newSelectedRow := -1 - - for _, entry := range results { - - if !filterExpr.matches(entry.Name, entry.Project, entry.Location, entry.Type) { - continue - } - - typeColor := getTypeColor(entry.Type) - table.SetCell(currentRow, 0, tview.NewTableCell(fmt.Sprintf("[%s]%s[-]", typeColor, entry.Type)).SetExpansion(1)) - table.SetCell(currentRow, 1, tview.NewTableCell(highlightMatch(entry.Name, currentSearchTerm)).SetExpansion(1)) - table.SetCell(currentRow, 2, tview.NewTableCell(highlightMatch(entry.Project, currentSearchTerm)).SetExpansion(1)) - table.SetCell(currentRow, 3, tview.NewTableCell(highlightMatch(entry.Location, currentSearchTerm)).SetExpansion(1)) - - if selectedKey != "" && newSelectedRow == -1 { - rowKey := entry.Name + "|" + entry.Project + "|" + entry.Location - if rowKey == selectedKey { - newSelectedRow = currentRow - } - } - - currentRow++ - matchCount++ - } - - if filter != "" { - table.SetTitle(fmt.Sprintf(" Search Results (%d/%d matched) ", matchCount, len(results))) - } else { - table.SetTitle(fmt.Sprintf(" Search Results (%d) ", len(results))) - } - - if matchCount > 0 && table.GetRowCount() > 1 { - if newSelectedRow > 0 { - - table.Select(newSelectedRow, 0) - } else if currentSelectedRow > 0 && currentSelectedRow < table.GetRowCount() { - - table.Select(currentSelectedRow, 0) - } else if currentSelectedRow >= table.GetRowCount() && table.GetRowCount() > 1 { - - table.Select(table.GetRowCount()-1, 0) - } else if currentSelectedRow == 0 { - - table.Select(1, 0) - } - } - } - // Function to update table with current results (copies data to avoid holding lock) updateTable := func(filter string) { - resultsMu.Lock() - results := make([]searchEntry, len(allResults)) - copy(results, allResults) - resultsMu.Unlock() - - updateTableWithData(filter, results) + state.ResultsMu.Lock() + results := make([]searchEntry, len(state.AllResults)) + copy(results, state.AllResults) + state.ResultsMu.Unlock() + + // Update table updater's search term for highlighting + tableUpdater.CurrentSearchTerm = state.CurrentSearchTerm + tableUpdater.UpdateWithData(filter, results) } // Alias for updateTable - both copy data safely @@ -259,13 +201,13 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, return nil } - resultsMu.Lock() - defer resultsMu.Unlock() + state.ResultsMu.Lock() + defer state.ResultsMu.Unlock() - expr := parseFilter(currentFilter) + expr := parseFilter(state.CurrentFilter) visibleIdx := 0 - for i := range allResults { - entry := &allResults[i] + for i := range state.AllResults { + entry := &state.AllResults[i] if !expr.matches(entry.Name, entry.Project, entry.Location, entry.Type) { continue } @@ -279,9 +221,9 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, // Helper function to update status bar with context-aware actions updateStatusWithActions := func() { - resultsMu.Lock() - count := len(allResults) - resultsMu.Unlock() + state.ResultsMu.Lock() + count := len(state.AllResults) + state.ResultsMu.Unlock() entry := getSelectedEntry() var actionStr string @@ -293,13 +235,13 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, // Build fuzzy indicator and toggle hint fuzzyBadge := "" fuzzyToggle := "[yellow]Tab[-] fuzzy" - if fuzzyMode { + if state.FuzzyMode { fuzzyBadge = " [green::b]FUZZY[-:-:-] " fuzzyToggle = "[yellow]Tab[-] exact" } // During search, show search-specific status with context actions - if isSearching { + if state.IsSearching { if actionStr != "" { status.SetText(fmt.Sprintf("%s [yellow]Searching...[-] %s [yellow]Esc[-] cancel", fuzzyBadge, actionStr)) } else { @@ -317,8 +259,8 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, return } - if currentFilter != "" { - status.SetText(fmt.Sprintf("%s[green]Filter: %s[-] %s [yellow]/[-] edit [yellow]Esc[-] clear [yellow]?[-] help", fuzzyBadge, currentFilter, actionStr)) + if state.CurrentFilter != "" { + status.SetText(fmt.Sprintf("%s[green]Filter: %s[-] %s [yellow]/[-] edit [yellow]Esc[-] clear [yellow]?[-] help", fuzzyBadge, state.CurrentFilter, actionStr)) } else { status.SetText(fmt.Sprintf("%s%s %s [yellow]Enter[-] search [yellow]/[-] filter [yellow]Esc[-] back [yellow]?[-] help", fuzzyBadge, actionStr, fuzzyToggle)) } @@ -326,7 +268,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, // Update status when selection changes table.SetSelectionChangedFunc(func(row, column int) { - if !modalOpen && !filterMode { + if !state.ModalOpen && !state.FilterMode { updateStatusWithActions() } }) @@ -339,24 +281,34 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, } // Cancel any existing search - if searchCancel != nil { - searchCancel() + if state.SearchCancel != nil { + state.SearchCancel() } // Clear previous results and warnings - resultsMu.Lock() - allResults = []searchEntry{} - allWarnings = nil - searchError = nil - resultsMu.Unlock() + state.ResultsMu.Lock() + state.AllResults = []searchEntry{} + state.AllWarnings = nil + state.SearchError = nil + state.ResultsMu.Unlock() searchCtx, cancel := context.WithCancel(ctx) - searchCancel = cancel - isSearching = true + state.SearchCancel = cancel + state.IsSearching = true // Track this search term for affinity reinforcement and reset recorded tracking - currentSearchTerm = query - recordedAffinity = make(map[string]struct{}) + state.CurrentSearchTerm = query + state.RecordedAffinity = make(map[string]struct{}) + + // Add to search history + go func() { + _ = c.AddSearchHistory(query) + // Reload history for next time + if history, err := c.GetSearchHistory(20); err == nil { + state.SearchHistory = history + state.HistoryIndex = -1 + } + }() // Get projects prioritized for this search term using learned affinity searchProjects := c.GetProjectsForSearch(query, nil) @@ -386,20 +338,64 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, for { select { case <-ticker.C: - resultsMu.Lock() - count := len(allResults) - resultsMu.Unlock() + state.ResultsMu.Lock() + count := len(state.AllResults) + state.ResultsMu.Unlock() progressMu.Lock() prog := currentProgress progressMu.Unlock() frame := spinnerFrames[spinnerIdx] spinnerIdx = (spinnerIdx + 1) % len(spinnerFrames) app.QueueUpdateDraw(func() { - if isSearching { + if state.IsSearching { if prog.TotalRequests > 0 { - progressText.SetText(fmt.Sprintf("[yellow]%s %d/%d requests | %d results[-]", frame, prog.CompletedRequests, prog.TotalRequests, count)) + // Calculate project progress + projectsSearched := prog.CompletedRequests / len(engine.GetProviderKinds()) + if projectsSearched > len(searchProjects) { + projectsSearched = len(searchProjects) + } + + // Calculate percentage for calls + callPercent := 0 + if prog.TotalRequests > 0 { + callPercent = (prog.CompletedRequests * 100) / prog.TotalRequests + } + + // Build progress message + var progressMsg string + if prog.CurrentProject != "" { + // Shorten long project names + projectName := prog.CurrentProject + if len(projectName) > 25 { + projectName = projectName[:22] + "..." + } + + // Show current project with progress + if count > 0 { + progressMsg = fmt.Sprintf("[yellow]%s [cyan]%s[-] [dim]• %d/%d projects • %d/%d calls (%d%%)[:-:-] • [green]%d found[-]", + frame, projectName, projectsSearched, len(searchProjects), + prog.CompletedRequests, prog.TotalRequests, callPercent, count) + } else { + progressMsg = fmt.Sprintf("[yellow]%s [cyan]%s[-] [dim]• %d/%d projects • %d/%d calls (%d%%)[:-:-]", + frame, projectName, projectsSearched, len(searchProjects), + prog.CompletedRequests, prog.TotalRequests, callPercent) + } + } else { + // Fallback if no current project + if count > 0 { + progressMsg = fmt.Sprintf("[yellow]%s Searching [dim]• %d/%d projects • %d/%d calls (%d%%)[:-:-] • [green]%d found[-]", + frame, projectsSearched, len(searchProjects), + prog.CompletedRequests, prog.TotalRequests, callPercent, count) + } else { + progressMsg = fmt.Sprintf("[yellow]%s Searching [dim]• %d/%d projects • %d/%d calls (%d%%)[:-:-]", + frame, projectsSearched, len(searchProjects), + prog.CompletedRequests, prog.TotalRequests, callPercent) + } + } + + progressText.SetText(progressMsg) } else { - progressText.SetText(fmt.Sprintf("[yellow]%s Starting...[-]", frame)) + progressText.SetText(fmt.Sprintf("[yellow]%s Starting search...[-]", frame)) } } }) @@ -418,7 +414,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, }) // Run search - searchQuery := search.Query{Term: query, Fuzzy: fuzzyMode} + searchQuery := search.Query{Term: query, Fuzzy: state.FuzzyMode} callback := func(results []search.Result, progress search.SearchProgress) error { // Check if cancelled @@ -451,8 +447,8 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, // Track for real-time affinity recording (only new project+type combos) affinityKey := r.Project + "|" + string(r.Type) - if _, exists := recordedAffinity[affinityKey]; !exists { - recordedAffinity[affinityKey] = struct{}{} + if _, exists := state.RecordedAffinity[affinityKey]; !exists { + state.RecordedAffinity[affinityKey] = struct{}{} newProjectResults[r.Project]++ if string(r.Type) != "" { if newProjectTypeResults[r.Project] == nil { @@ -463,9 +459,9 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, } } - resultsMu.Lock() - allResults = append(allResults, newEntries...) - resultsMu.Unlock() + state.ResultsMu.Lock() + state.AllResults = append(state.AllResults, newEntries...) + state.ResultsMu.Unlock() // Record affinity in real-time (in background to not block) if len(newProjectResults) > 0 { @@ -483,7 +479,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, } // Update UI - copy current filter to avoid race - filter := currentFilter + filter := state.CurrentFilter app.QueueUpdateDraw(func() { updateTableNoLock(filter) }) @@ -499,22 +495,22 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, default: } - isSearching = false + state.IsSearching = false // Store warnings and error - resultsMu.Lock() + state.ResultsMu.Lock() if err != nil { - searchError = err + state.SearchError = err } if output != nil && len(output.Warnings) > 0 { - allWarnings = output.Warnings + state.AllWarnings = output.Warnings } - resultsMu.Unlock() + state.ResultsMu.Unlock() // Note: Search affinity is now recorded in real-time during the search callback // Final UI update - filter := currentFilter + filter := state.CurrentFilter app.QueueUpdateDraw(func() { progressText.SetText("") @@ -529,19 +525,52 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, }) } + // Search input history navigation + searchInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyUp: + // Navigate backward in history + if len(state.SearchHistory) > 0 { + if state.HistoryIndex == -1 { + state.HistoryIndex = 0 + } else if state.HistoryIndex < len(state.SearchHistory)-1 { + state.HistoryIndex++ + } + if state.HistoryIndex >= 0 && state.HistoryIndex < len(state.SearchHistory) { + searchInput.SetText(state.SearchHistory[state.HistoryIndex]) + } + } + return nil + case tcell.KeyDown: + // Navigate forward in history + if len(state.SearchHistory) > 0 && state.HistoryIndex > 0 { + state.HistoryIndex-- + if state.HistoryIndex >= 0 { + searchInput.SetText(state.SearchHistory[state.HistoryIndex]) + } + } else if state.HistoryIndex == 0 { + state.HistoryIndex = -1 + searchInput.SetText("") + } + return nil + } + return event + }) + // Search input handler searchInput.SetDoneFunc(func(key tcell.Key) { switch key { case tcell.KeyEnter: query := strings.TrimSpace(searchInput.GetText()) if query != "" { + state.HistoryIndex = -1 // Reset history navigation app.SetFocus(table) // Run search in goroutine to avoid blocking the event loop go performSearch(query) } case tcell.KeyEscape: - if isSearching && searchCancel != nil { - searchCancel() + if state.IsSearching && state.SearchCancel != nil { + state.SearchCancel() } else { onBack() } @@ -552,15 +581,15 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, filterInput.SetDoneFunc(func(key tcell.Key) { switch key { case tcell.KeyEnter: - currentFilter = filterInput.GetText() - updateTable(currentFilter) - filterMode = false + state.CurrentFilter = filterInput.GetText() + updateTable(state.CurrentFilter) + state.FilterMode = false rebuildLayout(false, true) app.SetFocus(table) updateStatusWithActions() case tcell.KeyEscape: - filterInput.SetText(currentFilter) - filterMode = false + filterInput.SetText(state.CurrentFilter) + state.FilterMode = false rebuildLayout(false, true) app.SetFocus(table) } @@ -569,28 +598,28 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, // Setup keyboard handlers app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { // If a modal is open, let it handle all keys (except Ctrl+C) - if modalOpen && event.Key() != tcell.KeyCtrlC { + if state.ModalOpen && event.Key() != tcell.KeyCtrlC { return event } // Tab toggles fuzzy mode globally, regardless of focus if event.Key() == tcell.KeyTab { - fuzzyMode = !fuzzyMode - if fuzzyMode { + state.FuzzyMode = !state.FuzzyMode + if state.FuzzyMode { searchInput.SetLabel(" Search (fuzzy): ") } else { searchInput.SetLabel(" Search: ") } updateStatusWithActions() // Re-run current search if there is one - if currentSearchTerm != "" { - go performSearch(currentSearchTerm) + if state.CurrentSearchTerm != "" { + go performSearch(state.CurrentSearchTerm) } return nil } // If in filter mode, let the input field handle it - if filterMode { + if state.FilterMode { return event } @@ -601,14 +630,14 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, switch event.Key() { case tcell.KeyEscape: - if isSearching && searchCancel != nil { + if state.IsSearching && state.SearchCancel != nil { // Cancel ongoing search but stay in view - searchCancel() + state.SearchCancel() return nil } - if currentFilter != "" { + if state.CurrentFilter != "" { // Clear filter - currentFilter = "" + state.CurrentFilter = "" filterInput.SetText("") updateTable("") status.SetText(" [yellow]Filter cleared[-]") @@ -620,8 +649,8 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, return nil } // Go back - if searchCancel != nil { - searchCancel() + if state.SearchCancel != nil { + state.SearchCancel() } onBack() return nil @@ -635,8 +664,8 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, switch event.Rune() { case '/': // Enter filter mode - filterMode = true - filterInput.SetText(currentFilter) + state.FilterMode = true + filterInput.SetText(state.CurrentFilter) rebuildLayout(true, false) app.SetFocus(filterInput) status.SetText(" [yellow]Filter: spaces=AND |=OR -=NOT (e.g. \"web|api -dev\") Enter to apply, Esc to cancel[-]") @@ -669,11 +698,11 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, } // Reinforce search affinity when user SSHs to an instance - if currentSearchTerm != "" { + if state.CurrentSearchTerm != "" { go func(term, proj, resType string) { _ = c.TouchSearchAffinity(term, proj, resType) _ = c.TouchSearchAffinity(term, proj, "") // Also touch general affinity - }(currentSearchTerm, selectedEntry.Project, selectedEntry.Type) + }(state.CurrentSearchTerm, selectedEntry.Project, selectedEntry.Type) } // Capture values for callbacks @@ -744,12 +773,12 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, cachedSSHFlags := LoadSSHFlags(instance.Name, resourceProject) defaultUseIAP := instance.CanUseIAP - modalOpen = true + state.ModalOpen = true ShowSSHOptionsModal(app, instance.Name, defaultUseIAP, cachedIAP, cachedSSHFlags, func(opts SSHOptions) { app.SetRoot(flex, true) app.SetFocus(table) - modalOpen = false + state.ModalOpen = false RunSSHSession(app, instance.Name, resourceProject, instance.Zone, opts, outputRedir) @@ -763,7 +792,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, func() { app.SetRoot(flex, true) app.SetFocus(table) - modalOpen = false + state.ModalOpen = false updateStatusWithActions() }, ) @@ -778,7 +807,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, // Multiple instances - show selection modal app.QueueUpdateDraw(func() { - modalOpen = true + state.ModalOpen = true ShowMIGInstanceSelectionModal(app, resourceName, instances, func(selected MIGInstanceSelection) { status.SetText(fmt.Sprintf(" [yellow]Connecting to %s...[-]", selected.Name)) @@ -792,7 +821,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, // User cancelled app.SetRoot(flex, true) app.SetFocus(table) - modalOpen = false + state.ModalOpen = false updateStatusWithActions() }, ) @@ -803,12 +832,12 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, cachedIAP := LoadIAPPreference(resourceName) cachedSSHFlags := LoadSSHFlags(resourceName, resourceProject) - modalOpen = true + state.ModalOpen = true ShowSSHOptionsModal(app, resourceName, false, cachedIAP, cachedSSHFlags, func(opts SSHOptions) { app.SetRoot(flex, true) app.SetFocus(table) - modalOpen = false + state.ModalOpen = false RunSSHSession(app, resourceName, resourceProject, resourceLocation, opts, outputRedir) @@ -822,7 +851,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, func() { app.SetRoot(flex, true) app.SetFocus(table) - modalOpen = false + state.ModalOpen = false updateStatusWithActions() }, ) @@ -831,6 +860,9 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, case 'd': // Show details for selected result + // Note: This handler dispatches to type-specific detail views. + // Future refactoring: Consider implementing a registry pattern + // (see search_actions.go) to reduce this if/else chain. selectedEntry := getSelectedEntry() if selectedEntry == nil { status.SetText(" [red]No result selected[-]") @@ -843,11 +875,11 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, } // Reinforce search affinity when user views details - if currentSearchTerm != "" { + if state.CurrentSearchTerm != "" { go func(term, proj, resType string) { _ = c.TouchSearchAffinity(term, proj, resType) _ = c.TouchSearchAffinity(term, proj, "") // Also touch general affinity - }(currentSearchTerm, selectedEntry.Project, selectedEntry.Type) + }(state.CurrentSearchTerm, selectedEntry.Project, selectedEntry.Type) } // For instances, fetch live details from GCP @@ -882,19 +914,159 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, } executor.ExecuteDetails(actionCtx, func(details string) { - showInstanceDetailModal(app, table, flex, entryName, details, &modalOpen, status, currentFilter, updateStatusWithActions) + showInstanceDetailModal(app, table, flex, entryName, details, &state.ModalOpen, status, state.CurrentFilter, updateStatusWithActions) }) } else if selectedEntry.Type == string(search.KindInstanceTemplate) { // For instance templates, use formatted details details := FormatInstanceTemplateDetails(selectedEntry.Name, selectedEntry.Project, selectedEntry.Location, selectedEntry.Details) - showInstanceDetailModal(app, table, flex, selectedEntry.Name, details, &modalOpen, status, currentFilter, updateStatusWithActions) + showInstanceDetailModal(app, table, flex, selectedEntry.Name, details, &state.ModalOpen, status, state.CurrentFilter, updateStatusWithActions) } else if selectedEntry.Type == string(search.KindManagedInstanceGroup) { // For MIGs, use formatted details details := FormatMIGDetails(selectedEntry.Name, selectedEntry.Project, selectedEntry.Location, selectedEntry.Details) - showInstanceDetailModal(app, table, flex, selectedEntry.Name, details, &modalOpen, status, currentFilter, updateStatusWithActions) + showInstanceDetailModal(app, table, flex, selectedEntry.Name, details, &state.ModalOpen, status, state.CurrentFilter, updateStatusWithActions) + } else if selectedEntry.Type == string(search.KindConnectivityTest) { + // For connectivity tests, fetch and show full details + status.SetText(" [yellow]Loading connectivity test details...[-]") + + go func() { + connClient, err := gcp.NewConnectivityClient(ctx, selectedEntry.Project) + if err != nil { + app.QueueUpdateDraw(func() { + status.SetText(fmt.Sprintf(" [red]Error creating client: %v[-]", err)) + time.AfterFunc(3*time.Second, func() { + app.QueueUpdateDraw(func() { + updateStatusWithActions() + }) + }) + }) + return + } + + test, err := connClient.GetTest(ctx, selectedEntry.Name) + if err != nil { + app.QueueUpdateDraw(func() { + status.SetText(fmt.Sprintf(" [red]Error loading test: %v[-]", err)) + time.AfterFunc(3*time.Second, func() { + app.QueueUpdateDraw(func() { + updateStatusWithActions() + }) + }) + }) + return + } + + app.QueueUpdateDraw(func() { + ShowConnectivityTestDetails(app, test, func() { + // On close callback + state.ModalOpen = false + app.SetRoot(flex, true) + app.SetFocus(table) + updateStatusWithActions() + }) + state.ModalOpen = true + }) + }() + } else if selectedEntry.Type == string(search.KindVPNGateway) { + // For VPN gateways, fetch and show full details + go func() { + // Progress callback to keep status updated + progressCallback := func(msg string) { + app.QueueUpdateDraw(func() { + status.SetText(fmt.Sprintf(" [yellow]%s[-]", msg)) + }) + } + + progressCallback("Loading VPN gateway details...") + + gcpClient, err := gcp.NewClient(ctx, selectedEntry.Project) + if err != nil { + app.QueueUpdateDraw(func() { + status.SetText(fmt.Sprintf(" [red]Error creating client: %v[-]", err)) + time.AfterFunc(3*time.Second, func() { + app.QueueUpdateDraw(func() { + updateStatusWithActions() + }) + }) + }) + return + } + + gateway, err := gcpClient.GetVPNGatewayOverview(ctx, selectedEntry.Location, selectedEntry.Name, progressCallback) + if err != nil { + app.QueueUpdateDraw(func() { + status.SetText(fmt.Sprintf(" [red]Error loading gateway: %v[-]", err)) + time.AfterFunc(3*time.Second, func() { + app.QueueUpdateDraw(func() { + updateStatusWithActions() + }) + }) + }) + return + } + + app.QueueUpdateDraw(func() { + ShowVPNGatewayDetails(app, gateway, func() { + // On close callback + state.ModalOpen = false + app.SetRoot(flex, true) + app.SetFocus(table) + updateStatusWithActions() + }) + state.ModalOpen = true + }) + }() + } else if selectedEntry.Type == string(search.KindVPNTunnel) { + // For VPN tunnels, fetch and show full details + go func() { + // Progress callback to keep status updated + progressCallback := func(msg string) { + app.QueueUpdateDraw(func() { + status.SetText(fmt.Sprintf(" [yellow]%s[-]", msg)) + }) + } + + progressCallback("Loading VPN tunnel details...") + + gcpClient, err := gcp.NewClient(ctx, selectedEntry.Project) + if err != nil { + app.QueueUpdateDraw(func() { + status.SetText(fmt.Sprintf(" [red]Error creating client: %v[-]", err)) + time.AfterFunc(3*time.Second, func() { + app.QueueUpdateDraw(func() { + updateStatusWithActions() + }) + }) + }) + return + } + + tunnel, err := gcpClient.GetVPNTunnelOverview(ctx, selectedEntry.Location, selectedEntry.Name, progressCallback) + if err != nil { + app.QueueUpdateDraw(func() { + status.SetText(fmt.Sprintf(" [red]Error loading tunnel: %v[-]", err)) + time.AfterFunc(3*time.Second, func() { + app.QueueUpdateDraw(func() { + updateStatusWithActions() + }) + }) + }) + return + } + + app.QueueUpdateDraw(func() { + ShowVPNTunnelDetails(app, tunnel, func() { + // On close callback + state.ModalOpen = false + app.SetRoot(flex, true) + app.SetFocus(table) + updateStatusWithActions() + }) + state.ModalOpen = true + }) + }() } else { // For other types, show generic details - showSearchResultDetail(app, table, flex, selectedEntry, &modalOpen, status, currentFilter, updateStatusWithActions) + showSearchResultDetail(app, table, flex, selectedEntry, &state.ModalOpen, status, state.CurrentFilter, updateStatusWithActions) } return nil @@ -958,7 +1130,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, case '?': // Show help - showSearchHelp(app, table, flex, &modalOpen, currentFilter, status, updateStatusWithActions) + showSearchHelp(app, table, flex, &state.ModalOpen, state.CurrentFilter, status, updateStatusWithActions) return nil } } @@ -1087,226 +1259,16 @@ func createSearchEngine(parallelism int) *search.Engine { return gcp.NewClient(ctx, project) }, }, + &search.ConnectivityTestProvider{ + NewClient: func(ctx context.Context, project string) (search.ConnectivityTestClient, error) { + return gcp.NewConnectivityClient(ctx, project) + }, + }, ) engine.MaxConcurrentProjects = parallelism return engine } // getTypeColor returns the color for a resource type -func getTypeColor(resourceType string) string { - switch { - case strings.HasPrefix(resourceType, "compute."): - return "blue" - case strings.HasPrefix(resourceType, "storage."): - return "green" - case strings.HasPrefix(resourceType, "container."): - return "cyan" - case strings.HasPrefix(resourceType, "sqladmin."): - return "magenta" - case strings.HasPrefix(resourceType, "run."): - return "yellow" - case strings.HasPrefix(resourceType, "secretmanager."): - return "red" - default: - return "white" - } -} - -// showSearchResultDetail displays details for a search result -func showSearchResultDetail(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, entry *searchEntry, modalOpen *bool, status *tview.TextView, currentFilter string, onRestoreStatus func()) { - var detailText strings.Builder - - detailText.WriteString(fmt.Sprintf("[yellow::b]%s[-:-:-]\n\n", entry.Type)) - detailText.WriteString(fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", entry.Name)) - detailText.WriteString(fmt.Sprintf("[white::b]Project:[-:-:-] %s\n", entry.Project)) - detailText.WriteString(fmt.Sprintf("[white::b]Location:[-:-:-] %s\n", entry.Location)) - - if len(entry.Details) > 0 { - detailText.WriteString("\n[yellow::b]Details:[-:-:-]\n") - - // Sort keys for consistent display - keys := make([]string, 0, len(entry.Details)) - for k := range entry.Details { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, k := range keys { - v := entry.Details[k] - if v != "" { - detailText.WriteString(fmt.Sprintf(" [white::b]%s:[-:-:-] %s\n", k, v)) - } - } - } - - detailText.WriteString("\n[darkgray]Press Esc to close[-]") - - detailView := tview.NewTextView(). - SetDynamicColors(true). - SetText(detailText.String()). - SetScrollable(true). - SetWordWrap(true) - detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", entry.Name)) - - // Create status bar for detail view - detailStatus := tview.NewTextView(). - SetDynamicColors(true). - SetText(" [yellow]Esc[-] back [yellow]↑/↓[-] scroll") - - // Create fullscreen detail layout - detailFlex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(detailView, 0, 1, true). - AddItem(detailStatus, 1, 0, false) - - // Set up input handler - detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - *modalOpen = false - app.SetRoot(mainFlex, true) - app.SetFocus(table) - if onRestoreStatus != nil { - onRestoreStatus() - } - return nil - } - return event - }) - - *modalOpen = true - app.SetRoot(detailFlex, true).SetFocus(detailView) -} - -// showInstanceDetailModal displays instance details fetched from GCP -func showInstanceDetailModal(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, name string, details string, modalOpen *bool, status *tview.TextView, currentFilter string, onRestoreStatus func()) { - detailView := tview.NewTextView(). - SetDynamicColors(true). - SetText(details). - SetScrollable(true). - SetWordWrap(true) - detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", name)) - - // Create status bar for detail view - detailStatus := tview.NewTextView(). - SetDynamicColors(true). - SetText(" [yellow]Esc[-] back [yellow]↑/↓[-] scroll") - - // Create fullscreen detail layout - detailFlex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(detailView, 0, 1, true). - AddItem(detailStatus, 1, 0, false) - - // Set up input handler - detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - *modalOpen = false - app.SetRoot(mainFlex, true) - app.SetFocus(table) - if onRestoreStatus != nil { - onRestoreStatus() - } - return nil - } - return event - }) - - *modalOpen = true - app.SetRoot(detailFlex, true).SetFocus(detailView) -} - -// showSearchHelp displays help for the search view -func showSearchHelp(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, modalOpen *bool, currentFilter string, status *tview.TextView, onRestoreStatus func()) { - helpText := `[yellow::b]Search View - Keyboard Shortcuts[-:-:-] - -[yellow]Search[-] - [white]Enter[-] Start search / Focus search input - [white]Tab[-] Toggle fuzzy matching - [white]Esc[-] Cancel search (if running) / Clear filter / Go back - -[yellow]Navigation[-] - [white]↑/k[-] Move selection up - [white]↓/j[-] Move selection down - [white]Home/g[-] Jump to first result - [white]End/G[-] Jump to last result - -[yellow]Actions[-] - [white]s[-] SSH to selected instance - [white]d[-] Show details for selected result - [white]b[-] Open in Cloud Console (browser) - [white]o[-] Open in browser (for buckets) - [white]/[-] Filter displayed results - -[yellow]Search Features[-] - • Results appear progressively as they're found - • Search can be cancelled at any time with Esc - • Cancelled searches keep existing results - • Filter (/) narrows displayed results without new search - • Filter supports: spaces (AND), | (OR), - (NOT) - Example: "compute.instance prod" = instances in prod projects - Example: "web|api -dev" = web or api resources, excluding dev - • Tab toggles fuzzy mode (matches characters in order, e.g. "prd" matches "production") - • Context-aware actions based on resource type - -[darkgray]Press Esc or ? to close this help[-]` - - helpView := tview.NewTextView(). - SetDynamicColors(true). - SetText(helpText). - SetScrollable(true) - helpView.SetBorder(true). - SetTitle(" Search Help "). - SetTitleAlign(tview.AlignCenter) - - // Create status bar for help view - helpStatus := tview.NewTextView(). - SetDynamicColors(true). - SetText(" [yellow]Esc[-] back [yellow]?[-] close help") - - // Create fullscreen help layout - helpFlex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(helpView, 0, 1, true). - AddItem(helpStatus, 1, 0, false) - - // Set up input handler - helpView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape || (event.Key() == tcell.KeyRune && event.Rune() == '?') { - *modalOpen = false - app.SetRoot(mainFlex, true) - app.SetFocus(table) - if onRestoreStatus != nil { - onRestoreStatus() - } - return nil - } - return event - }) - - *modalOpen = true - app.SetRoot(helpFlex, true).SetFocus(helpView) -} - // highlightMatch wraps the first occurrence of term in text with tview bold+yellow color tags. // The match is case-insensitive but preserves the original case in the output. -func highlightMatch(text, term string) string { - if term == "" || text == "" { - return text - } - - termLower := strings.ToLower(strings.TrimSpace(term)) - if termLower == "" { - return text - } - - idx := strings.Index(strings.ToLower(text), termLower) - if idx < 0 { - return text - } - - before := text[:idx] - matched := text[idx : idx+len(termLower)] - after := text[idx+len(termLower):] - - return before + "[yellow::b]" + matched + "[-:-:-]" + after -} diff --git a/internal/tui/vpn_view.go b/internal/tui/vpn_view.go index fd9b9e9..f76deb8 100644 --- a/internal/tui/vpn_view.go +++ b/internal/tui/vpn_view.go @@ -522,104 +522,198 @@ func showVPNViewUI(ctx context.Context, gcpClient *gcp.Client, selectedProject s app.SetRoot(flex, true).EnableMouse(true).SetFocus(table) } -func showVPNDetail(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, entry vpnEntry, modalOpen *bool, status *tview.TextView, currentFilter string) { +// ShowVPNGatewayDetails displays a fullscreen modal with detailed VPN gateway information. +// This is a reusable function that can be called from both the VPN view and global search. +func ShowVPNGatewayDetails(app *tview.Application, gateway *gcp.VPNGatewayInfo, onClose func()) { var detailText strings.Builder - switch entry.Type { - case "gateway": - if entry.Gateway != nil { - gw := entry.Gateway - detailText.WriteString("[yellow::b]VPN Gateway Details[-:-:-]\n\n") - detailText.WriteString(fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", gw.Name)) - detailText.WriteString(fmt.Sprintf("[white::b]Region:[-:-:-] %s\n", gw.Region)) - detailText.WriteString(fmt.Sprintf("[white::b]Network:[-:-:-] %s\n", extractNetworkName(gw.Network))) - - if gw.Description != "" { - detailText.WriteString(fmt.Sprintf("[white::b]Description:[-:-:-] %s\n", gw.Description)) + detailText.WriteString("[yellow::b]VPN Gateway Details[-:-:-]\n\n") + detailText.WriteString(fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", gateway.Name)) + detailText.WriteString(fmt.Sprintf("[white::b]Region:[-:-:-] %s\n", gateway.Region)) + detailText.WriteString(fmt.Sprintf("[white::b]Network:[-:-:-] %s\n", extractNetworkName(gateway.Network))) + + if gateway.Description != "" { + detailText.WriteString(fmt.Sprintf("[white::b]Description:[-:-:-] %s\n", gateway.Description)) + } + + detailText.WriteString(fmt.Sprintf("[white::b]Tunnels:[-:-:-] %d\n", len(gateway.Tunnels))) + + if len(gateway.Interfaces) > 0 { + detailText.WriteString("\n[yellow::b]Interfaces:[-:-:-]\n") + for _, iface := range gateway.Interfaces { + detailText.WriteString(fmt.Sprintf(" Interface #%d: %s\n", iface.Id, iface.IpAddress)) + } + } + + if len(gateway.Labels) > 0 { + detailText.WriteString("\n[yellow::b]Labels:[-:-:-]\n") + for k, v := range gateway.Labels { + detailText.WriteString(fmt.Sprintf(" %s: %s\n", k, v)) + } + } + + detailText.WriteString("\n[darkgray]Press Esc to close[-]") + + detailView := tview.NewTextView(). + SetDynamicColors(true). + SetText(detailText.String()). + SetScrollable(true). + SetWordWrap(true) + detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", gateway.Name)) + + // Create status bar for detail view + detailStatus := tview.NewTextView(). + SetDynamicColors(true). + SetText(" [yellow]Esc[-] back [yellow]up/down[-] scroll") + + // Create fullscreen detail layout + detailFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(detailView, 0, 1, true). + AddItem(detailStatus, 1, 0, false) + + // Set up input handler + detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + if onClose != nil { + onClose() } + return nil + } + return event + }) - detailText.WriteString(fmt.Sprintf("[white::b]Tunnels:[-:-:-] %d\n", len(gw.Tunnels))) + app.SetRoot(detailFlex, true).SetFocus(detailView) +} + +// ShowVPNTunnelDetails displays a fullscreen modal with detailed VPN tunnel information. +// This is a reusable function that can be called from both the VPN view and global search. +func ShowVPNTunnelDetails(app *tview.Application, tunnel *gcp.VPNTunnelInfo, onClose func()) { + var detailText strings.Builder + + detailText.WriteString("[yellow::b]VPN Tunnel Details[-:-:-]\n\n") + detailText.WriteString(fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", tunnel.Name)) + detailText.WriteString(fmt.Sprintf("[white::b]Region:[-:-:-] %s\n", tunnel.Region)) + detailText.WriteString(fmt.Sprintf("[white::b]Status:[-:-:-] %s\n", tunnel.Status)) + + if tunnel.DetailedStatus != "" { + detailText.WriteString(fmt.Sprintf("[white::b]Detailed Status:[-:-:-] %s\n", tunnel.DetailedStatus)) + } + + if tunnel.Description != "" { + detailText.WriteString(fmt.Sprintf("[white::b]Description:[-:-:-] %s\n", tunnel.Description)) + } + + detailText.WriteString(fmt.Sprintf("[white::b]Local Gateway IP:[-:-:-] %s\n", tunnel.LocalGatewayIP)) + detailText.WriteString(fmt.Sprintf("[white::b]Peer IP:[-:-:-] %s\n", tunnel.PeerIP)) - if len(gw.Interfaces) > 0 { - detailText.WriteString("\n[yellow::b]Interfaces:[-:-:-]\n") - for _, iface := range gw.Interfaces { - detailText.WriteString(fmt.Sprintf(" Interface #%d: %s\n", iface.Id, iface.IpAddress)) + if tunnel.PeerGateway != "" { + detailText.WriteString(fmt.Sprintf("[white::b]Peer Gateway:[-:-:-] %s\n", extractNetworkName(tunnel.PeerGateway))) + } + + if tunnel.RouterName != "" { + detailText.WriteString(fmt.Sprintf("[white::b]Router:[-:-:-] %s\n", tunnel.RouterName)) + } + + if tunnel.IkeVersion > 0 { + detailText.WriteString(fmt.Sprintf("[white::b]IKE Version:[-:-:-] %d\n", tunnel.IkeVersion)) + } + + // BGP Sessions + if len(tunnel.BgpSessions) > 0 { + detailText.WriteString("\n[yellow::b]BGP Sessions:[-:-:-]\n") + for _, bgp := range tunnel.BgpSessions { + detailText.WriteString(fmt.Sprintf("\n [white::b]%s[-:-:-]\n", bgp.Name)) + detailText.WriteString(fmt.Sprintf(" Status: %s / %s\n", bgp.SessionStatus, bgp.SessionState)) + detailText.WriteString(fmt.Sprintf(" Local: %s (AS%d)\n", bgp.LocalIP, bgp.LocalASN)) + detailText.WriteString(fmt.Sprintf(" Peer: %s (AS%d)\n", bgp.PeerIP, bgp.PeerASN)) + detailText.WriteString(fmt.Sprintf(" Priority: %d\n", bgp.RoutePriority)) + detailText.WriteString(fmt.Sprintf(" Enabled: %v\n", bgp.Enabled)) + + if len(bgp.AdvertisedPrefixes) > 0 { + detailText.WriteString(fmt.Sprintf(" [green]Advertised:[-] %d prefixes\n", len(bgp.AdvertisedPrefixes))) + for _, prefix := range bgp.AdvertisedPrefixes { + detailText.WriteString(fmt.Sprintf(" %s\n", prefix)) } + } else { + detailText.WriteString(" [gray]Advertised: 0 prefixes[-]\n") } - if len(gw.Labels) > 0 { - detailText.WriteString("\n[yellow::b]Labels:[-:-:-]\n") - for k, v := range gw.Labels { - detailText.WriteString(fmt.Sprintf(" %s: %s\n", k, v)) + if len(bgp.LearnedPrefixes) > 0 { + detailText.WriteString(fmt.Sprintf(" [green]Learned:[-] %d prefixes\n", len(bgp.LearnedPrefixes))) + for _, prefix := range bgp.LearnedPrefixes { + detailText.WriteString(fmt.Sprintf(" %s\n", prefix)) } + } else { + detailText.WriteString(" [gray]Learned: 0 prefixes[-]\n") } } + } - case "tunnel", "orphan-tunnel": - if entry.Tunnel != nil { - tunnel := entry.Tunnel - detailText.WriteString("[yellow::b]VPN Tunnel Details[-:-:-]\n\n") - detailText.WriteString(fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", tunnel.Name)) - detailText.WriteString(fmt.Sprintf("[white::b]Region:[-:-:-] %s\n", tunnel.Region)) - detailText.WriteString(fmt.Sprintf("[white::b]Status:[-:-:-] %s\n", tunnel.Status)) - - if tunnel.DetailedStatus != "" { - detailText.WriteString(fmt.Sprintf("[white::b]Detailed Status:[-:-:-] %s\n", tunnel.DetailedStatus)) - } + detailText.WriteString("\n[darkgray]Press Esc to close[-]") - if tunnel.Description != "" { - detailText.WriteString(fmt.Sprintf("[white::b]Description:[-:-:-] %s\n", tunnel.Description)) - } + detailView := tview.NewTextView(). + SetDynamicColors(true). + SetText(detailText.String()). + SetScrollable(true). + SetWordWrap(true) + detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", tunnel.Name)) - detailText.WriteString(fmt.Sprintf("[white::b]Local Gateway IP:[-:-:-] %s\n", tunnel.LocalGatewayIP)) - detailText.WriteString(fmt.Sprintf("[white::b]Peer IP:[-:-:-] %s\n", tunnel.PeerIP)) + // Create status bar for detail view + detailStatus := tview.NewTextView(). + SetDynamicColors(true). + SetText(" [yellow]Esc[-] back [yellow]up/down[-] scroll") - if tunnel.PeerGateway != "" { - detailText.WriteString(fmt.Sprintf("[white::b]Peer Gateway:[-:-:-] %s\n", extractNetworkName(tunnel.PeerGateway))) - } + // Create fullscreen detail layout + detailFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(detailView, 0, 1, true). + AddItem(detailStatus, 1, 0, false) - if tunnel.RouterName != "" { - detailText.WriteString(fmt.Sprintf("[white::b]Router:[-:-:-] %s\n", tunnel.RouterName)) + // Set up input handler + detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + if onClose != nil { + onClose() } + return nil + } + return event + }) - if tunnel.IkeVersion > 0 { - detailText.WriteString(fmt.Sprintf("[white::b]IKE Version:[-:-:-] %d\n", tunnel.IkeVersion)) - } + app.SetRoot(detailFlex, true).SetFocus(detailView) +} - // BGP Sessions - if len(tunnel.BgpSessions) > 0 { - detailText.WriteString("\n[yellow::b]BGP Sessions:[-:-:-]\n") - for _, bgp := range tunnel.BgpSessions { - detailText.WriteString(fmt.Sprintf("\n [white::b]%s[-:-:-]\n", bgp.Name)) - detailText.WriteString(fmt.Sprintf(" Status: %s / %s\n", bgp.SessionStatus, bgp.SessionState)) - detailText.WriteString(fmt.Sprintf(" Local: %s (AS%d)\n", bgp.LocalIP, bgp.LocalASN)) - detailText.WriteString(fmt.Sprintf(" Peer: %s (AS%d)\n", bgp.PeerIP, bgp.PeerASN)) - detailText.WriteString(fmt.Sprintf(" Priority: %d\n", bgp.RoutePriority)) - detailText.WriteString(fmt.Sprintf(" Enabled: %v\n", bgp.Enabled)) - - if len(bgp.AdvertisedPrefixes) > 0 { - detailText.WriteString(fmt.Sprintf(" [green]Advertised:[-] %d prefixes\n", len(bgp.AdvertisedPrefixes))) - for _, prefix := range bgp.AdvertisedPrefixes { - detailText.WriteString(fmt.Sprintf(" %s\n", prefix)) - } - } else { - detailText.WriteString(" [gray]Advertised: 0 prefixes[-]\n") - } - - if len(bgp.LearnedPrefixes) > 0 { - detailText.WriteString(fmt.Sprintf(" [green]Learned:[-] %d prefixes\n", len(bgp.LearnedPrefixes))) - for _, prefix := range bgp.LearnedPrefixes { - detailText.WriteString(fmt.Sprintf(" %s\n", prefix)) - } - } else { - detailText.WriteString(" [gray]Learned: 0 prefixes[-]\n") - } - } - } +func showVPNDetail(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, entry vpnEntry, modalOpen *bool, status *tview.TextView, currentFilter string) { + // Prepare onClose callback to restore the VPN view + onClose := func() { + *modalOpen = false + app.SetRoot(mainFlex, true) + app.SetFocus(table) + if currentFilter != "" { + status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) + } else { + status.SetText(" [yellow]Esc[-] back [yellow]d[-] details [yellow]r[-] refresh [yellow]/[-] filter [yellow]?[-] help") + } + } + + *modalOpen = true + + switch entry.Type { + case "gateway": + if entry.Gateway != nil { + ShowVPNGatewayDetails(app, entry.Gateway, onClose) + } + + case "tunnel", "orphan-tunnel": + if entry.Tunnel != nil { + ShowVPNTunnelDetails(app, entry.Tunnel, onClose) } case "bgp", "orphan-bgp": if entry.BGP != nil { + // For BGP sessions, keep inline formatting as they aren't searchable resources + var detailText strings.Builder bgp := entry.BGP detailText.WriteString("[yellow::b]BGP Session Details[-:-:-]\n\n") detailText.WriteString(fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", bgp.Name)) @@ -667,50 +761,71 @@ func showVPNDetail(app *tview.Application, table *tview.Table, mainFlex *tview.F detailText.WriteString(fmt.Sprintf(" %s\n", prefix)) } } + + detailText.WriteString("\n[darkgray]Press Esc to close[-]") + + detailView := tview.NewTextView(). + SetDynamicColors(true). + SetText(detailText.String()). + SetScrollable(true). + SetWordWrap(true) + detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", entry.Name)) + + // Create status bar for detail view + detailStatus := tview.NewTextView(). + SetDynamicColors(true). + SetText(" [yellow]Esc[-] back [yellow]up/down[-] scroll") + + // Create fullscreen detail layout + detailFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(detailView, 0, 1, true). + AddItem(detailStatus, 1, 0, false) + + // Set up input handler + detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + onClose() + return nil + } + return event + }) + + app.SetRoot(detailFlex, true).SetFocus(detailView) } default: + // Handle unknown type + var detailText strings.Builder detailText.WriteString("[red]No details available[-]") - } - - detailText.WriteString("\n[darkgray]Press Esc to close[-]") - - detailView := tview.NewTextView(). - SetDynamicColors(true). - SetText(detailText.String()). - SetScrollable(true). - SetWordWrap(true) - detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", entry.Name)) - - // Create status bar for detail view - detailStatus := tview.NewTextView(). - SetDynamicColors(true). - SetText(" [yellow]Esc[-] back [yellow]up/down[-] scroll") - - // Create fullscreen detail layout - detailFlex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(detailView, 0, 1, true). - AddItem(detailStatus, 1, 0, false) - - // Set up input handler - detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - *modalOpen = false - app.SetRoot(mainFlex, true) - app.SetFocus(table) - if currentFilter != "" { - status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) - } else { - status.SetText(" [yellow]Esc[-] back [yellow]d[-] details [yellow]r[-] refresh [yellow]/[-] filter [yellow]?[-] help") + detailText.WriteString("\n[darkgray]Press Esc to close[-]") + + detailView := tview.NewTextView(). + SetDynamicColors(true). + SetText(detailText.String()). + SetScrollable(true). + SetWordWrap(true) + detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", entry.Name)) + + detailStatus := tview.NewTextView(). + SetDynamicColors(true). + SetText(" [yellow]Esc[-] back") + + detailFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(detailView, 0, 1, true). + AddItem(detailStatus, 1, 0, false) + + detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + onClose() + return nil } - return nil - } - return event - }) + return event + }) - *modalOpen = true - app.SetRoot(detailFlex, true).SetFocus(detailView) + app.SetRoot(detailFlex, true).SetFocus(detailView) + } } func showVPNHelp(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, modalOpen *bool, currentFilter string, status *tview.TextView) {