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