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. The
6//! `match` is a thin dispatch table: every arm forwards to a `handle_*`
7//! method on `Editor` that owns the actual logic for that variant.
8
9use rust_i18n::t;
10
11use crate::services::async_bridge::AsyncMessage;
12use crate::view::prompt::PromptType;
13
14use super::Editor;
15
16impl Editor {
17 /// Resolve the `attachRemoteAgent` promise behind `request_id` — the
18 /// session (authority + window) is fully constructed. Resolves with `null`;
19 /// the plugin only needs the success signal to close its dialog. Lives here
20 /// (not in the plugins-gated `plugin_dispatch`) because the non-plugin
21 /// `RemoteAttach*` async handlers call it; the plugin manager is a no-op
22 /// without the `plugins` feature, so this safely does nothing then.
23 pub(crate) fn resolve_remote_attach(&self, request_id: u64) {
24 self.plugin_manager.read().unwrap().resolve_callback(
25 fresh_core::api::JsCallbackId::from(request_id),
26 "null".to_string(),
27 );
28 }
29
30 /// Reject the `attachRemoteAgent` promise behind `request_id` with `error`
31 /// — the connect failed, the spec was bad / the runtime unavailable, or
32 /// window creation failed. The plugin surfaces the reason and creates no
33 /// window.
34 pub(crate) fn reject_remote_attach(&self, request_id: u64, error: String) {
35 tracing::warn!("attachRemoteAgent rejected: {error}");
36 self.plugin_manager
37 .read()
38 .unwrap()
39 .reject_callback(fresh_core::api::JsCallbackId::from(request_id), error);
40 }
41
42 /// Mark every in-flight `attachRemoteAgent` connect as cancelled, signal the
43 /// background connect thread to tear down its in-flight carrier (killing the
44 /// ssh/kubectl child), and reject the awaiting promise now. If a connect
45 /// races past cancellation its eventual `RemoteAttachReady`/`Failed` is
46 /// dropped on arrival (see `remote_attach_was_cancelled`) — so no window is
47 /// ever built. This is the host side of the New-Session dialog's Cancel.
48 pub(crate) fn cancel_remote_attaches(&mut self) {
49 let inflight: Vec<u64> = self.remote_attach_inflight.drain().collect();
50 let any = !inflight.is_empty();
51 for id in inflight {
52 self.remote_attach_cancelled.insert(id);
53 // Signal the background connect thread to abort. Its `select!` drops
54 // the in-flight connect future, which drops the ssh child (spawned
55 // kill-on-drop), so even a host that never completes the handshake
56 // leaves no orphaned process. A connect that already finished (its
57 // result still queued) ignores the signal; the late result is
58 // discarded by `remote_attach_was_cancelled`.
59 if let Some(cancel) = self.remote_attach_cancels.remove(&id) {
60 #[allow(clippy::let_underscore_must_use)]
61 let _ = cancel.send(());
62 }
63 self.reject_remote_attach(id, "cancelled".to_string());
64 }
65 // Clear the lingering "Connecting to …" status the connect set, so the
66 // status line doesn't keep claiming a connection is in progress.
67 if any {
68 self.set_status_message("Connection cancelled".to_string());
69 }
70 }
71
72 /// Consume the in-flight/cancelled tracking for `request_id` as a late
73 /// result arrives. Returns `true` if the connect was cancelled (the result
74 /// should be discarded), `false` for a normal completion (which still
75 /// clears the in-flight entry).
76 pub(crate) fn remote_attach_was_cancelled(&mut self, request_id: u64) -> bool {
77 self.remote_attach_inflight.remove(&request_id);
78 self.remote_attach_cancels.remove(&request_id);
79 self.remote_attach_cancelled.remove(&request_id)
80 }
81
82 /// Process pending async messages from the async bridge
83 ///
84 /// This should be called each frame in the main loop to handle:
85 /// - LSP diagnostics
86 /// - LSP initialization/errors
87 /// - File system changes (future)
88 /// - Git status updates
89 pub fn process_async_messages(&mut self) -> bool {
90 // Check plugin thread health - will panic if thread died due to error
91 // This ensures plugin errors surface quickly instead of causing silent hangs
92 self.plugin_manager.write().unwrap().check_thread_health();
93
94 // Lazily wire an event-driven reconnect forwarder for each remote
95 // window (idempotent; cheap when already wired). This replaces the old
96 // per-frame connection-state poll: the forwarder awaits the channel's
97 // reconnect notification and posts `RemoteReconnected`.
98 self.ensure_remote_reconnect_forwarders();
99
100 let Some(bridge) = &self.async_bridge else {
101 return false;
102 };
103
104 // Drain editor-global async messages first (plugin runtime
105 // callbacks, file dialog, etc.), then drain each window's
106 // per-window bridge (LSP responses, terminal output, etc.).
107 // Order matters only for cosmetic message ordering on a
108 // very-busy frame; semantically the dispatcher is the same
109 // for every source.
110 let mut messages = {
111 let _s = tracing::info_span!("try_recv_all").entered();
112 bridge.try_recv_all()
113 };
114 for window in self.windows.values() {
115 messages.extend(window.bridge.try_recv_all());
116 }
117 // A render is only warranted if a message can actually change the
118 // screen. A `DelayComplete` just resolves a debounced
119 // `editor.delay()` callback in the plugin runtime; on its own it
120 // paints nothing. Any visual outcome of the resumed plugin code
121 // arrives as a follow-up plugin *command* and is caught by
122 // `process_plugin_commands`'s `has_visual_commands` check below (or
123 // on the next tick). Forcing a render for the bare completion made
124 // live_diff's per-keystroke debounce repaint the screen with no
125 // change — invisible locally, but real lag over serial (#2100).
126 let needs_render = messages.iter().any(|m| {
127 !matches!(
128 m,
129 AsyncMessage::Plugin(fresh_core::api::PluginAsyncMessage::DelayComplete { .. })
130 )
131 });
132 tracing::trace!(
133 async_message_count = messages.len(),
134 "received async messages"
135 );
136
137 for message in messages {
138 match message {
139 AsyncMessage::LspDiagnostics {
140 uri,
141 diagnostics,
142 server_name,
143 } => {
144 self.handle_lsp_diagnostics(uri, diagnostics, server_name);
145 }
146 AsyncMessage::LspInitialized {
147 language,
148 server_name,
149 capabilities,
150 } => {
151 self.handle_lsp_initialized(language, server_name, capabilities);
152 }
153 AsyncMessage::LspError {
154 language,
155 error,
156 stderr_log_path,
157 } => {
158 self.handle_lsp_error(language, error, stderr_log_path);
159 }
160 AsyncMessage::LspCompletion { request_id, items } => {
161 if let Err(e) = self.handle_completion_response(request_id, items) {
162 tracing::error!("Error handling completion response: {}", e);
163 }
164 }
165 AsyncMessage::LspGotoDefinition {
166 request_id,
167 locations,
168 } => {
169 if let Err(e) = self.handle_goto_definition_response(request_id, locations) {
170 tracing::error!("Error handling goto definition response: {}", e);
171 }
172 }
173 AsyncMessage::LspImplementation {
174 request_id,
175 locations,
176 } => {
177 if let Err(e) = self.handle_implementation_response(request_id, locations) {
178 tracing::error!("Error handling implementation response: {}", e);
179 }
180 }
181 AsyncMessage::LspRename { request_id, result } => {
182 if let Err(e) = self.handle_rename_response(request_id, result) {
183 tracing::error!("Error handling rename response: {}", e);
184 }
185 }
186 AsyncMessage::LspHover {
187 request_id,
188 contents,
189 is_markdown,
190 range,
191 } => {
192 self.handle_hover_response(request_id, contents, is_markdown, range);
193 }
194 AsyncMessage::LspReferences {
195 request_id,
196 locations,
197 } => {
198 if let Err(e) = self.handle_references_response(request_id, locations) {
199 tracing::error!("Error handling references response: {}", e);
200 }
201 }
202 AsyncMessage::LspSignatureHelp {
203 request_id,
204 signature_help,
205 } => {
206 self.handle_signature_help_response(request_id, signature_help);
207 }
208 AsyncMessage::LspCodeActions {
209 request_id,
210 actions,
211 } => {
212 self.handle_code_actions_response(request_id, actions);
213 }
214 AsyncMessage::LspApplyEdit { edit, label } => {
215 self.handle_lsp_apply_edit(edit, label);
216 }
217 AsyncMessage::LspCodeActionResolved {
218 request_id: _,
219 action,
220 } => {
221 self.handle_lsp_code_action_resolved(action);
222 }
223 AsyncMessage::LspCompletionResolved {
224 request_id: _,
225 item,
226 } => {
227 if let Ok(resolved) = item {
228 self.handle_completion_resolved(resolved);
229 }
230 }
231 AsyncMessage::LspFormatting {
232 request_id: _,
233 uri,
234 edits,
235 } => {
236 if !edits.is_empty() {
237 if let Err(e) = self.apply_formatting_edits(&uri, edits) {
238 tracing::error!("Failed to apply formatting: {}", e);
239 }
240 }
241 }
242 AsyncMessage::LspPrepareRename {
243 request_id: _,
244 result,
245 } => {
246 self.handle_prepare_rename_response(result);
247 }
248 AsyncMessage::LspPulledDiagnostics {
249 request_id: _,
250 uri,
251 result_id,
252 diagnostics,
253 unchanged,
254 } => {
255 self.handle_lsp_pulled_diagnostics(uri, result_id, diagnostics, unchanged);
256 }
257 AsyncMessage::LspInlayHints {
258 request_id,
259 uri,
260 hints,
261 } => {
262 self.handle_lsp_inlay_hints(request_id, uri, hints);
263 }
264 AsyncMessage::LspFoldingRanges {
265 request_id,
266 uri,
267 ranges,
268 } => {
269 self.handle_lsp_folding_ranges(request_id, uri, ranges);
270 }
271 AsyncMessage::LspSemanticTokens {
272 request_id,
273 uri,
274 response,
275 } => {
276 self.handle_lsp_semantic_tokens(request_id, uri, response);
277 }
278 AsyncMessage::LspServerQuiescent { language } => {
279 self.handle_lsp_server_quiescent(language);
280 }
281 AsyncMessage::LspDiagnosticRefresh { language } => {
282 self.handle_lsp_diagnostic_refresh(language);
283 }
284 AsyncMessage::LspInlayHintRefresh { language } => {
285 self.handle_lsp_inlay_hint_refresh(language);
286 }
287 AsyncMessage::LspSemanticTokensRefresh { language } => {
288 self.handle_lsp_semantic_tokens_refresh(language);
289 }
290 AsyncMessage::LspDynamicCapabilities {
291 language,
292 server_name,
293 register,
294 registrations,
295 } => {
296 self.handle_lsp_dynamic_capabilities(
297 language,
298 server_name,
299 register,
300 registrations,
301 );
302 }
303 AsyncMessage::FileChanged { path } => {
304 self.handle_async_file_changed(path);
305 }
306 AsyncMessage::GitStatusChanged { status } => {
307 tracing::info!("Git status changed: {}", status);
308 // TODO: Handle git status changes
309 }
310 AsyncMessage::FileExplorerInitialized { window, view } => {
311 self.handle_file_explorer_initialized(window, view);
312 }
313 AsyncMessage::FileExplorerToggleNode(node_id) => {
314 self.handle_file_explorer_toggle_node(node_id);
315 }
316 AsyncMessage::FileExplorerRefreshNode(node_id) => {
317 self.handle_file_explorer_refresh_node(node_id);
318 }
319 AsyncMessage::FileExplorerExpandedToPath { window, view } => {
320 self.handle_file_explorer_expanded_to_path(window, view);
321 }
322 AsyncMessage::Plugin(plugin_msg) => {
323 self.handle_plugin_async_message(plugin_msg);
324 }
325 AsyncMessage::LspProgress {
326 language,
327 token,
328 value,
329 } => {
330 self.handle_lsp_progress(language, token, value);
331 }
332 AsyncMessage::LspWindowMessage {
333 language,
334 message_type,
335 message,
336 } => {
337 self.handle_lsp_window_message(language, message_type, message);
338 }
339 AsyncMessage::LspLogMessage {
340 language,
341 message_type,
342 message,
343 } => {
344 self.handle_lsp_log_message(language, message_type, message);
345 }
346 AsyncMessage::LspStatusUpdate {
347 language,
348 server_name,
349 status,
350 message: _,
351 } => {
352 self.handle_lsp_status_update(language, server_name, status);
353 }
354 AsyncMessage::FileOpenDirectoryLoaded(result) => {
355 self.handle_file_open_directory_loaded(result);
356 }
357 AsyncMessage::FileOpenShortcutsLoaded(shortcuts) => {
358 self.handle_file_open_shortcuts_loaded(shortcuts);
359 }
360 AsyncMessage::ClipboardPasteResult { request_id, text } => {
361 self.resolve_pending_paste(request_id, text);
362 }
363 AsyncMessage::TerminalOutput { terminal } => {
364 self.handle_terminal_output(terminal);
365 }
366 AsyncMessage::PathChanged { handle, path, kind } => {
367 self.handle_path_changed(handle, path, kind);
368 }
369 AsyncMessage::TerminalExited {
370 terminal,
371 exit_code,
372 } => {
373 self.handle_terminal_exited(terminal, exit_code);
374 }
375
376 AsyncMessage::LspServerRequest {
377 language,
378 server_command,
379 method,
380 params,
381 } => {
382 self.handle_lsp_server_request(language, server_command, method, params);
383 }
384 AsyncMessage::PluginLspResponse {
385 language: _,
386 request_id,
387 result,
388 } => {
389 self.handle_plugin_lsp_response(request_id, result);
390 }
391 AsyncMessage::RemoteAttachReady(ready) => {
392 self.handle_remote_attach_ready(ready);
393 }
394 AsyncMessage::RemoteReconnected { connection_id } => {
395 self.handle_remote_reconnected(connection_id);
396 }
397 AsyncMessage::RemoteAttachFailed {
398 error,
399 request_id,
400 reconnect_window,
401 } => {
402 self.handle_remote_attach_failed(error, request_id, reconnect_window);
403 }
404 AsyncMessage::PluginProcessOutput {
405 process_id,
406 stdout,
407 stderr,
408 exit_code,
409 } => {
410 // Drop any host-process kill handle tied to this
411 // id. The spawn task has exited (that's what this
412 // event means) so the handle is stale; a late
413 // `KillHostProcess` from the plugin should be a
414 // silent no-op rather than a dangling send. For
415 // non-host-process spawns the key won't be in
416 // the map and the remove is a no-op.
417 self.host_process_handles.remove(&process_id);
418 self.handle_plugin_process_output(
419 fresh_core::api::JsCallbackId::from(process_id),
420 stdout,
421 stderr,
422 exit_code,
423 );
424 }
425 AsyncMessage::GrammarRegistryBuilt {
426 registry,
427 callback_ids,
428 } => {
429 self.handle_grammar_registry_built(registry, callback_ids);
430 }
431 AsyncMessage::QuickOpenFilesLoaded {
432 cwd,
433 files,
434 complete,
435 } => {
436 self.handle_quick_open_files_loaded(cwd, files, complete);
437 }
438 AsyncMessage::PluginsDirLoaded {
439 dir,
440 errors,
441 discovered_plugins,
442 } => {
443 self.handle_plugins_dir_loaded(dir, errors, discovered_plugins);
444 }
445 AsyncMessage::PluginDeclarationsReady { declarations } => {
446 self.handle_plugin_declarations_ready(declarations);
447 }
448 AsyncMessage::PluginInitScriptLoaded(outcome) => {
449 self.handle_plugin_init_script_loaded(outcome);
450 }
451 }
452 }
453
454 // Update plugin state snapshot BEFORE processing commands
455 // This ensures plugins have access to current editor state (cursor positions, etc.)
456 #[cfg(feature = "plugins")]
457 {
458 let _s = tracing::info_span!("update_plugin_state_snapshot").entered();
459 self.update_plugin_state_snapshot();
460 }
461
462 // Process TypeScript plugin commands
463 #[cfg(not(feature = "plugins"))]
464 let processed_any_commands = false;
465 #[cfg(feature = "plugins")]
466 let processed_any_commands = {
467 let _s = tracing::info_span!("process_plugin_commands").entered();
468 self.process_plugin_commands()
469 };
470
471 // Re-sync snapshot after commands — commands like SetViewMode change
472 // state that plugins read via getBufferInfo(). Without this, a
473 // subsequent lines_changed callback would see stale values.
474 #[cfg(feature = "plugins")]
475 if processed_any_commands {
476 let _s = tracing::info_span!("update_plugin_state_snapshot_post").entered();
477 self.update_plugin_state_snapshot();
478 }
479
480 // Process pending plugin action completions
481 #[cfg(feature = "plugins")]
482 {
483 let _s = tracing::info_span!("process_pending_plugin_actions").entered();
484 self.process_pending_plugin_actions();
485 }
486
487 // Process pending LSP server restarts (with exponential backoff)
488 {
489 let _s = tracing::info_span!("process_pending_lsp_restarts").entered();
490 self.process_pending_lsp_restarts();
491 }
492
493 // Check and clear the plugin render request flag
494 #[cfg(feature = "plugins")]
495 let plugin_render = {
496 let render = self.plugin_render_requested;
497 self.plugin_render_requested = false;
498 render
499 };
500 #[cfg(not(feature = "plugins"))]
501 let plugin_render = false;
502
503 // Poll periodic update checker for new results
504 if let Some(ref mut checker) = self.update_checker {
505 // Poll for results but don't act on them - just cache
506 let _ = checker.poll_result();
507 }
508
509 // Poll for file changes (auto-revert) and file tree changes
510 let file_changes = {
511 let _s = tracing::info_span!("poll_file_changes").entered();
512 self.poll_file_changes()
513 };
514 let tree_changes = {
515 let _s = tracing::info_span!("poll_file_tree_changes").entered();
516 self.poll_file_tree_changes()
517 };
518
519 // Trigger render if any async messages, plugin commands were processed, or plugin requested render
520 needs_render || processed_any_commands || plugin_render || file_changes || tree_changes
521 }
522
523 /// Handle a server's `initialize` response: record capabilities and kick off
524 /// the deferred per-language requests that were gated on them.
525 fn handle_lsp_initialized(
526 &mut self,
527 language: String,
528 server_name: String,
529 capabilities: crate::services::lsp::manager::ServerCapabilitySummary,
530 ) {
531 tracing::info!(
532 "LSP server '{}' initialized for language: {}",
533 server_name,
534 language
535 );
536 self.active_window_mut().status_message = Some(format!("LSP ({}) ready", language));
537
538 // Store capabilities on the specific server handle
539 let __active_id = self.active_window;
540 if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
541 lsp.set_server_capabilities(&language, &server_name, capabilities);
542 }
543
544 // Send didOpen for all open buffers of this language
545 self.resend_did_open_for_language(&language);
546 self.request_semantic_tokens_for_language(&language);
547 self.request_folding_ranges_for_language(&language);
548 // Now that capabilities are known, kick off inlay hints
549 // and pull-diagnostics for buffers that opened before the
550 // `initialize` handshake completed. Both paths route
551 // through `handle_for_feature_mut`, so servers that
552 // didn't advertise the capability are skipped.
553 self.request_inlay_hints_for_language(&language);
554 self.pull_diagnostics_for_language(&language);
555 }
556
557 /// Handle an LSP server crash/spawn failure: surface it, fire the
558 /// `lsp_server_error` hook, and open the stderr log in the background.
559 fn handle_lsp_error(
560 &mut self,
561 language: String,
562 error: String,
563 stderr_log_path: Option<std::path::PathBuf>,
564 ) {
565 tracing::error!("LSP error for {}: {}", language, error);
566 self.active_window_mut().status_message =
567 Some(format!("LSP error ({}): {}", language, error));
568
569 // Get server command from config for the hook
570 let server_command = self
571 .config
572 .lsp
573 .get(&language)
574 .and_then(|configs| configs.as_slice().first())
575 .map(|c| c.command.clone())
576 .unwrap_or_else(|| "unknown".to_string());
577
578 // Determine error type from error message
579 let error_type = if error.contains("not found") || error.contains("NotFound") {
580 "not_found"
581 } else if error.contains("permission") || error.contains("PermissionDenied") {
582 "spawn_failed"
583 } else if error.contains("timeout") {
584 "timeout"
585 } else {
586 "spawn_failed"
587 }
588 .to_string();
589
590 // Fire the LspServerError hook for plugins
591 self.plugin_manager.read().unwrap().run_hook(
592 "lsp_server_error",
593 crate::services::plugins::hooks::HookArgs::LspServerError {
594 language: language.clone(),
595 server_command,
596 error_type,
597 message: error.clone(),
598 },
599 );
600
601 // Open stderr log as read-only buffer if it exists and has content
602 // Opens in background (new tab) without stealing focus
603 if let Some(log_path) = stderr_log_path {
604 let has_content = log_path.metadata().map(|m| m.len() > 0).unwrap_or(false);
605 if has_content {
606 tracing::info!("Opening LSP stderr log in background: {:?}", log_path);
607 match self.open_file_no_focus(&log_path) {
608 Ok(buffer_id) => {
609 self.active_window_mut()
610 .mark_buffer_read_only(buffer_id, true);
611 self.active_window_mut().status_message = Some(format!(
612 "LSP error ({}): {} - See stderr log",
613 language, error
614 ));
615 }
616 Err(e) => {
617 tracing::error!("Failed to open LSP stderr log: {}", e);
618 }
619 }
620 }
621 }
622 }
623
624 /// Apply a server-initiated `workspace/applyEdit`.
625 fn handle_lsp_apply_edit(&mut self, edit: lsp_types::WorkspaceEdit, label: Option<String>) {
626 tracing::info!("Applying workspace edit from server (label: {:?})", label);
627 match self.apply_workspace_edit(edit) {
628 Ok(n) => {
629 if let Some(label) = label {
630 self.set_status_message(
631 t!("lsp.code_action_applied", title = &label, count = n).to_string(),
632 );
633 }
634 }
635 Err(e) => {
636 tracing::error!("Failed to apply workspace edit: {}", e);
637 }
638 }
639 }
640
641 /// Execute a resolved code action, or report the `codeAction/resolve` error.
642 fn handle_lsp_code_action_resolved(&mut self, action: Result<lsp_types::CodeAction, String>) {
643 match action {
644 Ok(resolved) => {
645 self.execute_resolved_code_action(resolved);
646 }
647 Err(e) => {
648 tracing::warn!("codeAction/resolve failed: {}", e);
649 self.set_status_message(format!("Code action resolve failed: {e}"));
650 }
651 }
652 }
653
654 /// Route a plugin-runtime async message (process I/O, delays, LSP and
655 /// generic plugin responses) to its handler/hook.
656 fn handle_plugin_async_message(&mut self, plugin_msg: fresh_core::api::PluginAsyncMessage) {
657 use fresh_core::api::{JsCallbackId, PluginAsyncMessage};
658 match plugin_msg {
659 PluginAsyncMessage::ProcessOutput {
660 process_id,
661 stdout,
662 stderr,
663 exit_code,
664 } => {
665 self.handle_plugin_process_output(
666 JsCallbackId::from(process_id),
667 stdout,
668 stderr,
669 exit_code,
670 );
671 }
672 PluginAsyncMessage::DelayComplete { callback_id } => {
673 self.plugin_manager
674 .read()
675 .unwrap()
676 .resolve_callback(JsCallbackId::from(callback_id), "null".to_string());
677 }
678 PluginAsyncMessage::ProcessStdout { process_id, data } => {
679 self.plugin_manager.read().unwrap().run_hook(
680 "onProcessStdout",
681 crate::services::plugins::hooks::HookArgs::ProcessOutput { process_id, data },
682 );
683 }
684 PluginAsyncMessage::ProcessStderr { process_id, data } => {
685 self.plugin_manager.read().unwrap().run_hook(
686 "onProcessStderr",
687 crate::services::plugins::hooks::HookArgs::ProcessOutput { process_id, data },
688 );
689 }
690 PluginAsyncMessage::ProcessExit {
691 process_id,
692 callback_id,
693 exit_code,
694 } => {
695 self.background_process_handles.remove(&process_id);
696 let result = fresh_core::api::BackgroundProcessResult {
697 process_id,
698 exit_code,
699 };
700 self.plugin_manager.read().unwrap().resolve_callback(
701 JsCallbackId::from(callback_id),
702 serde_json::to_string(&result).unwrap(),
703 );
704 }
705 PluginAsyncMessage::LspResponse {
706 language: _,
707 request_id,
708 result,
709 } => {
710 self.handle_plugin_lsp_response(request_id, result);
711 }
712 PluginAsyncMessage::PluginResponse(response) => {
713 self.handle_plugin_response(response);
714 }
715 }
716 }
717
718 /// Handle new terminal output: follow the bottom when appropriate and fire
719 /// the `terminal_output` hook, attributing it to the owning session.
720 fn handle_terminal_output(&mut self, terminal: fresh_core::WindowTerminalId) {
721 // The message carries its owning window: terminal ids
722 // collide across windows, so we trust the tag rather
723 // than scanning windows for a matching id (which would
724 // attribute output to the wrong session).
725 let terminal_id = terminal.terminal;
726 let owner = terminal.window;
727 // Terminal output received - check if we should auto-jump back to terminal mode
728 tracing::trace!("Terminal output received for {}", terminal);
729
730 // If viewing scrollback for this terminal and jump_to_end_on_output is enabled,
731 // automatically re-enter terminal mode
732 if self.config.terminal.jump_to_end_on_output && !self.active_window().terminal_mode {
733 // Check if active buffer is this terminal
734 if let Some(active_terminal_id) =
735 self.active_window().get_terminal_id(self.active_buffer())
736 {
737 if active_terminal_id == terminal_id {
738 self.enter_terminal_mode();
739 }
740 }
741 }
742
743 // When in terminal mode, ensure display stays at bottom (follows new output)
744 if self.active_window().terminal_mode {
745 if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
746 if let Ok(mut state) = handle.state.lock() {
747 state.scroll_to_bottom();
748 }
749 }
750 }
751
752 // Notify plugins, attributing output to the owning
753 // *session* even when it's a background one (terminals
754 // live in their own window's manager, not the active
755 // window's). Snapshot the cursor row's text from that
756 // same window so prompt detection works off-focus too.
757 // The grid lock is released before `run_hook` runs to
758 // avoid holding it across plugin code.
759 let last_line = self
760 .windows
761 .get(&owner)
762 .and_then(|w| w.terminal_manager.get(terminal_id))
763 .and_then(|handle| handle.state.lock().ok().map(|s| s.last_visible_line()))
764 .unwrap_or_default();
765 self.plugin_manager.read().unwrap().run_hook(
766 "terminal_output",
767 crate::services::plugins::hooks::HookArgs::TerminalOutput {
768 terminal_id: terminal_id.0 as u64,
769 window_id: owner.0,
770 last_line,
771 },
772 );
773 }
774
775 /// Forward a watched-path filesystem event to the `path_changed` hook.
776 fn handle_path_changed(
777 &mut self,
778 handle: u64,
779 path: std::path::PathBuf,
780 kind: crate::services::async_bridge::PathChangeKind,
781 ) {
782 self.last_path_change_for_test = Some((handle, path.clone(), kind.as_str()));
783 self.plugin_manager.read().unwrap().run_hook(
784 "path_changed",
785 crate::services::plugins::hooks::HookArgs::PathChanged {
786 handle,
787 path: path.to_string_lossy().into_owned(),
788 kind: kind.as_str().to_owned(),
789 },
790 );
791 }
792
793 /// Tear down (or preserve, for a pending remote reconnect) a terminal whose
794 /// process exited, then fire the `terminal_exit` hook.
795 /// Per-frame detector for *silent* agent-channel reconnects.
796 ///
797 /// The SSH / Kubernetes agent channel re-establishes itself in the
798 /// background by hot-swapping its transport (`spawn_reconnect_task`),
799 /// without ever routing through the app-level `RemoteAttachMode::Reconnect`
800 /// flow — and that flow is the only thing that respawns the embedded
801 /// terminal PTYs. Those PTYs are a *separate* `ssh -t` / `kubectl exec`
802 /// carrier from the agent channel, so they die when the link drops and,
803 /// on the automatic recovery path, would otherwise stay dead even though
804 /// the filesystem/LSP came back.
805 ///
806 /// Bring `window_id`'s remote session back to life after its carrier
807 /// reconnected. The single convergence point for *every* reconnect path:
808 ///
809 /// * the silent background transport hot-swap (`spawn_reconnect_task`),
810 /// which keeps the existing authority and notifies via
811 /// `AsyncMessage::RemoteReconnected`; and
812 /// * the app-level rebuild (`RemoteAttachMode::Reconnect`), which installs
813 /// a fresh authority first and then calls this.
814 ///
815 /// Either way the embedded terminal PTYs died with the old carrier (a
816 /// separate `ssh -t` / `kubectl exec` from the agent channel), so we respawn
817 /// them in place through the now-live authority, reusing each backing file
818 /// so scrollback continues. `respawn_terminals_through_authority` skips
819 /// still-live terminals, so this is idempotent under duplicate signals.
820 pub(crate) fn reattach_window(&mut self, window_id: fresh_core::WindowId) {
821 let Some(window) = self.windows.get_mut(&window_id) else {
822 return;
823 };
824 window.remote_reconnect_error = None;
825 let revived = window.respawn_terminals_through_authority();
826 if revived > 0 {
827 let label = window.label.clone();
828 self.set_status_message(format!("Reconnected: {label}"));
829 }
830 }
831
832 /// Ensure each remote window has a background task forwarding its agent
833 /// channel's reconnect notifications onto the bridge as
834 /// `AsyncMessage::RemoteReconnected`. Idempotent and cheap: it only spawns
835 /// for channels not already in `remote_reconnect_forwarders`. Called every
836 /// frame so windows born/reconnected after startup are covered without
837 /// hooking each authority-install site.
838 ///
839 /// The forwarder loops (`notified().await` → `send`) so it survives many
840 /// reconnects. A window whose authority is later rebuilt gets a *new*
841 /// channel id and a fresh forwarder; the old forwarder parks forever on a
842 /// notify that can no longer fire (its channel is dropped) — a single idle
843 /// task per rebuild, which is rare. Cleaning those up is a future refinement.
844 fn ensure_remote_reconnect_forwarders(&mut self) {
845 let (Some(runtime), Some(bridge)) =
846 (self.tokio_runtime.as_ref(), self.async_bridge.as_ref())
847 else {
848 return;
849 };
850 // Collect first to avoid spawning while holding the `windows` borrow.
851 let mut to_spawn: Vec<(u64, std::sync::Arc<tokio::sync::Notify>)> = Vec::new();
852 for window in self.windows.values() {
853 let fs = &window.authority().filesystem;
854 if let (Some(id), Some(notify)) = (fs.remote_channel_id(), fs.remote_reconnect_notify())
855 {
856 if !self.remote_reconnect_forwarders.contains(&id) {
857 to_spawn.push((id, notify));
858 }
859 }
860 }
861 for (id, notify) in to_spawn {
862 self.remote_reconnect_forwarders.insert(id);
863 let sender = bridge.sender();
864 runtime.spawn(async move {
865 loop {
866 notify.notified().await;
867 if sender
868 .send(AsyncMessage::RemoteReconnected { connection_id: id })
869 .is_err()
870 {
871 break; // editor/bridge gone
872 }
873 }
874 });
875 }
876 }
877
878 /// Test-only seam: drive the reconnect dispatch directly, as if a
879 /// `RemoteReconnected` event for `connection_id` had arrived on the bridge.
880 /// Lets component tests exercise the id→window→reattach mapping without a
881 /// live agent channel or tokio runtime.
882 #[doc(hidden)]
883 pub fn test_dispatch_remote_reconnected(&mut self, connection_id: u64) {
884 self.handle_remote_reconnected(connection_id);
885 }
886
887 /// Map a reconnected agent channel (identified by its stable connection id)
888 /// back to the window whose live authority owns it, and reattach. Driven by
889 /// the background reconnect task via `AsyncMessage::RemoteReconnected`.
890 fn handle_remote_reconnected(&mut self, connection_id: u64) {
891 let Some(window_id) = self.windows.iter().find_map(|(id, w)| {
892 (w.authority().filesystem.remote_channel_id() == Some(connection_id)).then_some(*id)
893 }) else {
894 // The window was closed, or its authority was swapped out from under
895 // this connection — nothing to reattach.
896 return;
897 };
898 tracing::info!("agent channel {connection_id} reconnected; reattaching window {window_id}");
899 self.reattach_window(window_id);
900 }
901
902 fn handle_terminal_exited(
903 &mut self,
904 terminal: fresh_core::WindowTerminalId,
905 exit_code: Option<i32>,
906 ) {
907 // The message is tagged with its owning window, so the
908 // plugin hook is attributed correctly even for a
909 // background session's terminal.
910 let terminal_id = terminal.terminal;
911 let exited_window_id = terminal.window;
912 tracing::info!("Terminal {} exited", terminal);
913 // A remote window whose carrier just dropped: its embedded PTY (a
914 // separate `ssh -t` / `kubectl exec` from the agent channel) died with
915 // the link, not because the user exited the shell. Keep the
916 // buffer↔terminal binding (and the backing/command maps, which this
917 // handler already leaves intact) so a reconnect can respawn it in
918 // place (`respawn_terminals_through_authority`, driven on the automatic
919 // path by `detect_remote_terminal_reconnects`). Removing it here would
920 // strand the buffer as a dead read-only tab with no way back.
921 //
922 // The signal is the *live authority*: a remote filesystem that is
923 // currently disconnected. Gating on `authority_spec` instead would
924 // miss a plain `fresh ssh://…` launch, whose spec stays `Local`. A
925 // normal exit (remote still connected, or any local terminal) falls
926 // through to the usual permanent teardown.
927 let preserve_for_reconnect = {
928 let fs = &self.active_window().authority().filesystem;
929 fs.remote_connection_info().is_some() && !fs.is_remote_connected()
930 };
931 // Find the buffer associated with this terminal
932 if let Some((&buffer_id, _)) = self
933 .active_window()
934 .terminal_buffers
935 .iter()
936 .find(|(_, tb)| tb.terminal_id == terminal_id)
937 {
938 // A genuinely exited terminal becomes a read-only scrollback tab,
939 // so mark it Scrollback — a later focus shows scrollback instead of
940 // trying to drive a dead PTY. A terminal preserved for remote
941 // reconnect keeps its live mode so it comes back live when the
942 // carrier respawns it.
943 if !preserve_for_reconnect {
944 self.active_window_mut().set_terminal_interaction_mode(
945 buffer_id,
946 crate::app::window::TerminalInteractionMode::Scrollback,
947 );
948 }
949
950 // Exit terminal mode if this is the active buffer
951 if self.active_buffer() == buffer_id && self.active_window().terminal_mode {
952 self.active_window_mut().terminal_mode = false;
953 self.active_window_mut().key_context =
954 crate::input::keybindings::KeyContext::Normal;
955 }
956
957 // Sync terminal content to buffer (final screen state)
958 self.active_window_mut().sync_terminal_to_buffer(buffer_id);
959
960 // Append exit message to the backing file and reload
961 let exit_msg = "\n[Terminal process exited]\n";
962
963 if let Some(backing_path) = self
964 .active_window()
965 .terminal_backing_files
966 .get(&terminal_id)
967 .cloned()
968 {
969 if let Ok(mut file) =
970 crate::app::terminal::terminal_backing_fs().open_file_for_append(&backing_path)
971 {
972 use std::io::Write;
973 if let Err(e) = file.write_all(exit_msg.as_bytes()) {
974 tracing::warn!("Failed to write terminal exit message: {}", e);
975 }
976 }
977
978 // Force reload buffer from file to pick up the exit message
979 if let Err(e) = self.revert_buffer_by_id(buffer_id, &backing_path) {
980 tracing::warn!("Failed to revert terminal buffer: {}", e);
981 }
982
983 // After revert, scroll the viewport so the just-
984 // appended exit message is visible. sync_terminal_to_buffer
985 // pinned the viewport to the start of the visible screen
986 // (so exit is pixel-identical to the last live frame); the
987 // exit message is appended *after* that pinned region,
988 // so we have to deliberately scroll past the pin to bring
989 // it on-screen. Move the cursor to the new end-of-buffer
990 // and clear the skip_ensure_visible flag the sync path
991 // armed; the next render's ensure_visible will then scroll
992 // the cursor (and the exit-message line above it) into
993 // view.
994 let new_total = self
995 .windows
996 .get(&self.active_window)
997 .and_then(|w| w.buffers.get(&buffer_id))
998 .map(|s| s.buffer.total_bytes())
999 .unwrap_or(0);
1000 if let Some((mgr, view_states)) = self
1001 .windows
1002 .get_mut(&self.active_window)
1003 .map(|w| &mut w.buffers)
1004 .expect("active window present")
1005 .splits_mut()
1006 {
1007 let active_split = mgr.active_split();
1008 if let Some(view_state) = view_states.get_mut(&active_split) {
1009 view_state.cursors.primary_mut().position = new_total;
1010 view_state.viewport.clear_skip_ensure_visible();
1011 }
1012 }
1013 }
1014
1015 // Ensure buffer remains read-only with no line numbers
1016 if let Some(state) = self
1017 .windows
1018 .get_mut(&self.active_window)
1019 .map(|w| &mut w.buffers)
1020 .expect("active window present")
1021 .get_mut(&buffer_id)
1022 {
1023 state.editing_disabled = true;
1024 state.margins.configure_for_line_numbers(false);
1025 state.buffer.set_modified(false);
1026 }
1027
1028 // Remove from terminal_buffers so it's no longer treated
1029 // as a terminal — unless we're holding it for a remote
1030 // reconnect to respawn in place (see above).
1031 if !preserve_for_reconnect {
1032 self.active_window_mut().terminal_buffers.remove(&buffer_id);
1033 }
1034
1035 self.set_status_message(t!("terminal.exited", id = terminal_id.0).to_string());
1036 }
1037 self.active_window_mut().terminal_manager.close(terminal_id);
1038
1039 // Notify plugins after the editor's own exit handling
1040 // is complete. Orchestrator's state machine reads this
1041 // to transition agents to READY (code 0) or ERRORED.
1042 // `exit_code` is currently always `None` here; full
1043 // wait-status capture is a follow-up commit.
1044 self.plugin_manager.read().unwrap().run_hook(
1045 "terminal_exit",
1046 crate::services::plugins::hooks::HookArgs::TerminalExited {
1047 terminal_id: terminal_id.0 as u64,
1048 window_id: exited_window_id.0,
1049 exit_code,
1050 },
1051 );
1052 }
1053
1054 /// Install a completed `attachRemoteAgent` connection per its mode
1055 /// (restart / new window / dormant-session reconnect).
1056 fn handle_remote_attach_ready(
1057 &mut self,
1058 ready: crate::services::async_bridge::RemoteAttachReady,
1059 ) {
1060 // The background connect succeeded. Install per `mode`:
1061 // Restart rebuilds the whole editor around the backend
1062 // (global), Window spawns a born-attached session beside
1063 // the existing ones.
1064 let crate::services::async_bridge::RemoteAttachReady {
1065 authority,
1066 keepalive,
1067 working_dir,
1068 mode,
1069 spec,
1070 request_id,
1071 } = ready;
1072 // If the plugin cancelled this connect while it was
1073 // in flight (the New-Session dialog's Cancel), the result
1074 // arrives too late to matter: drop the authority and its
1075 // keepalive here so the carrier is torn down and no window
1076 // is ever built. The reject was already delivered at cancel
1077 // time, so there's nothing left to resolve.
1078 if self.remote_attach_was_cancelled(request_id) {
1079 tracing::info!(
1080 "Remote attach for request {} arrived after cancellation; discarding",
1081 request_id
1082 );
1083 drop(keepalive);
1084 drop(authority);
1085 return;
1086 }
1087 // Re-root at the pod's workspace (or its home if the plugin
1088 // didn't supply one) — never the stale local path. The
1089 // filesystem call is safe here: `process_async_messages`
1090 // runs on the main loop, not inside a runtime.
1091 let root = working_dir
1092 .or_else(|| authority.filesystem.home_dir().ok())
1093 .unwrap_or_else(|| std::path::PathBuf::from("/"));
1094 match mode {
1095 crate::services::async_bridge::RemoteAttachMode::Restart => {
1096 tracing::info!(
1097 "Remote attach connected ({}); installing authority (restart), rooting at {}",
1098 authority.display_label,
1099 root.display()
1100 );
1101 // Resolve before the restart tears the plugin
1102 // runtime down, so the awaiting caller observes
1103 // success rather than a vanished promise.
1104 self.resolve_remote_attach(request_id);
1105 // Record the reconnect spec on the (re-rooted)
1106 // active session before the restart so it persists
1107 // and the rebuilt editor restores this backend.
1108 self.active_window_mut().authority_spec = spec;
1109 self.install_authority_with_keepalive(authority, keepalive, root);
1110 }
1111 crate::services::async_bridge::RemoteAttachMode::Window { label, command } => {
1112 tracing::info!(
1113 "Remote attach connected ({}); opening born-attached window at {}",
1114 authority.display_label,
1115 root.display()
1116 );
1117 // The session is only "ready" once the window
1118 // exists. Resolve on success; on a window-creation
1119 // failure reject so the plugin keeps its dialog
1120 // open with the reason and no half-built window.
1121 match self
1122 .create_remote_session_window(authority, keepalive, root, label, command, spec)
1123 {
1124 Ok(_) => self.resolve_remote_attach(request_id),
1125 Err(e) => self.reject_remote_attach(request_id, e),
1126 }
1127 }
1128 crate::services::async_bridge::RemoteAttachMode::Reconnect { window_id } => {
1129 // The common case: a dormant remote session the user
1130 // dived into finished connecting. It has no `Window`
1131 // yet (it lived in `dormant_remote` as an
1132 // authority-less descriptor) — promote it now, born
1133 // with this connected authority, restoring its
1134 // workspace through it so its terminals run on the
1135 // remote backend, not the local host.
1136 if self.dormant_remote.contains_key(&window_id) {
1137 tracing::info!(
1138 "Promoting dormant remote session {window_id} ({})",
1139 authority.display_label
1140 );
1141 self.promote_dormant_remote(window_id, authority, keepalive);
1142 } else if self.windows.contains_key(&window_id) {
1143 tracing::info!(
1144 "Reconnected dormant session {window_id} ({})",
1145 authority.display_label
1146 );
1147 // This path *rebuilt* the authority, so install it first,
1148 // then reattach: `reattach_window` clears the FailedAttach
1149 // indicator and respawns the embedded terminal(s) that died
1150 // with the old carrier, through the freshly-installed
1151 // authority. The silent hot-swap path keeps the existing
1152 // authority and reaches the same `reattach_window` via
1153 // `AsyncMessage::RemoteReconnected`.
1154 self.set_session_authority(window_id, authority);
1155 self.session_keepalives.insert(window_id, keepalive);
1156 self.reattach_window(window_id);
1157 } else {
1158 // The window was closed while the connect was in
1159 // flight — drop the backend we just built.
1160 drop(authority);
1161 drop(keepalive);
1162 }
1163 }
1164 }
1165 }
1166
1167 /// Reject a failed `attachRemoteAgent` connect, recording the reason on the
1168 /// reconnecting window so its status-bar indicator shows `FailedAttach`.
1169 fn handle_remote_attach_failed(
1170 &mut self,
1171 error: String,
1172 request_id: u64,
1173 reconnect_window: Option<fresh_core::WindowId>,
1174 ) {
1175 // A cancelled connect was already rejected at cancel time;
1176 // swallow the late failure rather than rejecting twice.
1177 if self.remote_attach_was_cancelled(request_id) {
1178 tracing::info!(
1179 "Remote attach for request {} failed after cancellation; discarding",
1180 request_id
1181 );
1182 return;
1183 }
1184 tracing::warn!("Remote attach failed: {}", error);
1185 // A *dive-triggered* reconnect of a dormant workspace has no
1186 // awaiting JS callback for `reject_remote_attach` to reject
1187 // and no plugin dialog open, so its only user-visible signal
1188 // is the status-bar remote indicator. Record the reason on
1189 // the workspace's window so the indicator renders
1190 // `FailedAttach` (persistent, error-styled, with a Retry /
1191 // Reopen Locally popup) until the next reconnect attempt.
1192 // Born-attached / restart attaches carry `None` here; their
1193 // failure is surfaced by the launching plugin's rejected
1194 // promise (e.g. the New-Session dialog's inline error).
1195 if let Some(window_id) = reconnect_window {
1196 let reason = error.lines().next().unwrap_or(&error).to_string();
1197 if let Some(w) = self.windows.get_mut(&window_id) {
1198 // An already-live remote window whose reconnect
1199 // failed: record on the window so its status-bar
1200 // indicator shows FailedAttach.
1201 w.remote_reconnect_error = Some(reason);
1202 } else {
1203 // A dormant session's connect failed: it has no
1204 // window (we never built one without the backend), so
1205 // surface the reason on the status line. The session
1206 // stays dormant in the dock; diving again retries.
1207 self.set_status_message(format!("Connection failed: {reason}"));
1208 }
1209 }
1210 self.reject_remote_attach(request_id, error);
1211 }
1212
1213 /// Swap in a freshly-built grammar registry, re-detect syntax for open
1214 /// buffers, and resolve any plugin callbacks that awaited the build.
1215 fn handle_grammar_registry_built(
1216 &mut self,
1217 registry: std::sync::Arc<crate::primitives::grammar::GrammarRegistry>,
1218 callback_ids: Vec<fresh_core::api::JsCallbackId>,
1219 ) {
1220 tracing::info!(
1221 "Background grammar build completed ({} syntaxes)",
1222 registry.available_syntaxes().len()
1223 );
1224 // Merge user `[languages]` config into the catalog so
1225 // find_by_path honours user globs/filenames/extensions.
1226 // The background thread just sent the Arc through the
1227 // channel, so we're the sole owner here. Assert rather
1228 // than silently drop config.
1229 let mut registry = registry;
1230 std::sync::Arc::get_mut(&mut registry)
1231 .expect("freshly-received grammar registry Arc must be uniquely owned")
1232 .apply_language_config(&self.config.languages);
1233 crate::config::reload_indent_overrides(&self.config.languages);
1234 self.grammar_registry = registry;
1235 // Propagate the new grammar registry to every window's
1236 // resources so window-side syntax detection picks up the
1237 // freshly-built grammars without waiting for a restart.
1238 for w in self.windows.values_mut() {
1239 w.resources.grammar_registry = self.grammar_registry.clone();
1240 }
1241 self.grammar_build_in_progress = false;
1242
1243 // Re-detect syntax for all open buffers with the full registry
1244 let buffers_to_update: Vec<_> = self
1245 .active_window()
1246 .buffer_metadata
1247 .iter()
1248 .filter_map(|(id, meta)| meta.file_path().map(|p| (*id, p.to_path_buf())))
1249 .collect();
1250
1251 for (buf_id, path) in buffers_to_update {
1252 if let Some(state) = self
1253 .windows
1254 .get_mut(&self.active_window)
1255 .map(|w| &mut w.buffers)
1256 .expect("active window present")
1257 .get_mut(&buf_id)
1258 {
1259 let first_line = state.buffer.first_line_lossy();
1260 let detected = crate::primitives::detected_language::DetectedLanguage::from_path(
1261 &path,
1262 first_line.as_deref(),
1263 &self.grammar_registry,
1264 &self.config.languages,
1265 );
1266
1267 if detected.highlighter.has_highlighting() || !state.highlighter.has_highlighting()
1268 {
1269 state.apply_language(detected);
1270 }
1271 }
1272 }
1273
1274 // Resolve plugin callbacks that were waiting for this build
1275 #[cfg(feature = "plugins")]
1276 for cb_id in callback_ids {
1277 self.plugin_manager
1278 .read()
1279 .unwrap()
1280 .resolve_callback(cb_id, "null".to_string());
1281 }
1282
1283 // Flush any plugin grammars that arrived during the build
1284 self.flush_pending_grammars();
1285 }
1286
1287 /// Update the Quick Open file cache from a background scan and refresh the
1288 /// open prompt's suggestions.
1289 fn handle_quick_open_files_loaded(
1290 &mut self,
1291 cwd: String,
1292 files: std::sync::Arc<Vec<crate::input::quick_open::providers::FileEntry>>,
1293 complete: bool,
1294 ) {
1295 // Update the file provider cache and refresh suggestions
1296 // if Quick Open is currently showing file mode (empty prefix).
1297 if let Some((provider, _)) = self.quick_open_registry.get_provider_for_input("") {
1298 if let Some(fp) = provider
1299 .as_any()
1300 .downcast_ref::<crate::input::quick_open::providers::FileProvider>()
1301 {
1302 if complete {
1303 fp.set_cache(&cwd, files);
1304 } else {
1305 fp.set_partial_cache(&cwd, files);
1306 }
1307 }
1308 }
1309 // Refresh the Quick Open suggestions if the prompt is open
1310 if let Some(prompt) = &self.active_window_mut().prompt {
1311 if prompt.prompt_type == PromptType::QuickOpen {
1312 let input = prompt.input.clone();
1313 self.update_quick_open_suggestions(&input);
1314 }
1315 }
1316 }
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321 use super::*;
1322 use crate::config::Config;
1323 use crate::config_io::DirectoryContext;
1324 use std::sync::Arc;
1325
1326 fn test_editor() -> Editor {
1327 let temp = tempfile::tempdir().unwrap();
1328 let dir_context = DirectoryContext::for_testing(temp.path());
1329 // Keep the temp dir alive for the editor's lifetime.
1330 std::mem::forget(temp);
1331 // Plugins disabled: an enabled plugin can set its own status on its
1332 // first tick (the bundled i18n test plugin does), which would clobber
1333 // the status this test asserts on. The handler under test is core, not
1334 // plugin-gated, so this isolates it cleanly.
1335 Editor::for_test(
1336 Config::default(),
1337 80,
1338 24,
1339 None,
1340 dir_context,
1341 crate::view::color_support::ColorCapability::TrueColor,
1342 Arc::new(crate::model::filesystem::StdFileSystem),
1343 None,
1344 None,
1345 false,
1346 false,
1347 )
1348 .unwrap()
1349 }
1350
1351 #[test]
1352 fn dive_reconnect_failure_records_error_on_its_window() {
1353 // A failed dive-triggered reconnect records the (first line of the)
1354 // error on its own window, which drives the status-bar remote indicator
1355 // into FailedAttach for that workspace.
1356 let mut editor = test_editor();
1357 let win = editor.active_window;
1358
1359 let sender = editor.async_bridge.as_ref().unwrap().sender();
1360 sender
1361 .send(AsyncMessage::RemoteAttachFailed {
1362 error: "Agent failed to start: SSH could not connect\nsecond line".to_string(),
1363 request_id: u64::MAX - win.0,
1364 reconnect_window: Some(win),
1365 })
1366 .unwrap();
1367 editor.process_async_messages();
1368
1369 let err = editor
1370 .windows
1371 .get(&win)
1372 .unwrap()
1373 .remote_reconnect_error
1374 .clone();
1375 assert_eq!(
1376 err.as_deref(),
1377 Some("Agent failed to start: SSH could not connect"),
1378 "only the first line of a multi-line error is recorded"
1379 );
1380 }
1381
1382 #[test]
1383 fn non_reconnect_attach_failure_sets_no_window_error() {
1384 // Born-attached / restart attaches (reconnect_window = None) surface
1385 // their failure via the launching plugin's rejected promise, not the
1386 // per-window indicator — so no window error is recorded.
1387 let mut editor = test_editor();
1388 let win = editor.active_window;
1389
1390 let sender = editor.async_bridge.as_ref().unwrap().sender();
1391 sender
1392 .send(AsyncMessage::RemoteAttachFailed {
1393 error: "boom".to_string(),
1394 request_id: 7,
1395 reconnect_window: None,
1396 })
1397 .unwrap();
1398 editor.process_async_messages();
1399
1400 assert!(
1401 editor
1402 .windows
1403 .get(&win)
1404 .unwrap()
1405 .remote_reconnect_error
1406 .is_none(),
1407 "a non-reconnect failure must not set a window error"
1408 );
1409 }
1410}