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    /// Resolve the `attachRemoteAgent` promise behind `request_id` — the
17    /// session (authority + window) is fully constructed. Resolves with `null`;
18    /// the plugin only needs the success signal to close its dialog. Lives here
19    /// (not in the plugins-gated `plugin_dispatch`) because the non-plugin
20    /// `RemoteAttach*` async handlers call it; the plugin manager is a no-op
21    /// without the `plugins` feature, so this safely does nothing then.
22    pub(crate) fn resolve_remote_attach(&self, request_id: u64) {
23        self.plugin_manager.read().unwrap().resolve_callback(
24            fresh_core::api::JsCallbackId::from(request_id),
25            "null".to_string(),
26        );
27    }
28
29    /// Reject the `attachRemoteAgent` promise behind `request_id` with `error`
30    /// — the connect failed, the spec was bad / the runtime unavailable, or
31    /// window creation failed. The plugin surfaces the reason and creates no
32    /// window.
33    pub(crate) fn reject_remote_attach(&self, request_id: u64, error: String) {
34        tracing::warn!("attachRemoteAgent rejected: {error}");
35        self.plugin_manager
36            .read()
37            .unwrap()
38            .reject_callback(fresh_core::api::JsCallbackId::from(request_id), error);
39    }
40
41    /// Mark every in-flight `attachRemoteAgent` connect as cancelled, signal the
42    /// background connect thread to tear down its in-flight carrier (killing the
43    /// ssh/kubectl child), and reject the awaiting promise now. If a connect
44    /// races past cancellation its eventual `RemoteAttachReady`/`Failed` is
45    /// dropped on arrival (see `remote_attach_was_cancelled`) — so no window is
46    /// ever built. This is the host side of the New-Session dialog's Cancel.
47    pub(crate) fn cancel_remote_attaches(&mut self) {
48        let inflight: Vec<u64> = self.remote_attach_inflight.drain().collect();
49        let any = !inflight.is_empty();
50        for id in inflight {
51            self.remote_attach_cancelled.insert(id);
52            // Signal the background connect thread to abort. Its `select!` drops
53            // the in-flight connect future, which drops the ssh child (spawned
54            // kill-on-drop), so even a host that never completes the handshake
55            // leaves no orphaned process. A connect that already finished (its
56            // result still queued) ignores the signal; the late result is
57            // discarded by `remote_attach_was_cancelled`.
58            if let Some(cancel) = self.remote_attach_cancels.remove(&id) {
59                #[allow(clippy::let_underscore_must_use)]
60                let _ = cancel.send(());
61            }
62            self.reject_remote_attach(id, "cancelled".to_string());
63        }
64        // Clear the lingering "Connecting to …" status the connect set, so the
65        // status line doesn't keep claiming a connection is in progress.
66        if any {
67            self.set_status_message("Connection cancelled".to_string());
68        }
69    }
70
71    /// Consume the in-flight/cancelled tracking for `request_id` as a late
72    /// result arrives. Returns `true` if the connect was cancelled (the result
73    /// should be discarded), `false` for a normal completion (which still
74    /// clears the in-flight entry).
75    pub(crate) fn remote_attach_was_cancelled(&mut self, request_id: u64) -> bool {
76        self.remote_attach_inflight.remove(&request_id);
77        self.remote_attach_cancels.remove(&request_id);
78        self.remote_attach_cancelled.remove(&request_id)
79    }
80
81    /// Process pending async messages from the async bridge
82    ///
83    /// This should be called each frame in the main loop to handle:
84    /// - LSP diagnostics
85    /// - LSP initialization/errors
86    /// - File system changes (future)
87    /// - Git status updates
88    pub fn process_async_messages(&mut self) -> bool {
89        // Check plugin thread health - will panic if thread died due to error
90        // This ensures plugin errors surface quickly instead of causing silent hangs
91        self.plugin_manager.write().unwrap().check_thread_health();
92
93        let Some(bridge) = &self.async_bridge else {
94            return false;
95        };
96
97        // Drain editor-global async messages first (plugin runtime
98        // callbacks, file dialog, etc.), then drain each window's
99        // per-window bridge (LSP responses, terminal output, etc.).
100        // Order matters only for cosmetic message ordering on a
101        // very-busy frame; semantically the dispatcher is the same
102        // for every source.
103        let mut messages = {
104            let _s = tracing::info_span!("try_recv_all").entered();
105            bridge.try_recv_all()
106        };
107        for window in self.windows.values() {
108            messages.extend(window.bridge.try_recv_all());
109        }
110        // A render is only warranted if a message can actually change the
111        // screen. A `DelayComplete` just resolves a debounced
112        // `editor.delay()` callback in the plugin runtime; on its own it
113        // paints nothing. Any visual outcome of the resumed plugin code
114        // arrives as a follow-up plugin *command* and is caught by
115        // `process_plugin_commands`'s `has_visual_commands` check below (or
116        // on the next tick). Forcing a render for the bare completion made
117        // live_diff's per-keystroke debounce repaint the screen with no
118        // change — invisible locally, but real lag over serial (#2100).
119        let needs_render = messages.iter().any(|m| {
120            !matches!(
121                m,
122                AsyncMessage::Plugin(fresh_core::api::PluginAsyncMessage::DelayComplete { .. })
123            )
124        });
125        tracing::trace!(
126            async_message_count = messages.len(),
127            "received async messages"
128        );
129
130        for message in messages {
131            match message {
132                AsyncMessage::LspDiagnostics {
133                    uri,
134                    diagnostics,
135                    server_name,
136                } => {
137                    self.handle_lsp_diagnostics(uri, diagnostics, server_name);
138                }
139                AsyncMessage::LspInitialized {
140                    language,
141                    server_name,
142                    capabilities,
143                } => {
144                    tracing::info!(
145                        "LSP server '{}' initialized for language: {}",
146                        server_name,
147                        language
148                    );
149                    self.active_window_mut().status_message =
150                        Some(format!("LSP ({}) ready", language));
151
152                    // Store capabilities on the specific server handle
153                    let __active_id = self.active_window;
154                    if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
155                        lsp.set_server_capabilities(&language, &server_name, capabilities);
156                    }
157
158                    // Send didOpen for all open buffers of this language
159                    self.resend_did_open_for_language(&language);
160                    self.request_semantic_tokens_for_language(&language);
161                    self.request_folding_ranges_for_language(&language);
162                    // Now that capabilities are known, kick off inlay hints
163                    // and pull-diagnostics for buffers that opened before the
164                    // `initialize` handshake completed. Both paths route
165                    // through `handle_for_feature_mut`, so servers that
166                    // didn't advertise the capability are skipped.
167                    self.request_inlay_hints_for_language(&language);
168                    self.pull_diagnostics_for_language(&language);
169                }
170                AsyncMessage::LspError {
171                    language,
172                    error,
173                    stderr_log_path,
174                } => {
175                    tracing::error!("LSP error for {}: {}", language, error);
176                    self.active_window_mut().status_message =
177                        Some(format!("LSP error ({}): {}", language, error));
178
179                    // Get server command from config for the hook
180                    let server_command = self
181                        .config
182                        .lsp
183                        .get(&language)
184                        .and_then(|configs| configs.as_slice().first())
185                        .map(|c| c.command.clone())
186                        .unwrap_or_else(|| "unknown".to_string());
187
188                    // Determine error type from error message
189                    let error_type = if error.contains("not found") || error.contains("NotFound") {
190                        "not_found"
191                    } else if error.contains("permission") || error.contains("PermissionDenied") {
192                        "spawn_failed"
193                    } else if error.contains("timeout") {
194                        "timeout"
195                    } else {
196                        "spawn_failed"
197                    }
198                    .to_string();
199
200                    // Fire the LspServerError hook for plugins
201                    self.plugin_manager.read().unwrap().run_hook(
202                        "lsp_server_error",
203                        crate::services::plugins::hooks::HookArgs::LspServerError {
204                            language: language.clone(),
205                            server_command,
206                            error_type,
207                            message: error.clone(),
208                        },
209                    );
210
211                    // Open stderr log as read-only buffer if it exists and has content
212                    // Opens in background (new tab) without stealing focus
213                    if let Some(log_path) = stderr_log_path {
214                        let has_content = log_path.metadata().map(|m| m.len() > 0).unwrap_or(false);
215                        if has_content {
216                            tracing::info!("Opening LSP stderr log in background: {:?}", log_path);
217                            match self.open_file_no_focus(&log_path) {
218                                Ok(buffer_id) => {
219                                    self.active_window_mut()
220                                        .mark_buffer_read_only(buffer_id, true);
221                                    self.active_window_mut().status_message = Some(format!(
222                                        "LSP error ({}): {} - See stderr log",
223                                        language, error
224                                    ));
225                                }
226                                Err(e) => {
227                                    tracing::error!("Failed to open LSP stderr log: {}", e);
228                                }
229                            }
230                        }
231                    }
232                }
233                AsyncMessage::LspCompletion { request_id, items } => {
234                    if let Err(e) = self.handle_completion_response(request_id, items) {
235                        tracing::error!("Error handling completion response: {}", e);
236                    }
237                }
238                AsyncMessage::LspGotoDefinition {
239                    request_id,
240                    locations,
241                } => {
242                    if let Err(e) = self.handle_goto_definition_response(request_id, locations) {
243                        tracing::error!("Error handling goto definition response: {}", e);
244                    }
245                }
246                AsyncMessage::LspRename { request_id, result } => {
247                    if let Err(e) = self.handle_rename_response(request_id, result) {
248                        tracing::error!("Error handling rename response: {}", e);
249                    }
250                }
251                AsyncMessage::LspHover {
252                    request_id,
253                    contents,
254                    is_markdown,
255                    range,
256                } => {
257                    self.handle_hover_response(request_id, contents, is_markdown, range);
258                }
259                AsyncMessage::LspReferences {
260                    request_id,
261                    locations,
262                } => {
263                    if let Err(e) = self.handle_references_response(request_id, locations) {
264                        tracing::error!("Error handling references response: {}", e);
265                    }
266                }
267                AsyncMessage::LspSignatureHelp {
268                    request_id,
269                    signature_help,
270                } => {
271                    self.handle_signature_help_response(request_id, signature_help);
272                }
273                AsyncMessage::LspCodeActions {
274                    request_id,
275                    actions,
276                } => {
277                    self.handle_code_actions_response(request_id, actions);
278                }
279                AsyncMessage::LspApplyEdit { edit, label } => {
280                    tracing::info!("Applying workspace edit from server (label: {:?})", label);
281                    match self.apply_workspace_edit(edit) {
282                        Ok(n) => {
283                            if let Some(label) = label {
284                                self.set_status_message(
285                                    t!("lsp.code_action_applied", title = &label, count = n)
286                                        .to_string(),
287                                );
288                            }
289                        }
290                        Err(e) => {
291                            tracing::error!("Failed to apply workspace edit: {}", e);
292                        }
293                    }
294                }
295                AsyncMessage::LspCodeActionResolved {
296                    request_id: _,
297                    action,
298                } => match action {
299                    Ok(resolved) => {
300                        self.execute_resolved_code_action(resolved);
301                    }
302                    Err(e) => {
303                        tracing::warn!("codeAction/resolve failed: {}", e);
304                        self.set_status_message(format!("Code action resolve failed: {e}"));
305                    }
306                },
307                AsyncMessage::LspCompletionResolved {
308                    request_id: _,
309                    item,
310                } => {
311                    if let Ok(resolved) = item {
312                        self.handle_completion_resolved(resolved);
313                    }
314                }
315                AsyncMessage::LspFormatting {
316                    request_id: _,
317                    uri,
318                    edits,
319                } => {
320                    if !edits.is_empty() {
321                        if let Err(e) = self.apply_formatting_edits(&uri, edits) {
322                            tracing::error!("Failed to apply formatting: {}", e);
323                        }
324                    }
325                }
326                AsyncMessage::LspPrepareRename {
327                    request_id: _,
328                    result,
329                } => {
330                    self.handle_prepare_rename_response(result);
331                }
332                AsyncMessage::LspPulledDiagnostics {
333                    request_id: _,
334                    uri,
335                    result_id,
336                    diagnostics,
337                    unchanged,
338                } => {
339                    self.handle_lsp_pulled_diagnostics(uri, result_id, diagnostics, unchanged);
340                }
341                AsyncMessage::LspInlayHints {
342                    request_id,
343                    uri,
344                    hints,
345                } => {
346                    self.handle_lsp_inlay_hints(request_id, uri, hints);
347                }
348                AsyncMessage::LspFoldingRanges {
349                    request_id,
350                    uri,
351                    ranges,
352                } => {
353                    self.handle_lsp_folding_ranges(request_id, uri, ranges);
354                }
355                AsyncMessage::LspSemanticTokens {
356                    request_id,
357                    uri,
358                    response,
359                } => {
360                    self.handle_lsp_semantic_tokens(request_id, uri, response);
361                }
362                AsyncMessage::LspServerQuiescent { language } => {
363                    self.handle_lsp_server_quiescent(language);
364                }
365                AsyncMessage::LspDiagnosticRefresh { language } => {
366                    self.handle_lsp_diagnostic_refresh(language);
367                }
368                AsyncMessage::LspInlayHintRefresh { language } => {
369                    self.handle_lsp_inlay_hint_refresh(language);
370                }
371                AsyncMessage::LspSemanticTokensRefresh { language } => {
372                    self.handle_lsp_semantic_tokens_refresh(language);
373                }
374                AsyncMessage::LspDynamicCapabilities {
375                    language,
376                    server_name,
377                    register,
378                    registrations,
379                } => {
380                    self.handle_lsp_dynamic_capabilities(
381                        language,
382                        server_name,
383                        register,
384                        registrations,
385                    );
386                }
387                AsyncMessage::FileChanged { path } => {
388                    self.handle_async_file_changed(path);
389                }
390                AsyncMessage::GitStatusChanged { status } => {
391                    tracing::info!("Git status changed: {}", status);
392                    // TODO: Handle git status changes
393                }
394                AsyncMessage::FileExplorerInitialized { window, view } => {
395                    self.handle_file_explorer_initialized(window, view);
396                }
397                AsyncMessage::FileExplorerToggleNode(node_id) => {
398                    self.handle_file_explorer_toggle_node(node_id);
399                }
400                AsyncMessage::FileExplorerRefreshNode(node_id) => {
401                    self.handle_file_explorer_refresh_node(node_id);
402                }
403                AsyncMessage::FileExplorerExpandedToPath { window, view } => {
404                    self.handle_file_explorer_expanded_to_path(window, view);
405                }
406                AsyncMessage::Plugin(plugin_msg) => {
407                    use fresh_core::api::{JsCallbackId, PluginAsyncMessage};
408                    match plugin_msg {
409                        PluginAsyncMessage::ProcessOutput {
410                            process_id,
411                            stdout,
412                            stderr,
413                            exit_code,
414                        } => {
415                            self.handle_plugin_process_output(
416                                JsCallbackId::from(process_id),
417                                stdout,
418                                stderr,
419                                exit_code,
420                            );
421                        }
422                        PluginAsyncMessage::DelayComplete { callback_id } => {
423                            self.plugin_manager.read().unwrap().resolve_callback(
424                                JsCallbackId::from(callback_id),
425                                "null".to_string(),
426                            );
427                        }
428                        PluginAsyncMessage::ProcessStdout { process_id, data } => {
429                            self.plugin_manager.read().unwrap().run_hook(
430                                "onProcessStdout",
431                                crate::services::plugins::hooks::HookArgs::ProcessOutput {
432                                    process_id,
433                                    data,
434                                },
435                            );
436                        }
437                        PluginAsyncMessage::ProcessStderr { process_id, data } => {
438                            self.plugin_manager.read().unwrap().run_hook(
439                                "onProcessStderr",
440                                crate::services::plugins::hooks::HookArgs::ProcessOutput {
441                                    process_id,
442                                    data,
443                                },
444                            );
445                        }
446                        PluginAsyncMessage::ProcessExit {
447                            process_id,
448                            callback_id,
449                            exit_code,
450                        } => {
451                            self.background_process_handles.remove(&process_id);
452                            let result = fresh_core::api::BackgroundProcessResult {
453                                process_id,
454                                exit_code,
455                            };
456                            self.plugin_manager.read().unwrap().resolve_callback(
457                                JsCallbackId::from(callback_id),
458                                serde_json::to_string(&result).unwrap(),
459                            );
460                        }
461                        PluginAsyncMessage::LspResponse {
462                            language: _,
463                            request_id,
464                            result,
465                        } => {
466                            self.handle_plugin_lsp_response(request_id, result);
467                        }
468                        PluginAsyncMessage::PluginResponse(response) => {
469                            self.handle_plugin_response(response);
470                        }
471                    }
472                }
473                AsyncMessage::LspProgress {
474                    language,
475                    token,
476                    value,
477                } => {
478                    self.handle_lsp_progress(language, token, value);
479                }
480                AsyncMessage::LspWindowMessage {
481                    language,
482                    message_type,
483                    message,
484                } => {
485                    self.handle_lsp_window_message(language, message_type, message);
486                }
487                AsyncMessage::LspLogMessage {
488                    language,
489                    message_type,
490                    message,
491                } => {
492                    self.handle_lsp_log_message(language, message_type, message);
493                }
494                AsyncMessage::LspStatusUpdate {
495                    language,
496                    server_name,
497                    status,
498                    message: _,
499                } => {
500                    self.handle_lsp_status_update(language, server_name, status);
501                }
502                AsyncMessage::FileOpenDirectoryLoaded(result) => {
503                    self.handle_file_open_directory_loaded(result);
504                }
505                AsyncMessage::FileOpenShortcutsLoaded(shortcuts) => {
506                    self.handle_file_open_shortcuts_loaded(shortcuts);
507                }
508                AsyncMessage::ClipboardPasteResult { request_id, text } => {
509                    self.resolve_pending_paste(request_id, text);
510                }
511                AsyncMessage::TerminalOutput { terminal } => {
512                    // The message carries its owning window: terminal ids
513                    // collide across windows, so we trust the tag rather
514                    // than scanning windows for a matching id (which would
515                    // attribute output to the wrong session).
516                    let terminal_id = terminal.terminal;
517                    let owner = terminal.window;
518                    // Terminal output received - check if we should auto-jump back to terminal mode
519                    tracing::trace!("Terminal output received for {}", terminal);
520
521                    // If viewing scrollback for this terminal and jump_to_end_on_output is enabled,
522                    // automatically re-enter terminal mode
523                    if self.config.terminal.jump_to_end_on_output
524                        && !self.active_window().terminal_mode
525                    {
526                        // Check if active buffer is this terminal
527                        if let Some(&active_terminal_id) = self
528                            .active_window()
529                            .terminal_buffers
530                            .get(&self.active_buffer())
531                        {
532                            if active_terminal_id == terminal_id {
533                                self.enter_terminal_mode();
534                            }
535                        }
536                    }
537
538                    // When in terminal mode, ensure display stays at bottom (follows new output)
539                    if self.active_window().terminal_mode {
540                        if let Some(handle) = self.active_window().terminal_manager.get(terminal_id)
541                        {
542                            if let Ok(mut state) = handle.state.lock() {
543                                state.scroll_to_bottom();
544                            }
545                        }
546                    }
547
548                    // Notify plugins, attributing output to the owning
549                    // *session* even when it's a background one (terminals
550                    // live in their own window's manager, not the active
551                    // window's). Snapshot the cursor row's text from that
552                    // same window so prompt detection works off-focus too.
553                    // The grid lock is released before `run_hook` runs to
554                    // avoid holding it across plugin code.
555                    let last_line = self
556                        .windows
557                        .get(&owner)
558                        .and_then(|w| w.terminal_manager.get(terminal_id))
559                        .and_then(|handle| handle.state.lock().ok().map(|s| s.last_visible_line()))
560                        .unwrap_or_default();
561                    self.plugin_manager.read().unwrap().run_hook(
562                        "terminal_output",
563                        crate::services::plugins::hooks::HookArgs::TerminalOutput {
564                            terminal_id: terminal_id.0 as u64,
565                            window_id: owner.0,
566                            last_line,
567                        },
568                    );
569                }
570                AsyncMessage::PathChanged { handle, path, kind } => {
571                    self.last_path_change_for_test = Some((handle, path.clone(), kind.as_str()));
572                    self.plugin_manager.read().unwrap().run_hook(
573                        "path_changed",
574                        crate::services::plugins::hooks::HookArgs::PathChanged {
575                            handle,
576                            path: path.to_string_lossy().into_owned(),
577                            kind: kind.as_str().to_owned(),
578                        },
579                    );
580                }
581                AsyncMessage::TerminalExited {
582                    terminal,
583                    exit_code,
584                } => {
585                    // The message is tagged with its owning window, so the
586                    // plugin hook is attributed correctly even for a
587                    // background session's terminal.
588                    let terminal_id = terminal.terminal;
589                    let exited_window_id = terminal.window;
590                    tracing::info!("Terminal {} exited", terminal);
591                    // Find the buffer associated with this terminal
592                    if let Some((&buffer_id, _)) = self
593                        .active_window()
594                        .terminal_buffers
595                        .iter()
596                        .find(|(_, &tid)| tid == terminal_id)
597                    {
598                        // Exit terminal mode if this is the active buffer
599                        if self.active_buffer() == buffer_id && self.active_window().terminal_mode {
600                            self.active_window_mut().terminal_mode = false;
601                            self.active_window_mut().key_context =
602                                crate::input::keybindings::KeyContext::Normal;
603                        }
604
605                        // Sync terminal content to buffer (final screen state)
606                        self.active_window_mut().sync_terminal_to_buffer(buffer_id);
607
608                        // Append exit message to the backing file and reload
609                        let exit_msg = "\n[Terminal process exited]\n";
610
611                        if let Some(backing_path) = self
612                            .active_window()
613                            .terminal_backing_files
614                            .get(&terminal_id)
615                            .cloned()
616                        {
617                            if let Ok(mut file) = self
618                                .authority()
619                                .filesystem
620                                .open_file_for_append(&backing_path)
621                            {
622                                use std::io::Write;
623                                if let Err(e) = file.write_all(exit_msg.as_bytes()) {
624                                    tracing::warn!("Failed to write terminal exit message: {}", e);
625                                }
626                            }
627
628                            // Force reload buffer from file to pick up the exit message
629                            if let Err(e) = self.revert_buffer_by_id(buffer_id, &backing_path) {
630                                tracing::warn!("Failed to revert terminal buffer: {}", e);
631                            }
632
633                            // After revert, scroll the viewport so the just-
634                            // appended exit message is visible. sync_terminal_to_buffer
635                            // pinned the viewport to the start of the visible screen
636                            // (so exit is pixel-identical to the last live frame); the
637                            // exit message is appended *after* that pinned region,
638                            // so we have to deliberately scroll past the pin to bring
639                            // it on-screen. Move the cursor to the new end-of-buffer
640                            // and clear the skip_ensure_visible flag the sync path
641                            // armed; the next render's ensure_visible will then scroll
642                            // the cursor (and the exit-message line above it) into
643                            // view.
644                            let new_total = self
645                                .windows
646                                .get(&self.active_window)
647                                .and_then(|w| w.buffers.get(&buffer_id))
648                                .map(|s| s.buffer.total_bytes())
649                                .unwrap_or(0);
650                            if let Some((mgr, view_states)) = self
651                                .windows
652                                .get_mut(&self.active_window)
653                                .map(|w| &mut w.buffers)
654                                .expect("active window present")
655                                .splits_mut()
656                            {
657                                let active_split = mgr.active_split();
658                                if let Some(view_state) = view_states.get_mut(&active_split) {
659                                    view_state.cursors.primary_mut().position = new_total;
660                                    view_state.viewport.clear_skip_ensure_visible();
661                                }
662                            }
663                        }
664
665                        // Ensure buffer remains read-only with no line numbers
666                        if let Some(state) = self
667                            .windows
668                            .get_mut(&self.active_window)
669                            .map(|w| &mut w.buffers)
670                            .expect("active window present")
671                            .get_mut(&buffer_id)
672                        {
673                            state.editing_disabled = true;
674                            state.margins.configure_for_line_numbers(false);
675                            state.buffer.set_modified(false);
676                        }
677
678                        // Remove from terminal_buffers so it's no longer treated as a terminal
679                        self.active_window_mut().terminal_buffers.remove(&buffer_id);
680
681                        self.set_status_message(
682                            t!("terminal.exited", id = terminal_id.0).to_string(),
683                        );
684                    }
685                    self.active_window_mut().terminal_manager.close(terminal_id);
686
687                    // Notify plugins after the editor's own exit handling
688                    // is complete. Orchestrator's state machine reads this
689                    // to transition agents to READY (code 0) or ERRORED.
690                    // `exit_code` is currently always `None` here; full
691                    // wait-status capture is a follow-up commit.
692                    self.plugin_manager.read().unwrap().run_hook(
693                        "terminal_exit",
694                        crate::services::plugins::hooks::HookArgs::TerminalExited {
695                            terminal_id: terminal_id.0 as u64,
696                            window_id: exited_window_id.0,
697                            exit_code,
698                        },
699                    );
700                }
701
702                AsyncMessage::LspServerRequest {
703                    language,
704                    server_command,
705                    method,
706                    params,
707                } => {
708                    self.handle_lsp_server_request(language, server_command, method, params);
709                }
710                AsyncMessage::PluginLspResponse {
711                    language: _,
712                    request_id,
713                    result,
714                } => {
715                    self.handle_plugin_lsp_response(request_id, result);
716                }
717                AsyncMessage::RemoteAttachReady(ready) => {
718                    // The background connect succeeded. Install per `mode`:
719                    // Restart rebuilds the whole editor around the backend
720                    // (global), Window spawns a born-attached session beside
721                    // the existing ones.
722                    let crate::services::async_bridge::RemoteAttachReady {
723                        authority,
724                        keepalive,
725                        working_dir,
726                        mode,
727                        spec,
728                        request_id,
729                    } = ready;
730                    // If the plugin cancelled this connect while it was
731                    // in flight (the New-Session dialog's Cancel), the result
732                    // arrives too late to matter: drop the authority and its
733                    // keepalive here so the carrier is torn down and no window
734                    // is ever built. The reject was already delivered at cancel
735                    // time, so there's nothing left to resolve.
736                    if self.remote_attach_was_cancelled(request_id) {
737                        tracing::info!(
738                            "Remote attach for request {} arrived after cancellation; discarding",
739                            request_id
740                        );
741                        drop(keepalive);
742                        drop(authority);
743                        continue;
744                    }
745                    // Re-root at the pod's workspace (or its home if the plugin
746                    // didn't supply one) — never the stale local path. The
747                    // filesystem call is safe here: `process_async_messages`
748                    // runs on the main loop, not inside a runtime.
749                    let root = working_dir
750                        .or_else(|| authority.filesystem.home_dir().ok())
751                        .unwrap_or_else(|| std::path::PathBuf::from("/"));
752                    match mode {
753                        crate::services::async_bridge::RemoteAttachMode::Restart => {
754                            tracing::info!(
755                                "Remote attach connected ({}); installing authority (restart), rooting at {}",
756                                authority.display_label,
757                                root.display()
758                            );
759                            // Resolve before the restart tears the plugin
760                            // runtime down, so the awaiting caller observes
761                            // success rather than a vanished promise.
762                            self.resolve_remote_attach(request_id);
763                            // Record the reconnect spec on the (re-rooted)
764                            // active session before the restart so it persists
765                            // and the rebuilt editor restores this backend.
766                            self.active_window_mut().authority_spec = spec;
767                            self.install_authority_with_keepalive(authority, keepalive, root);
768                        }
769                        crate::services::async_bridge::RemoteAttachMode::Window {
770                            label,
771                            command,
772                        } => {
773                            tracing::info!(
774                                "Remote attach connected ({}); opening born-attached window at {}",
775                                authority.display_label,
776                                root.display()
777                            );
778                            // The session is only "ready" once the window
779                            // exists. Resolve on success; on a window-creation
780                            // failure reject so the plugin keeps its dialog
781                            // open with the reason and no half-built window.
782                            match self.create_remote_session_window(
783                                authority, keepalive, root, label, command, spec,
784                            ) {
785                                Ok(_) => self.resolve_remote_attach(request_id),
786                                Err(e) => self.reject_remote_attach(request_id, e),
787                            }
788                        }
789                        crate::services::async_bridge::RemoteAttachMode::Reconnect {
790                            window_id,
791                        } => {
792                            // A dormant session the user switched to finished
793                            // reconnecting: re-point *that window's* authority at
794                            // the live backend and park the keepalive so the
795                            // connection survives. No new window, no restart, no
796                            // re-root (the window keeps its own root). The spec is
797                            // already on the window from restore.
798                            if self.windows.contains_key(&window_id) {
799                                tracing::info!(
800                                    "Reconnected dormant session {window_id} ({})",
801                                    authority.display_label
802                                );
803                                self.set_session_authority(window_id, authority);
804                                self.session_keepalives.insert(window_id, keepalive);
805                                self.set_status_message(format!(
806                                    "Reconnected: {}",
807                                    self.windows
808                                        .get(&window_id)
809                                        .map(|w| w.label.clone())
810                                        .unwrap_or_default()
811                                ));
812                            } else {
813                                // The window was closed while the connect was in
814                                // flight — drop the backend we just built.
815                                drop(authority);
816                                drop(keepalive);
817                            }
818                        }
819                    }
820                }
821                AsyncMessage::RemoteAttachFailed { error, request_id } => {
822                    // A cancelled connect was already rejected at cancel time;
823                    // swallow the late failure rather than rejecting twice.
824                    if self.remote_attach_was_cancelled(request_id) {
825                        tracing::info!(
826                            "Remote attach for request {} failed after cancellation; discarding",
827                            request_id
828                        );
829                        continue;
830                    }
831                    tracing::warn!("Remote attach failed: {}", error);
832                    self.reject_remote_attach(request_id, error);
833                }
834                AsyncMessage::PluginProcessOutput {
835                    process_id,
836                    stdout,
837                    stderr,
838                    exit_code,
839                } => {
840                    // Drop any host-process kill handle tied to this
841                    // id. The spawn task has exited (that's what this
842                    // event means) so the handle is stale; a late
843                    // `KillHostProcess` from the plugin should be a
844                    // silent no-op rather than a dangling send. For
845                    // non-host-process spawns the key won't be in
846                    // the map and the remove is a no-op.
847                    self.host_process_handles.remove(&process_id);
848                    self.handle_plugin_process_output(
849                        fresh_core::api::JsCallbackId::from(process_id),
850                        stdout,
851                        stderr,
852                        exit_code,
853                    );
854                }
855                AsyncMessage::GrammarRegistryBuilt {
856                    registry,
857                    callback_ids,
858                } => {
859                    tracing::info!(
860                        "Background grammar build completed ({} syntaxes)",
861                        registry.available_syntaxes().len()
862                    );
863                    // Merge user `[languages]` config into the catalog so
864                    // find_by_path honours user globs/filenames/extensions.
865                    // The background thread just sent the Arc through the
866                    // channel, so we're the sole owner here. Assert rather
867                    // than silently drop config.
868                    let mut registry = registry;
869                    std::sync::Arc::get_mut(&mut registry)
870                        .expect("freshly-received grammar registry Arc must be uniquely owned")
871                        .apply_language_config(&self.config.languages);
872                    crate::config::reload_indent_overrides(&self.config.languages);
873                    self.grammar_registry = registry;
874                    // Propagate the new grammar registry to every window's
875                    // resources so window-side syntax detection picks up the
876                    // freshly-built grammars without waiting for a restart.
877                    for w in self.windows.values_mut() {
878                        w.resources.grammar_registry = self.grammar_registry.clone();
879                    }
880                    self.grammar_build_in_progress = false;
881
882                    // Re-detect syntax for all open buffers with the full registry
883                    let buffers_to_update: Vec<_> = self
884                        .active_window()
885                        .buffer_metadata
886                        .iter()
887                        .filter_map(|(id, meta)| meta.file_path().map(|p| (*id, p.to_path_buf())))
888                        .collect();
889
890                    for (buf_id, path) in buffers_to_update {
891                        if let Some(state) = self
892                            .windows
893                            .get_mut(&self.active_window)
894                            .map(|w| &mut w.buffers)
895                            .expect("active window present")
896                            .get_mut(&buf_id)
897                        {
898                            let first_line = state.buffer.first_line_lossy();
899                            let detected =
900                                crate::primitives::detected_language::DetectedLanguage::from_path(
901                                    &path,
902                                    first_line.as_deref(),
903                                    &self.grammar_registry,
904                                    &self.config.languages,
905                                );
906
907                            if detected.highlighter.has_highlighting()
908                                || !state.highlighter.has_highlighting()
909                            {
910                                state.apply_language(detected);
911                            }
912                        }
913                    }
914
915                    // Resolve plugin callbacks that were waiting for this build
916                    #[cfg(feature = "plugins")]
917                    for cb_id in callback_ids {
918                        self.plugin_manager
919                            .read()
920                            .unwrap()
921                            .resolve_callback(cb_id, "null".to_string());
922                    }
923
924                    // Flush any plugin grammars that arrived during the build
925                    self.flush_pending_grammars();
926                }
927                AsyncMessage::QuickOpenFilesLoaded {
928                    cwd,
929                    files,
930                    complete,
931                } => {
932                    // Update the file provider cache and refresh suggestions
933                    // if Quick Open is currently showing file mode (empty prefix).
934                    if let Some((provider, _)) = self.quick_open_registry.get_provider_for_input("")
935                    {
936                        if let Some(fp) = provider
937                            .as_any()
938                            .downcast_ref::<crate::input::quick_open::providers::FileProvider>(
939                        ) {
940                            if complete {
941                                fp.set_cache(&cwd, files);
942                            } else {
943                                fp.set_partial_cache(&cwd, files);
944                            }
945                        }
946                    }
947                    // Refresh the Quick Open suggestions if the prompt is open
948                    if let Some(prompt) = &self.active_window_mut().prompt {
949                        if prompt.prompt_type == PromptType::QuickOpen {
950                            let input = prompt.input.clone();
951                            self.update_quick_open_suggestions(&input);
952                        }
953                    }
954                }
955                AsyncMessage::PluginsDirLoaded {
956                    dir,
957                    errors,
958                    discovered_plugins,
959                } => {
960                    self.handle_plugins_dir_loaded(dir, errors, discovered_plugins);
961                }
962                AsyncMessage::PluginDeclarationsReady { declarations } => {
963                    self.handle_plugin_declarations_ready(declarations);
964                }
965                AsyncMessage::PluginInitScriptLoaded(outcome) => {
966                    self.handle_plugin_init_script_loaded(outcome);
967                }
968            }
969        }
970
971        // Update plugin state snapshot BEFORE processing commands
972        // This ensures plugins have access to current editor state (cursor positions, etc.)
973        #[cfg(feature = "plugins")]
974        {
975            let _s = tracing::info_span!("update_plugin_state_snapshot").entered();
976            self.update_plugin_state_snapshot();
977        }
978
979        // Process TypeScript plugin commands
980        #[cfg(not(feature = "plugins"))]
981        let processed_any_commands = false;
982        #[cfg(feature = "plugins")]
983        let processed_any_commands = {
984            let _s = tracing::info_span!("process_plugin_commands").entered();
985            self.process_plugin_commands()
986        };
987
988        // Re-sync snapshot after commands — commands like SetViewMode change
989        // state that plugins read via getBufferInfo().  Without this, a
990        // subsequent lines_changed callback would see stale values.
991        #[cfg(feature = "plugins")]
992        if processed_any_commands {
993            let _s = tracing::info_span!("update_plugin_state_snapshot_post").entered();
994            self.update_plugin_state_snapshot();
995        }
996
997        // Process pending plugin action completions
998        #[cfg(feature = "plugins")]
999        {
1000            let _s = tracing::info_span!("process_pending_plugin_actions").entered();
1001            self.process_pending_plugin_actions();
1002        }
1003
1004        // Process pending LSP server restarts (with exponential backoff)
1005        {
1006            let _s = tracing::info_span!("process_pending_lsp_restarts").entered();
1007            self.process_pending_lsp_restarts();
1008        }
1009
1010        // Check and clear the plugin render request flag
1011        #[cfg(feature = "plugins")]
1012        let plugin_render = {
1013            let render = self.plugin_render_requested;
1014            self.plugin_render_requested = false;
1015            render
1016        };
1017        #[cfg(not(feature = "plugins"))]
1018        let plugin_render = false;
1019
1020        // Poll periodic update checker for new results
1021        if let Some(ref mut checker) = self.update_checker {
1022            // Poll for results but don't act on them - just cache
1023            let _ = checker.poll_result();
1024        }
1025
1026        // Poll for file changes (auto-revert) and file tree changes
1027        let file_changes = {
1028            let _s = tracing::info_span!("poll_file_changes").entered();
1029            self.poll_file_changes()
1030        };
1031        let tree_changes = {
1032            let _s = tracing::info_span!("poll_file_tree_changes").entered();
1033            self.poll_file_tree_changes()
1034        };
1035
1036        // Trigger render if any async messages, plugin commands were processed, or plugin requested render
1037        needs_render || processed_any_commands || plugin_render || file_changes || tree_changes
1038    }
1039}