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}