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