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