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
522 // Ensure buffer remains read-only with no line numbers
523 if let Some(state) = self
524 .windows
525 .get_mut(&self.active_window)
526 .map(|w| &mut w.buffers)
527 .expect("active window present")
528 .get_mut(&buffer_id)
529 {
530 state.editing_disabled = true;
531 state.margins.configure_for_line_numbers(false);
532 state.buffer.set_modified(false);
533 }
534
535 // Remove from terminal_buffers so it's no longer treated as a terminal
536 self.active_window_mut().terminal_buffers.remove(&buffer_id);
537
538 self.set_status_message(
539 t!("terminal.exited", id = terminal_id.0).to_string(),
540 );
541 }
542 self.active_window_mut().terminal_manager.close(terminal_id);
543
544 // Notify plugins after the editor's own exit handling
545 // is complete. Orchestrator's state machine reads this
546 // to transition agents to READY (code 0) or ERRORED.
547 // `exit_code` is currently always `None` here; full
548 // wait-status capture is a follow-up commit.
549 self.plugin_manager.read().unwrap().run_hook(
550 "terminal_exit",
551 crate::services::plugins::hooks::HookArgs::TerminalExited {
552 terminal_id: terminal_id.0 as u64,
553 exit_code,
554 },
555 );
556 }
557
558 AsyncMessage::LspServerRequest {
559 language,
560 server_command,
561 method,
562 params,
563 } => {
564 self.handle_lsp_server_request(language, server_command, method, params);
565 }
566 AsyncMessage::PluginLspResponse {
567 language: _,
568 request_id,
569 result,
570 } => {
571 self.handle_plugin_lsp_response(request_id, result);
572 }
573 AsyncMessage::PluginProcessOutput {
574 process_id,
575 stdout,
576 stderr,
577 exit_code,
578 } => {
579 // Drop any host-process kill handle tied to this
580 // id. The spawn task has exited (that's what this
581 // event means) so the handle is stale; a late
582 // `KillHostProcess` from the plugin should be a
583 // silent no-op rather than a dangling send. For
584 // non-host-process spawns the key won't be in
585 // the map and the remove is a no-op.
586 self.host_process_handles.remove(&process_id);
587 self.handle_plugin_process_output(
588 fresh_core::api::JsCallbackId::from(process_id),
589 stdout,
590 stderr,
591 exit_code,
592 );
593 }
594 AsyncMessage::GrammarRegistryBuilt {
595 registry,
596 callback_ids,
597 } => {
598 tracing::info!(
599 "Background grammar build completed ({} syntaxes)",
600 registry.available_syntaxes().len()
601 );
602 // Merge user `[languages]` config into the catalog so
603 // find_by_path honours user globs/filenames/extensions.
604 // The background thread just sent the Arc through the
605 // channel, so we're the sole owner here. Assert rather
606 // than silently drop config.
607 let mut registry = registry;
608 std::sync::Arc::get_mut(&mut registry)
609 .expect("freshly-received grammar registry Arc must be uniquely owned")
610 .apply_language_config(&self.config.languages);
611 self.grammar_registry = registry;
612 // Propagate the new grammar registry to every window's
613 // resources so window-side syntax detection picks up the
614 // freshly-built grammars without waiting for a restart.
615 for w in self.windows.values_mut() {
616 w.resources.grammar_registry = self.grammar_registry.clone();
617 }
618 self.grammar_build_in_progress = false;
619
620 // Re-detect syntax for all open buffers with the full registry
621 let buffers_to_update: Vec<_> = self
622 .active_window()
623 .buffer_metadata
624 .iter()
625 .filter_map(|(id, meta)| meta.file_path().map(|p| (*id, p.to_path_buf())))
626 .collect();
627
628 for (buf_id, path) in buffers_to_update {
629 if let Some(state) = self
630 .windows
631 .get_mut(&self.active_window)
632 .map(|w| &mut w.buffers)
633 .expect("active window present")
634 .get_mut(&buf_id)
635 {
636 let first_line = state.buffer.first_line_lossy();
637 let detected =
638 crate::primitives::detected_language::DetectedLanguage::from_path(
639 &path,
640 first_line.as_deref(),
641 &self.grammar_registry,
642 &self.config.languages,
643 );
644
645 if detected.highlighter.has_highlighting()
646 || !state.highlighter.has_highlighting()
647 {
648 state.apply_language(detected);
649 }
650 }
651 }
652
653 // Resolve plugin callbacks that were waiting for this build
654 #[cfg(feature = "plugins")]
655 for cb_id in callback_ids {
656 self.plugin_manager
657 .read()
658 .unwrap()
659 .resolve_callback(cb_id, "null".to_string());
660 }
661
662 // Flush any plugin grammars that arrived during the build
663 self.flush_pending_grammars();
664 }
665 AsyncMessage::QuickOpenFilesLoaded { files, complete } => {
666 // Update the file provider cache and refresh suggestions
667 // if Quick Open is currently showing file mode (empty prefix).
668 if let Some((provider, _)) = self.quick_open_registry.get_provider_for_input("")
669 {
670 if let Some(fp) = provider
671 .as_any()
672 .downcast_ref::<crate::input::quick_open::providers::FileProvider>(
673 ) {
674 if complete {
675 fp.set_cache(files);
676 } else {
677 fp.set_partial_cache(files);
678 }
679 }
680 }
681 // Refresh the Quick Open suggestions if the prompt is open
682 if let Some(prompt) = &self.active_window_mut().prompt {
683 if prompt.prompt_type == PromptType::QuickOpen {
684 let input = prompt.input.clone();
685 self.update_quick_open_suggestions(&input);
686 }
687 }
688 }
689 AsyncMessage::PluginsDirLoaded {
690 dir,
691 errors,
692 discovered_plugins,
693 } => {
694 self.handle_plugins_dir_loaded(dir, errors, discovered_plugins);
695 }
696 AsyncMessage::PluginDeclarationsReady { declarations } => {
697 self.handle_plugin_declarations_ready(declarations);
698 }
699 AsyncMessage::PluginInitScriptLoaded(outcome) => {
700 self.handle_plugin_init_script_loaded(outcome);
701 }
702 }
703 }
704
705 // Update plugin state snapshot BEFORE processing commands
706 // This ensures plugins have access to current editor state (cursor positions, etc.)
707 #[cfg(feature = "plugins")]
708 {
709 let _s = tracing::info_span!("update_plugin_state_snapshot").entered();
710 self.update_plugin_state_snapshot();
711 }
712
713 // Process TypeScript plugin commands
714 let processed_any_commands = {
715 let _s = tracing::info_span!("process_plugin_commands").entered();
716 self.process_plugin_commands()
717 };
718
719 // Re-sync snapshot after commands — commands like SetViewMode change
720 // state that plugins read via getBufferInfo(). Without this, a
721 // subsequent lines_changed callback would see stale values.
722 #[cfg(feature = "plugins")]
723 if processed_any_commands {
724 let _s = tracing::info_span!("update_plugin_state_snapshot_post").entered();
725 self.update_plugin_state_snapshot();
726 }
727
728 // Process pending plugin action completions
729 #[cfg(feature = "plugins")]
730 {
731 let _s = tracing::info_span!("process_pending_plugin_actions").entered();
732 self.process_pending_plugin_actions();
733 }
734
735 // Process pending LSP server restarts (with exponential backoff)
736 {
737 let _s = tracing::info_span!("process_pending_lsp_restarts").entered();
738 self.process_pending_lsp_restarts();
739 }
740
741 // Check and clear the plugin render request flag
742 #[cfg(feature = "plugins")]
743 let plugin_render = {
744 let render = self.plugin_render_requested;
745 self.plugin_render_requested = false;
746 render
747 };
748 #[cfg(not(feature = "plugins"))]
749 let plugin_render = false;
750
751 // Poll periodic update checker for new results
752 if let Some(ref mut checker) = self.update_checker {
753 // Poll for results but don't act on them - just cache
754 let _ = checker.poll_result();
755 }
756
757 // Poll for file changes (auto-revert) and file tree changes
758 let file_changes = {
759 let _s = tracing::info_span!("poll_file_changes").entered();
760 self.poll_file_changes()
761 };
762 let tree_changes = {
763 let _s = tracing::info_span!("poll_file_tree_changes").entered();
764 self.poll_file_tree_changes()
765 };
766
767 // Trigger render if any async messages, plugin commands were processed, or plugin requested render
768 needs_render || processed_any_commands || plugin_render || file_changes || tree_changes
769 }
770}