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