mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Summary Fix the TUI `$` app mention paths so App Directory rows that are not accessible are not treated as usable apps. This includes the core preservation fix from #24104, but expands it to the other app mention paths: - preserve app-server `is_accessible` flags when partial `app/list/updated` snapshots reach the TUI - require apps to be both accessible and enabled when resolving exact `$slug` mentions - require restored/stale `app://...` bindings to point at accessible, enabled apps before emitting structured app mentions - remove the now-unused `codex-chatgpt` dependency from `codex-tui`, which addresses the `cargo shear` failure seen on #24104 ## Root Cause The app server already sends merged app snapshots with accessibility computed. The TUI handled app-server app list updates as partial app loads and re-ran the old accessible-app merge path. That path treated every notification row as accessible, so App Directory entries with `isAccessible=false` could appear in `$` suggestions. Regression source: #22914 routed app-list updates through the app server while reusing the old TUI partial-load handling. Related precursor: #14717 introduced the partial-load path, but #22914 made it user-visible for app-server updates. ## Issues Fixes #24145 Fixes #24205 Fixes #24319 ## Validation - `just fmt` - `git diff --check` - `just bazel-lock-update` - `just bazel-lock-check` - `just argument-comment-lint -p codex-tui` - `just test -p codex-tui chatwidget::tests::popups_and_settings::apps_notification_update_excludes_inaccessible_apps_from_mentions chatwidget::tests::composer_submission::submit_user_message_ignores_inaccessible_app_mentions_from_bindings chatwidget::skills::tests::find_app_mentions_requires_accessible_enabled_apps_for_bound_paths chatwidget::skills::tests::find_app_mentions_requires_accessible_enabled_apps_for_slugs`
375 lines
14 KiB
Rust
375 lines
14 KiB
Rust
//! 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));
|
|
}
|
|
}
|