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        // 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}