//! Connector cache, popup, and refresh handling for `ChatWidget`.use super::*;use crate::app_event::ConnectorsSnapshot;#[derive(Debug, Clone, Default)]pub(super) enum ConnectorsCacheState { #[default] Uninitialized, Loading, Ready(ConnectorsSnapshot), Failed(String),}#[derive(Debug, Default)]pub(super) struct ConnectorsState { pub(super) cache: ConnectorsCacheState, pub(super) partial_snapshot: Option<ConnectorsSnapshot>, pub(super) prefetch_in_flight: bool, pub(super) force_refetch_pending: bool,}impl ChatWidget { pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) { self.queue_connectors_refresh(force_refetch); } pub(super) fn prefetch_connectors(&mut self) { self.queue_connectors_refresh(/*force_refetch*/ false); } fn queue_connectors_refresh(&mut self, force_refetch: bool) { if self.begin_connectors_refresh(force_refetch) { self.app_event_tx .send(AppEvent::FetchConnectorsList { force_refetch }); } } fn begin_connectors_refresh(&mut self, force_refetch: bool) -> bool { if !self.connectors_enabled() { return false; } if self.connectors.prefetch_in_flight { if force_refetch { self.connectors.force_refetch_pending = true; } return false; } self.connectors.prefetch_in_flight = true; if !matches!(self.connectors.cache, ConnectorsCacheState::Ready(_)) { self.connectors.cache = ConnectorsCacheState::Loading; } true } pub(super) fn connectors_enabled(&self) -> bool { self.config.features.enabled(Feature::Apps) && self.has_chatgpt_account } pub(super) fn connectors_for_mentions(&self) -> Option<&[AppInfo]> { if !self.connectors_enabled() { return None; } if let Some(snapshot) = &self.connectors.partial_snapshot { return Some(snapshot.connectors.as_slice()); } match &self.connectors.cache { ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()), _ => None, } } pub(crate) fn add_connectors_output(&mut self) { if !self.connectors_enabled() { self.add_info_message( "Apps are disabled.".to_string(), Some("Enable the apps feature to use $ or /apps.".to_string()), ); return; } let connectors_cache = self.connectors.cache.clone(); let should_force_refetch = !self.connectors.prefetch_in_flight || matches!(connectors_cache, ConnectorsCacheState::Ready(_)); self.queue_connectors_refresh(should_force_refetch); match connectors_cache { ConnectorsCacheState::Ready(snapshot) => { if snapshot.connectors.is_empty() { self.add_info_message("No apps available.".to_string(), /*hint*/ None); } else { self.open_connectors_popup(&snapshot.connectors); } } ConnectorsCacheState::Failed(err) => { self.add_to_history(history_cell::new_error_event(err)); } ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => { self.open_connectors_loading_popup(); } } self.request_redraw(); } fn open_connectors_loading_popup(&mut self) { if !self.bottom_pane.replace_selection_view_if_active( CONNECTORS_SELECTION_VIEW_ID, self.connectors_loading_popup_params(), ) { self.bottom_pane .show_selection_view(self.connectors_loading_popup_params()); } } fn open_connectors_popup(&mut self, connectors: &[AppInfo]) { self.bottom_pane.show_selection_view( self.connectors_popup_params(connectors, /*selected_connector_id*/ None), ); } fn connectors_loading_popup_params(&self) -> SelectionViewParams { let mut header = ColumnRenderable::new(); header.push(Line::from("Apps".bold())); header.push(Line::from("Loading installed and available apps...".dim())); SelectionViewParams { view_id: Some(CONNECTORS_SELECTION_VIEW_ID), header: Box::new(header), items: vec![SelectionItem { name: "Loading apps...".to_string(), description: Some("This updates when the full list is ready.".to_string()), is_disabled: true, ..Default::default() }], ..Default::default() } } fn connectors_popup_params( &self, connectors: &[AppInfo], selected_connector_id: Option<&str>, ) -> SelectionViewParams { let total = connectors.len(); let installed = connectors .iter() .filter(|connector| connector.is_accessible) .count(); let mut header = ColumnRenderable::new(); header.push(Line::from("Apps".bold())); header.push(Line::from( "Use $ to insert an installed app into your prompt.".dim(), )); header.push(Line::from( format!("Installed {installed} of {total} available apps.").dim(), )); let initial_selected_idx = selected_connector_id.and_then(|selected_connector_id| { connectors .iter() .position(|connector| connector.id == selected_connector_id) }); let mut items: Vec<SelectionItem> = Vec::with_capacity(connectors.len()); for connector in connectors { let connector_label = codex_connectors::metadata::connector_display_label(connector); let connector_title = connector_label.clone(); let link_description = Self::connector_description(connector); let description = Self::connector_brief_description(connector); let status_label = Self::connector_status_label(connector); let search_value = format!("{connector_label} {}", connector.id); let mut item = SelectionItem { name: connector_label, description: Some(description), search_value: Some(search_value), ..Default::default() }; let is_installed = connector.is_accessible; let selected_label = if is_installed { format!( "{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app." ) } else { format!("{status_label}. Press Enter to open the app page to install this app.") }; let missing_label = format!("{status_label}. App link unavailable."); let instructions = if connector.is_accessible { "Manage this app in your browser." } else { "Install this app in your browser, then reload Codex." }; if let Some(install_url) = connector.install_url.clone() { let app_id = connector.id.clone(); let is_enabled = connector.is_enabled; let title = connector_title.clone(); let instructions = instructions.to_string(); let description = link_description.clone(); item.actions = vec![Box::new(move |tx| { tx.send(AppEvent::OpenAppLink { app_id: app_id.clone(), title: title.clone(), description: description.clone(), instructions: instructions.clone(), url: install_url.clone(), is_installed, is_enabled, }); })]; item.dismiss_on_select = true; item.selected_description = Some(selected_label); } else { let missing_label_for_action = missing_label.clone(); item.actions = vec![Box::new(move |tx| { tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_info_event( missing_label_for_action.clone(), /*hint*/ None, ), ))); })]; item.dismiss_on_select = true; item.selected_description = Some(missing_label); } items.push(item); } SelectionViewParams { view_id: Some(CONNECTORS_SELECTION_VIEW_ID), header: Box::new(header), footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search apps".to_string()), col_width_mode: ColumnWidthMode::AutoAllRows, initial_selected_idx, ..Default::default() } } fn refresh_connectors_popup_if_open(&mut self, connectors: &[AppInfo]) { let selected_connector_id = if let (Some(selected_index), ConnectorsCacheState::Ready(snapshot)) = ( self.bottom_pane .selected_index_for_active_view(CONNECTORS_SELECTION_VIEW_ID), &self.connectors.cache, ) { snapshot .connectors .get(selected_index) .map(|connector| connector.id.as_str()) } else { None }; let _ = self.bottom_pane.replace_selection_view_if_active( CONNECTORS_SELECTION_VIEW_ID, self.connectors_popup_params(connectors, selected_connector_id), ); } fn connector_brief_description(connector: &AppInfo) -> String { let status_label = Self::connector_status_label(connector); match Self::connector_description(connector) { Some(description) => format!("{status_label} · {description}"), None => status_label.to_string(), } } fn connector_status_label(connector: &AppInfo) -> &'static str { if connector.is_accessible { if connector.is_enabled { "Installed" } else { "Installed · Disabled" } } else { "Can be installed" } } fn connector_description(connector: &AppInfo) -> Option<String> { connector .description .as_deref() .map(str::trim) .filter(|description| !description.is_empty()) .map(str::to_string) } pub(crate) fn on_connectors_loaded( &mut self, result: Result<ConnectorsSnapshot, String>, is_final: bool, ) { let mut trigger_pending_force_refetch = false; if is_final { self.connectors.prefetch_in_flight = false; if self.connectors.force_refetch_pending { self.connectors.force_refetch_pending = false; trigger_pending_force_refetch = true; } } match result { Ok(mut snapshot) => { if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache { let enabled_by_id: HashMap<&str, bool> = existing_snapshot .connectors .iter() .map(|connector| (connector.id.as_str(), connector.is_enabled)) .collect(); for connector in &mut snapshot.connectors { if let Some(is_enabled) = enabled_by_id.get(connector.id.as_str()) { connector.is_enabled = *is_enabled; } } } if is_final { self.connectors.partial_snapshot = None; self.refresh_connectors_popup_if_open(&snapshot.connectors); self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); } else { self.connectors.partial_snapshot = Some(snapshot.clone()); } self.bottom_pane.set_connectors_snapshot(Some(snapshot)); } Err(err) => { let partial_snapshot = self.connectors.partial_snapshot.take(); if let ConnectorsCacheState::Ready(snapshot) = &self.connectors.cache { warn!("failed to refresh apps list; retaining current apps snapshot: {err}"); self.bottom_pane .set_connectors_snapshot(Some(snapshot.clone())); } else if let Some(snapshot) = partial_snapshot { warn!( "failed to load full apps list; falling back to installed apps snapshot: {err}" ); self.refresh_connectors_popup_if_open(&snapshot.connectors); self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); self.bottom_pane.set_connectors_snapshot(Some(snapshot)); } else { self.connectors.cache = ConnectorsCacheState::Failed(err); self.bottom_pane.set_connectors_snapshot(/*snapshot*/ None); } } } if trigger_pending_force_refetch { self.queue_connectors_refresh(/*force_refetch*/ true); } } pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) { let ConnectorsCacheState::Ready(mut snapshot) = self.connectors.cache.clone() else { return; }; let mut changed = false; for connector in &mut snapshot.connectors { if connector.id == connector_id { changed = connector.is_enabled != enabled; connector.is_enabled = enabled; break; } } if !changed { return; } self.refresh_connectors_popup_if_open(&snapshot.connectors); self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); self.bottom_pane.set_connectors_snapshot(Some(snapshot)); }}