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