Skip to main content

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}