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