Queue plugin toggle writes and let Space extend active search

This commit is contained in:
canvrno-oai
2026-04-16 10:26:29 -07:00
parent 1baeaa95e9
commit 34a8025994
3 changed files with 132 additions and 12 deletions
+48 -11
View File
@@ -1049,6 +1049,9 @@ pub(crate) struct App {
primary_session_configured: Option<ThreadSessionState>,
pending_primary_events: VecDeque<ThreadBufferedEvent>,
pending_app_server_requests: PendingAppServerRequests,
// Serialize plugin enablement writes per plugin so stale completions cannot
// overwrite a newer toggle.
pending_plugin_enabled_writes: HashMap<(PathBuf, String), Option<bool>>,
}
#[derive(Default)]
@@ -2183,6 +2186,23 @@ impl App {
cwd: PathBuf,
plugin_id: String,
enabled: bool,
) {
let key = (cwd.clone(), plugin_id.clone());
if let Some(queued_enabled) = self.pending_plugin_enabled_writes.get_mut(&key) {
*queued_enabled = Some(enabled);
return;
}
self.pending_plugin_enabled_writes.insert(key, None);
self.spawn_plugin_enabled_write(app_server, cwd, plugin_id, enabled);
}
fn spawn_plugin_enabled_write(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
plugin_id: String,
enabled: bool,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
@@ -4049,6 +4069,7 @@ impl App {
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
};
if let Some(started) = initial_started_thread {
app.enqueue_primary_thread_session(started.session, started.turns)
@@ -5313,19 +5334,35 @@ impl App {
enabled,
result,
} => {
let update_succeeded = result.is_ok();
if update_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after plugin toggle"
);
let key = (cwd.clone(), plugin_id.clone());
let queued_enabled = self
.pending_plugin_enabled_writes
.get_mut(&key)
.and_then(Option::take);
let should_apply_result = if let Some(queued_enabled) = queued_enabled
&& (result.is_err() || queued_enabled != enabled)
{
self.spawn_plugin_enabled_write(app_server, cwd, plugin_id, queued_enabled);
false
} else {
true
};
if should_apply_result {
self.pending_plugin_enabled_writes.remove(&key);
let update_succeeded = result.is_ok();
if update_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after plugin toggle"
);
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
self.chat_widget
.on_plugin_enabled_set(cwd, plugin_id, enabled, result);
}
self.chat_widget
.on_plugin_enabled_set(cwd, plugin_id, enabled, result);
}
AppEvent::RefreshPluginMentions => {
self.refresh_plugin_mentions();
@@ -798,7 +798,11 @@ impl BottomPaneView for ListSelectionView {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
} if self.selected_item_has_toggle() => self.toggle_selected(),
} if self.selected_item_has_toggle()
&& (!self.is_searchable || self.search_query.is_empty()) =>
{
self.toggle_selected()
}
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
@@ -1482,6 +1486,45 @@ mod tests {
);
}
#[test]
fn space_appends_to_active_search_instead_of_toggling_selected_item() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = ListSelectionView::new(
SelectionViewParams {
items: vec![SelectionItem {
name: "Plugin".to_string(),
toggle: Some(SelectionToggle {
is_on: false,
action: Box::new(|_enabled, tx: &_| {
tx.send(AppEvent::OpenApprovalsPopup);
}),
}),
..Default::default()
}],
is_searchable: true,
..Default::default()
},
tx,
);
view.set_search_query("plugin".to_string());
view.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(view.search_query, "plugin ");
assert!(
!view.active_items()[0]
.toggle
.as_ref()
.is_some_and(|toggle| toggle.is_on),
"expected Space to leave the toggle state unchanged while search is active"
);
assert!(
rx.try_recv().is_err(),
"expected Space with an active search query to avoid firing the toggle action"
);
}
#[test]
fn enter_with_no_matches_triggers_cancel_callback() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
@@ -663,6 +663,46 @@ async fn plugins_popup_space_on_uninstalled_row_does_not_start_search() {
assert_eq!(after, before);
}
#[tokio::test]
async fn plugins_popup_space_with_active_search_does_not_toggle_installed_plugin() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
render_loaded_plugins_popup(
&mut chat,
plugins_test_response(vec![plugins_test_curated_marketplace(vec![
plugins_test_summary(
"plugin-calendar",
"calendar",
Some("Calendar"),
Some("Schedule management."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]),
);
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
type_plugins_search_query(&mut chat, "dr");
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert!(
rx.try_recv().is_err(),
"did not expect Space with an active plugin search to emit a toggle event"
);
}
#[tokio::test]
async fn plugins_popup_search_filters_visible_rows_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;