fresh/app/async_dispatch.rs
1//! Async-message dispatch on `Editor`.
2//!
3//! `process_async_messages` runs each frame and drains the AsyncBridge,
4//! routing each AsyncMessage to its handler — LSP responses,
5//! initialization/errors, plugin commands, filesystem polling, etc.
6//! ~650 lines of `match`-armed dispatch.
7
8use rust_i18n::t;
9
10use crate::services::async_bridge::AsyncMessage;
11use crate::view::prompt::PromptType;
12
13use super::Editor;
14
15impl Editor {
16 /// Process pending async messages from the async bridge
17 ///
18 /// This should be called each frame in the main loop to handle:
19 /// - LSP diagnostics
20 /// - LSP initialization/errors
21 /// - File system changes (future)
22 /// - Git status updates
23 pub fn process_async_messages(&mut self) -> bool {
24 // Check plugin thread health - will panic if thread died due to error
25 // This ensures plugin errors surface quickly instead of causing silent hangs
26 self.plugin_manager.write().unwrap().check_thread_health();
27
28 let Some(bridge) = &self.async_bridge else {
29 return false;
30 };
31
32 // Drain editor-global async messages first (plugin runtime
33 // callbacks, file dialog, etc.), then drain each window's
34 // per-window bridge (LSP responses, terminal output, etc.).
35 // Order matters only for cosmetic message ordering on a
36 // very-busy frame; semantically the dispatcher is the same
37 // for every source.
38 let mut messages = {
39 let _s = tracing::info_span!("try_recv_all").entered();
40 bridge.try_recv_all()
41 };
42 for window in self.windows.values() {
43 messages.extend(window.bridge.try_recv_all());
44 }
45 let needs_render = !messages.is_empty();
46 tracing::trace!(
47 async_message_count = messages.len(),
48 "received async messages"
49 );
50
51 for message in messages {
52 match message {
53 AsyncMessage::LspDiagnostics {
54 uri,
55 diagnostics,
56 server_name,
57 } => {
58 self.handle_lsp_diagnostics(uri, diagnostics, server_name);
59 }
60 AsyncMessage::LspInitialized {
61 language,
62 server_name,
63 capabilities,
64 } => {
65 tracing::info!(
66 "LSP server '{}' initialized for language: {}",
67 server_name,
68 language
69 );
70 self.active_window_mut().status_message =
71 Some(format!("LSP ({}) ready", language));
72
73 // Store capabilities on the specific server handle
74 let __active_id = self.active_window;
75 if let Some(lsp) = self
76 .windows
77 .get_mut(&__active_id)
78 .and_then(|w| w.lsp.as_mut())
79 {
80 lsp.set_server_capabilities(&language, &server_name, capabilities);
81 }
82
83 // Send didOpen for all open buffers of this language
84 self.resend_did_open_for_language(&language);
85 self.request_semantic_tokens_for_language(&language);
86 self.request_folding_ranges_for_language(&language);
87 // Now that capabilities are known, kick off inlay hints
88 // and pull-diagnostics for buffers that opened before the
89 // `initialize` handshake completed. Both paths route
90 // through `handle_for_feature_mut`, so servers that
91 // didn't advertise the capability are skipped.
92 self.request_inlay_hints_for_language(&language);
93 self.pull_diagnostics_for_language(&language);
94 }
95 AsyncMessage::LspError {
96 language,
97 error,
98 stderr_log_path,
99 } => {
100 tracing::error!("LSP error for {}: {}", language, error);
101 self.active_window_mut().status_message =
102 Some(format!("LSP error ({}): {}", language, error));
103
104 // Get server command from config for the hook
105 let server_command = self
106 .config
107 .lsp
108 .get(&language)
109 .and_then(|configs| configs.as_slice().first())
110 .map(|c| c.command.clone())
111 .unwrap_or_else(|| "unknown".to_string());
112
113 // Determine error type from error message
114 let error_type = if error.contains("not found") || error.contains("NotFound") {
115 "not_found"
116 } else if error.contains("permission") || error.contains("PermissionDenied") {
117 "spawn_failed"
118 } else if error.contains("timeout") {
119 "timeout"
120 } else {
121 "spawn_failed"
122 }
123 .to_string();
124
125 // Fire the LspServerError hook for plugins
126 self.plugin_manager.read().unwrap().run_hook(
127 "lsp_server_error",
128 crate::services::plugins::hooks::HookArgs::LspServerError {
129 language: language.clone(),
130 server_command,
131 error_type,
132 message: error.clone(),
133 },
134 );
135
136 // Open stderr log as read-only buffer if it exists and has content
137 // Opens in background (new tab) without stealing focus
138 if let Some(log_path) = stderr_log_path {
139 let has_content = log_path.metadata().map(|m| m.len() > 0).unwrap_or(false);
140 if has_content {
141 tracing::info!("Opening LSP stderr log in background: {:?}", log_path);
142 match self.open_file_no_focus(&log_path) {
143 Ok(buffer_id) => {
144 self.active_window_mut()
145 .mark_buffer_read_only(buffer_id, true);
146 self.active_window_mut().status_message = Some(format!(
147 "LSP error ({}): {} - See stderr log",
148 language, error
149 ));
150 }
151 Err(e) => {
152 tracing::error!("Failed to open LSP stderr log: {}", e);
153 }
154 }
155 }
156 }
157 }
158 AsyncMessage::LspCompletion { request_id, items } => {
159 if let Err(e) = self.handle_completion_response(request_id, items) {
160 tracing::error!("Error handling completion response: {}", e);
161 }
162 }
163 AsyncMessage::LspGotoDefinition {
164 request_id,
165 locations,
166 } => {
167 if let Err(e) = self.handle_goto_definition_response(request_id, locations) {
168 tracing::error!("Error handling goto definition response: {}", e);
169 }
170 }
171 AsyncMessage::LspRename { request_id, result } => {
172 if let Err(e) = self.handle_rename_response(request_id, result) {
173 tracing::error!("Error handling rename response: {}", e);
174 }
175 }
176 AsyncMessage::LspHover {
177 request_id,
178 contents,
179 is_markdown,
180 range,
181 } => {
182 self.handle_hover_response(request_id, contents, is_markdown, range);
183 }
184 AsyncMessage::LspReferences {
185 request_id,
186 locations,
187 } => {
188 if let Err(e) = self.handle_references_response(request_id, locations) {
189 tracing::error!("Error handling references response: {}", e);
190 }
191 }
192 AsyncMessage::LspSignatureHelp {
193 request_id,
194 signature_help,
195 } => {
196 self.handle_signature_help_response(request_id, signature_help);
197 }
198 AsyncMessage::LspCodeActions {
199 request_id,
200 actions,
201 } => {
202 self.handle_code_actions_response(request_id, actions);
203 }
204 AsyncMessage::LspApplyEdit { edit, label } => {
205 tracing::info!("Applying workspace edit from server (label: {:?})", label);
206 match self.apply_workspace_edit(edit) {
207 Ok(n) => {
208 if let Some(label) = label {
209 self.set_status_message(
210 t!("lsp.code_action_applied", title = &label, count = n)
211 .to_string(),
212 );
213 }
214 }
215 Err(e) => {
216 tracing::error!("Failed to apply workspace edit: {}", e);
217 }
218 }
219 }
220 AsyncMessage::LspCodeActionResolved {
221 request_id: _,
222 action,
223 } => match action {
224 Ok(resolved) => {
225 self.execute_resolved_code_action(resolved);
226 }
227 Err(e) => {
228 tracing::warn!("codeAction/resolve failed: {}", e);
229 self.set_status_message(format!("Code action resolve failed: {e}"));
230 }
231 },
232 AsyncMessage::LspCompletionResolved {
233 request_id: _,
234 item,
235 } => {
236 if let Ok(resolved) = item {
237 self.handle_completion_resolved(resolved);
238 }
239 }
240 AsyncMessage::LspFormatting {
241 request_id: _,
242 uri,
243 edits,
244 } => {
245 if !edits.is_empty() {
246 if let Err(e) = self.apply_formatting_edits(&uri, edits) {
247 tracing::error!("Failed to apply formatting: {}", e);
248 }
249 }
250 }
251 AsyncMessage::LspPrepareRename {
252 request_id: _,
253 result,
254 } => {
255 self.handle_prepare_rename_response(result);
256 }
257 AsyncMessage::LspPulledDiagnostics {
258 request_id: _,
259 uri,
260 result_id,
261 diagnostics,
262 unchanged,
263 } => {
264 self.handle_lsp_pulled_diagnostics(uri, result_id, diagnostics, unchanged);
265 }
266 AsyncMessage::LspInlayHints {
267 request_id,
268 uri,
269 hints,
270 } => {
271 self.handle_lsp_inlay_hints(request_id, uri, hints);
272 }
273 AsyncMessage::LspFoldingRanges {
274 request_id,
275 uri,
276 ranges,
277 } => {
278 self.handle_lsp_folding_ranges(request_id, uri, ranges);
279 }
280 AsyncMessage::LspSemanticTokens {
281 request_id,
282 uri,
283 response,
284 } => {
285 self.handle_lsp_semantic_tokens(request_id, uri, response);
286 }
287 AsyncMessage::LspServerQuiescent { language } => {
288 self.handle_lsp_server_quiescent(language);
289 }
290 AsyncMessage::LspDiagnosticRefresh { language } => {
291 self.handle_lsp_diagnostic_refresh(language);
292 }
293 AsyncMessage::FileChanged { path } => {
294 self.handle_async_file_changed(path);
295 }
296 AsyncMessage::GitStatusChanged { status } => {
297 tracing::info!("Git status changed: {}", status);
298 // TODO: Handle git status changes
299 }
300 AsyncMessage::FileExplorerInitialized(view) => {
301 self.handle_file_explorer_initialized(view);
302 }
303 AsyncMessage::FileExplorerToggleNode(node_id) => {
304 self.handle_file_explorer_toggle_node(node_id);
305 }
306 AsyncMessage::FileExplorerRefreshNode(node_id) => {
307 self.handle_file_explorer_refresh_node(node_id);
308 }
309 AsyncMessage::FileExplorerExpandedToPath(view) => {
310 self.handle_file_explorer_expanded_to_path(view);
311 }
312 AsyncMessage::Plugin(plugin_msg) => {
313 use fresh_core::api::{JsCallbackId, PluginAsyncMessage};
314 match plugin_msg {
315 PluginAsyncMessage::ProcessOutput {
316 process_id,
317 stdout,
318 stderr,
319 exit_code,
320 } => {
321 self.handle_plugin_process_output(
322 JsCallbackId::from(process_id),
323 stdout,
324 stderr,
325 exit_code,
326 );
327 }
328 PluginAsyncMessage::DelayComplete { callback_id } => {
329 self.plugin_manager.read().unwrap().resolve_callback(
330 JsCallbackId::from(callback_id),
331 "null".to_string(),
332 );
333 }
334 PluginAsyncMessage::ProcessStdout { process_id, data } => {
335 self.plugin_manager.read().unwrap().run_hook(
336 "onProcessStdout",
337 crate::services::plugins::hooks::HookArgs::ProcessOutput {
338 process_id,
339 data,
340 },
341 );
342 }
343 PluginAsyncMessage::ProcessStderr { process_id, data } => {
344 self.plugin_manager.read().unwrap().run_hook(
345 "onProcessStderr",
346 crate::services::plugins::hooks::HookArgs::ProcessOutput {
347 process_id,
348 data,
349 },
350 );
351 }
352 PluginAsyncMessage::ProcessExit {
353 process_id,
354 callback_id,
355 exit_code,
356 } => {
357 self.background_process_handles.remove(&process_id);
358 let result = fresh_core::api::BackgroundProcessResult {
359 process_id,
360 exit_code,
361 };
362 self.plugin_manager.read().unwrap().resolve_callback(
363 JsCallbackId::from(callback_id),
364 serde_json::to_string(&result).unwrap(),
365 );
366 }
367 PluginAsyncMessage::LspResponse {
368 language: _,
369 request_id,
370 result,
371 } => {
372 self.handle_plugin_lsp_response(request_id, result);
373 }
374 PluginAsyncMessage::PluginResponse(response) => {
375 self.handle_plugin_response(response);
376 }
377 }
378 }
379 AsyncMessage::LspProgress {
380 language,
381 token,
382 value,
383 } => {
384 self.handle_lsp_progress(language, token, value);
385 }
386 AsyncMessage::LspWindowMessage {
387 language,
388 message_type,
389 message,
390 } => {
391 self.handle_lsp_window_message(language, message_type, message);
392 }
393 AsyncMessage::LspLogMessage {
394 language,
395 message_type,
396 message,
397 } => {
398 self.handle_lsp_log_message(language, message_type, message);
399 }
400 AsyncMessage::LspStatusUpdate {
401 language,
402 server_name,
403 status,
404 message: _,
405 } => {
406 self.handle_lsp_status_update(language, server_name, status);
407 }
408 AsyncMessage::FileOpenDirectoryLoaded(result) => {
409 self.handle_file_open_directory_loaded(result);
410 }
411 AsyncMessage::FileOpenShortcutsLoaded(shortcuts) => {
412 self.handle_file_open_shortcuts_loaded(shortcuts);
413 }
414 AsyncMessage::TerminalOutput { terminal_id } => {
415 // Terminal output received - check if we should auto-jump back to terminal mode
416 tracing::trace!("Terminal output received for {:?}", terminal_id);
417
418 // If viewing scrollback for this terminal and jump_to_end_on_output is enabled,
419 // automatically re-enter terminal mode
420 if self.config.terminal.jump_to_end_on_output
421 && !self.active_window().terminal_mode
422 {
423 // Check if active buffer is this terminal
424 if let Some(&active_terminal_id) = self
425 .active_window()
426 .terminal_buffers
427 .get(&self.active_buffer())
428 {
429 if active_terminal_id == terminal_id {
430 self.enter_terminal_mode();
431 }
432 }
433 }
434
435 // When in terminal mode, ensure display stays at bottom (follows new output)
436 if self.active_window().terminal_mode {
437 if let Some(handle) = self.active_window().terminal_manager.get(terminal_id)
438 {
439 if let Ok(mut state) = handle.state.lock() {
440 state.scroll_to_bottom();
441 }
442 }
443 }
444
445 // Notify plugins. Snapshot the cursor row's text so the
446 // listener can match prompt patterns without a separate
447 // readback API. The grid lock is released before
448 // `run_hook` runs to avoid holding it across plugin code.
449 let last_line = self
450 .active_window()
451 .terminal_manager
452 .get(terminal_id)
453 .and_then(|handle| handle.state.lock().ok().map(|s| s.last_visible_line()))
454 .unwrap_or_default();
455 self.plugin_manager.read().unwrap().run_hook(
456 "terminal_output",
457 crate::services::plugins::hooks::HookArgs::TerminalOutput {
458 terminal_id: terminal_id.0 as u64,
459 last_line,
460 },
461 );
462 }
463 AsyncMessage::PathChanged { handle, path, kind } => {
464 self.last_path_change_for_test = Some((handle, path.clone(), kind.as_str()));
465 self.plugin_manager.read().unwrap().run_hook(
466 "path_changed",
467 crate::services::plugins::hooks::HookArgs::PathChanged {
468 handle,
469 path: path.to_string_lossy().into_owned(),
470 kind: kind.as_str().to_owned(),
471 },
472 );
473 }
474 AsyncMessage::TerminalExited {
475 terminal_id,
476 exit_code,
477 } => {
478 tracing::info!("Terminal {:?} exited", terminal_id);
479 // Find the buffer associated with this terminal
480 if let Some((&buffer_id, _)) = self
481 .active_window()
482 .terminal_buffers
483 .iter()
484 .find(|(_, &tid)| tid == terminal_id)
485 {
486 // Exit terminal mode if this is the active buffer
487 if self.active_buffer() == buffer_id && self.active_window().terminal_mode {
488 self.active_window_mut().terminal_mode = false;
489 self.active_window_mut().key_context =
490 crate::input::keybindings::KeyContext::Normal;
491 }
492
493 // Sync terminal content to buffer (final screen state)
494 self.active_window_mut().sync_terminal_to_buffer(buffer_id);
495
496 // Append exit message to the backing file and reload
497 let exit_msg = "\n[Terminal process exited]\n";
498
499 if let Some(backing_path) = self
500 .active_window()
501 .terminal_backing_files
502 .get(&terminal_id)
503 .cloned()
504 {
505 if let Ok(mut file) = self
506 .authority
507 .filesystem
508 .open_file_for_append(&backing_path)
509 {
510 use std::io::Write;
511 if let Err(e) = file.write_all(exit_msg.as_bytes()) {
512 tracing::warn!("Failed to write terminal exit message: {}", e);
513 }
514 }
515
516 // Force reload buffer from file to pick up the exit message
517 if let Err(e) = self.revert_buffer_by_id(buffer_id, &backing_path) {
518 tracing::warn!("Failed to revert terminal buffer: {}", e);
519 }
520
521 // After revert, scroll the viewport so the just-
522 // appended exit message is visible. sync_terminal_to_buffer
523 // pinned the viewport to the start of the visible screen
524 // (so exit is pixel-identical to the last live frame); the
525 // exit message is appended *after* that pinned region,
526 // so we have to deliberately scroll past the pin to bring
527 // it on-screen. Move the cursor to the new end-of-buffer
528 // and clear the skip_ensure_visible flag the sync path
529 // armed; the next render's ensure_visible will then scroll
530 // the cursor (and the exit-message line above it) into
531 // view.
532 let new_total = self
533 .windows
534 .get(&self.active_window)
535 .and_then(|w| w.buffers.get(&buffer_id))
536 .map(|s| s.buffer.total_bytes())
537 .unwrap_or(0);
538 if let Some((mgr, view_states)) = self
539 .windows
540 .get_mut(&self.active_window)
541 .map(|w| &mut w.buffers)
542 .expect("active window present")
543 .splits_mut()
544 {
545 let active_split = mgr.active_split();
546 if let Some(view_state) = view_states.get_mut(&active_split) {
547 view_state.cursors.primary_mut().position = new_total;
548 view_state.viewport.clear_skip_ensure_visible();
549 }
550 }
551 }
552
553 // Ensure buffer remains read-only with no line numbers
554 if let Some(state) = self
555 .windows
556 .get_mut(&self.active_window)
557 .map(|w| &mut w.buffers)
558 .expect("active window present")
559 .get_mut(&buffer_id)
560 {
561 state.editing_disabled = true;
562 state.margins.configure_for_line_numbers(false);
563 state.buffer.set_modified(false);
564 }
565
566 // Remove from terminal_buffers so it's no longer treated as a terminal
567 self.active_window_mut().terminal_buffers.remove(&buffer_id);
568
569 self.set_status_message(
570 t!("terminal.exited", id = terminal_id.0).to_string(),
571 );
572 }
573 self.active_window_mut().terminal_manager.close(terminal_id);
574
575 // Notify plugins after the editor's own exit handling
576 // is complete. Orchestrator's state machine reads this
577 // to transition agents to READY (code 0) or ERRORED.
578 // `exit_code` is currently always `None` here; full
579 // wait-status capture is a follow-up commit.
580 self.plugin_manager.read().unwrap().run_hook(
581 "terminal_exit",
582 crate::services::plugins::hooks::HookArgs::TerminalExited {
583 terminal_id: terminal_id.0 as u64,
584 exit_code,
585 },
586 );
587 }
588
589 AsyncMessage::LspServerRequest {
590 language,
591 server_command,
592 method,
593 params,
594 } => {
595 self.handle_lsp_server_request(language, server_command, method, params);
596 }
597 AsyncMessage::PluginLspResponse {
598 language: _,
599 request_id,
600 result,
601 } => {
602 self.handle_plugin_lsp_response(request_id, result);
603 }
604 AsyncMessage::PluginProcessOutput {
605 process_id,
606 stdout,
607 stderr,
608 exit_code,
609 } => {
610 // Drop any host-process kill handle tied to this
611 // id. The spawn task has exited (that's what this
612 // event means) so the handle is stale; a late
613 // `KillHostProcess` from the plugin should be a
614 // silent no-op rather than a dangling send. For
615 // non-host-process spawns the key won't be in
616 // the map and the remove is a no-op.
617 self.host_process_handles.remove(&process_id);
618 self.handle_plugin_process_output(
619 fresh_core::api::JsCallbackId::from(process_id),
620 stdout,
621 stderr,
622 exit_code,
623 );
624 }
625 AsyncMessage::GrammarRegistryBuilt {
626 registry,
627 callback_ids,
628 } => {
629 tracing::info!(
630 "Background grammar build completed ({} syntaxes)",
631 registry.available_syntaxes().len()
632 );
633 // Merge user `[languages]` config into the catalog so
634 // find_by_path honours user globs/filenames/extensions.
635 // The background thread just sent the Arc through the
636 // channel, so we're the sole owner here. Assert rather
637 // than silently drop config.
638 let mut registry = registry;
639 std::sync::Arc::get_mut(&mut registry)
640 .expect("freshly-received grammar registry Arc must be uniquely owned")
641 .apply_language_config(&self.config.languages);
642 self.grammar_registry = registry;
643 // Propagate the new grammar registry to every window's
644 // resources so window-side syntax detection picks up the
645 // freshly-built grammars without waiting for a restart.
646 for w in self.windows.values_mut() {
647 w.resources.grammar_registry = self.grammar_registry.clone();
648 }
649 self.grammar_build_in_progress = false;
650
651 // Re-detect syntax for all open buffers with the full registry
652 let buffers_to_update: Vec<_> = self
653 .active_window()
654 .buffer_metadata
655 .iter()
656 .filter_map(|(id, meta)| meta.file_path().map(|p| (*id, p.to_path_buf())))
657 .collect();
658
659 for (buf_id, path) in buffers_to_update {
660 if let Some(state) = self
661 .windows
662 .get_mut(&self.active_window)
663 .map(|w| &mut w.buffers)
664 .expect("active window present")
665 .get_mut(&buf_id)
666 {
667 let first_line = state.buffer.first_line_lossy();
668 let detected =
669 crate::primitives::detected_language::DetectedLanguage::from_path(
670 &path,
671 first_line.as_deref(),
672 &self.grammar_registry,
673 &self.config.languages,
674 );
675
676 if detected.highlighter.has_highlighting()
677 || !state.highlighter.has_highlighting()
678 {
679 state.apply_language(detected);
680 }
681 }
682 }
683
684 // Resolve plugin callbacks that were waiting for this build
685 #[cfg(feature = "plugins")]
686 for cb_id in callback_ids {
687 self.plugin_manager
688 .read()
689 .unwrap()
690 .resolve_callback(cb_id, "null".to_string());
691 }
692
693 // Flush any plugin grammars that arrived during the build
694 self.flush_pending_grammars();
695 }
696 AsyncMessage::QuickOpenFilesLoaded {
697 cwd,
698 files,
699 complete,
700 } => {
701 // Update the file provider cache and refresh suggestions
702 // if Quick Open is currently showing file mode (empty prefix).
703 if let Some((provider, _)) = self.quick_open_registry.get_provider_for_input("")
704 {
705 if let Some(fp) = provider
706 .as_any()
707 .downcast_ref::<crate::input::quick_open::providers::FileProvider>(
708 ) {
709 if complete {
710 fp.set_cache(&cwd, files);
711 } else {
712 fp.set_partial_cache(&cwd, files);
713 }
714 }
715 }
716 // Refresh the Quick Open suggestions if the prompt is open
717 if let Some(prompt) = &self.active_window_mut().prompt {
718 if prompt.prompt_type == PromptType::QuickOpen {
719 let input = prompt.input.clone();
720 self.update_quick_open_suggestions(&input);
721 }
722 }
723 }
724 AsyncMessage::PluginsDirLoaded {
725 dir,
726 errors,
727 discovered_plugins,
728 } => {
729 self.handle_plugins_dir_loaded(dir, errors, discovered_plugins);
730 }
731 AsyncMessage::PluginDeclarationsReady { declarations } => {
732 self.handle_plugin_declarations_ready(declarations);
733 }
734 AsyncMessage::PluginInitScriptLoaded(outcome) => {
735 self.handle_plugin_init_script_loaded(outcome);
736 }
737 }
738 }
739
740 // Update plugin state snapshot BEFORE processing commands
741 // This ensures plugins have access to current editor state (cursor positions, etc.)
742 #[cfg(feature = "plugins")]
743 {
744 let _s = tracing::info_span!("update_plugin_state_snapshot").entered();
745 self.update_plugin_state_snapshot();
746 }
747
748 // Process TypeScript plugin commands
749 let processed_any_commands = {
750 let _s = tracing::info_span!("process_plugin_commands").entered();
751 self.process_plugin_commands()
752 };
753
754 // Re-sync snapshot after commands — commands like SetViewMode change
755 // state that plugins read via getBufferInfo(). Without this, a
756 // subsequent lines_changed callback would see stale values.
757 #[cfg(feature = "plugins")]
758 if processed_any_commands {
759 let _s = tracing::info_span!("update_plugin_state_snapshot_post").entered();
760 self.update_plugin_state_snapshot();
761 }
762
763 // Process pending plugin action completions
764 #[cfg(feature = "plugins")]
765 {
766 let _s = tracing::info_span!("process_pending_plugin_actions").entered();
767 self.process_pending_plugin_actions();
768 }
769
770 // Process pending LSP server restarts (with exponential backoff)
771 {
772 let _s = tracing::info_span!("process_pending_lsp_restarts").entered();
773 self.process_pending_lsp_restarts();
774 }
775
776 // Check and clear the plugin render request flag
777 #[cfg(feature = "plugins")]
778 let plugin_render = {
779 let render = self.plugin_render_requested;
780 self.plugin_render_requested = false;
781 render
782 };
783 #[cfg(not(feature = "plugins"))]
784 let plugin_render = false;
785
786 // Poll periodic update checker for new results
787 if let Some(ref mut checker) = self.update_checker {
788 // Poll for results but don't act on them - just cache
789 let _ = checker.poll_result();
790 }
791
792 // Poll for file changes (auto-revert) and file tree changes
793 let file_changes = {
794 let _s = tracing::info_span!("poll_file_changes").entered();
795 self.poll_file_changes()
796 };
797 let tree_changes = {
798 let _s = tracing::info_span!("poll_file_tree_changes").entered();
799 self.poll_file_tree_changes()
800 };
801
802 // Trigger render if any async messages, plugin commands were processed, or plugin requested render
803 needs_render || processed_any_commands || plugin_render || file_changes || tree_changes
804 }
805}