Skip to main content

fresh/services/
async_bridge.rs

1//! Async Bridge: Communication between async Tokio runtime and sync main loop
2//!
3//! This module implements the hybrid architecture described in TOKIO_ANALYSIS.md:
4//! - Tokio runtime handles I/O tasks (LSP, file watching, git, etc.)
5//! - Main UI loop stays synchronous (rendering, input, buffer manipulation)
6//! - std::sync::mpsc channels bridge the two worlds
7//!
8//! Philosophy:
9//! - I/O should be async (LSP, filesystem, network)
10//! - Computation should be sync (editing, rendering)
11//! - Main loop remains responsive and simple
12
13use crate::view::file_tree::{FileTreeView, NodeId};
14use lsp_types::{
15    CodeActionOrCommand, CompletionItem, Diagnostic, FoldingRange, InlayHint, Location,
16    SemanticTokensFullDeltaResult, SemanticTokensRangeResult, SemanticTokensResult, SignatureHelp,
17};
18use serde_json::Value;
19use std::sync::mpsc;
20
21/// Semantic token responses grouped by request type.
22#[derive(Debug)]
23pub enum LspSemanticTokensResponse {
24    Full(Result<Option<SemanticTokensResult>, String>),
25    FullDelta(Result<Option<SemanticTokensFullDeltaResult>, String>),
26    Range(Result<Option<SemanticTokensRangeResult>, String>),
27}
28
29/// How a completed remote attach is installed.
30pub enum RemoteAttachMode {
31    /// Global: replace the editor's single authority and restart the whole
32    /// editor around the remote backend (the original `setAuthority`-style
33    /// destructive transition). Every window becomes remote.
34    Restart,
35    /// Born-attached: spawn a *new window* whose authority is the remote
36    /// backend, leaving existing (local / other-remote) windows untouched.
37    /// The session coexists warm beside them; switching windows retargets the
38    /// active authority (see `set_active_window` / Gap A). `command` is the
39    /// optional agent argv for the window's seed terminal.
40    Window {
41        label: String,
42        command: Option<Vec<String>>,
43    },
44    /// Reconnect an **existing dormant** session: a remote session restored
45    /// from disk (its backend spec known, but its live authority still the
46    /// local placeholder) whose user just switched to it. Re-point *that
47    /// window's* authority at the freshly-connected backend and park the
48    /// keepalive — no new window, no editor restart.
49    Reconnect { window_id: fresh_core::WindowId },
50}
51
52/// A completed remote-agent attach: the assembled authority plus the
53/// keepalive that must outlive it. Carried back from the async connect
54/// task to the main loop, which installs it per `mode`. Manual `Debug`
55/// because the fields are not `Debug`.
56pub struct RemoteAttachReady {
57    pub authority: crate::services::authority::Authority,
58    pub keepalive: Box<dyn std::any::Any + Send>,
59    /// Pod-side root to re-open the editor at (the remote workspace, e.g.
60    /// `/workspace`). Without this the editor keeps the *local* working
61    /// directory after attach, so the explorer / quick-open / open-file all
62    /// look at a host path that doesn't exist in the pod. `None` falls back to
63    /// the remote home directory.
64    pub working_dir: Option<std::path::PathBuf>,
65    /// Restart (global) vs. born-attached new window.
66    pub mode: RemoteAttachMode,
67    /// Declarative spec for *how to reconnect* this backend — stored on the
68    /// session so a restart / relaunch can bring it back (dormant) and
69    /// reconnect it, rather than degrading it to local.
70    pub spec: crate::services::authority::SessionAuthoritySpec,
71    /// JS callback id of the `attachRemoteAgent` promise to settle once the
72    /// session (authority + window) is fully constructed. The main loop
73    /// resolves it on success and rejects it if window creation fails, so the
74    /// plugin's dialog only closes when there is a real session to show.
75    pub request_id: u64,
76}
77
78impl std::fmt::Debug for RemoteAttachReady {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("RemoteAttachReady")
81            .field("label", &self.authority.display_label)
82            .finish_non_exhaustive()
83    }
84}
85
86/// Messages sent from async tasks to the synchronous main loop
87#[derive(Debug)]
88pub enum AsyncMessage {
89    /// An async `attachRemoteAgent` connect succeeded — install the
90    /// authority + keepalive and restart.
91    RemoteAttachReady(RemoteAttachReady),
92
93    /// An async `attachRemoteAgent` connect failed — reject the plugin's
94    /// promise with `error` (the plugin shows it and creates no window); the
95    /// editor stays on its current authority.
96    RemoteAttachFailed { error: String, request_id: u64 },
97
98    /// LSP diagnostics received for a file
99    LspDiagnostics {
100        uri: String,
101        diagnostics: Vec<Diagnostic>,
102        /// Name of the server that sent these diagnostics (for per-server tracking)
103        server_name: String,
104    },
105
106    /// LSP server initialized successfully
107    LspInitialized {
108        language: String,
109        /// Name of the specific server (for per-server capability tracking)
110        server_name: String,
111        /// Capabilities reported by this server
112        capabilities: crate::services::lsp::manager::ServerCapabilitySummary,
113    },
114
115    /// LSP server crashed or failed
116    LspError {
117        language: String,
118        error: String,
119        /// Path to the stderr log file for this LSP session
120        stderr_log_path: Option<std::path::PathBuf>,
121    },
122
123    /// LSP completion response
124    LspCompletion {
125        request_id: u64,
126        items: Vec<CompletionItem>,
127    },
128
129    /// LSP go-to-definition response
130    LspGotoDefinition {
131        request_id: u64,
132        locations: Vec<Location>,
133    },
134
135    /// LSP rename response
136    LspRename {
137        request_id: u64,
138        result: Result<lsp_types::WorkspaceEdit, String>,
139    },
140
141    /// LSP hover response
142    LspHover {
143        request_id: u64,
144        /// Hover contents as a single string (joined if multiple parts)
145        contents: String,
146        /// Whether the content is markdown (true) or plaintext (false)
147        is_markdown: bool,
148        /// Optional range of the symbol that was hovered over (LSP line/character positions)
149        /// Used to highlight the hovered symbol
150        range: Option<((u32, u32), (u32, u32))>,
151    },
152
153    /// LSP find references response
154    LspReferences {
155        request_id: u64,
156        locations: Vec<Location>,
157    },
158
159    /// LSP signature help response
160    LspSignatureHelp {
161        request_id: u64,
162        signature_help: Option<SignatureHelp>,
163    },
164
165    /// LSP code actions response
166    LspCodeActions {
167        request_id: u64,
168        actions: Vec<CodeActionOrCommand>,
169    },
170
171    /// LSP completionItem/resolve response
172    LspCompletionResolved {
173        request_id: u64,
174        item: Result<lsp_types::CompletionItem, String>,
175    },
176
177    /// LSP textDocument/formatting response
178    LspFormatting {
179        request_id: u64,
180        uri: String,
181        edits: Vec<lsp_types::TextEdit>,
182    },
183
184    /// LSP textDocument/prepareRename response
185    LspPrepareRename {
186        request_id: u64,
187        result: Result<serde_json::Value, String>,
188    },
189
190    /// LSP pulled diagnostics response (textDocument/diagnostic)
191    LspPulledDiagnostics {
192        request_id: u64,
193        uri: String,
194        /// New result_id for incremental updates (None if server doesn't support)
195        result_id: Option<String>,
196        /// Diagnostics (empty if unchanged)
197        diagnostics: Vec<Diagnostic>,
198        /// True if diagnostics haven't changed since previous_result_id
199        unchanged: bool,
200    },
201
202    /// LSP inlay hints response (textDocument/inlayHint)
203    LspInlayHints {
204        request_id: u64,
205        uri: String,
206        /// Inlay hints for the requested range
207        hints: Vec<InlayHint>,
208    },
209
210    /// LSP folding ranges response (textDocument/foldingRange)
211    LspFoldingRanges {
212        request_id: u64,
213        uri: String,
214        ranges: Vec<FoldingRange>,
215    },
216
217    /// LSP semantic tokens response (full, full/delta, or range)
218    LspSemanticTokens {
219        request_id: u64,
220        uri: String,
221        response: LspSemanticTokensResponse,
222    },
223
224    /// LSP server status became quiescent (project fully loaded)
225    /// This is a rust-analyzer specific notification (experimental/serverStatus)
226    LspServerQuiescent { language: String },
227
228    /// LSP server requests diagnostic refresh (workspace/diagnostic/refresh)
229    /// Client should re-pull diagnostics for all open documents
230    LspDiagnosticRefresh { language: String },
231
232    /// LSP server requests an inlay-hint refresh (workspace/inlayHint/refresh).
233    /// Client should re-pull inlay hints for all open documents — used when the
234    /// server learns more later (e.g. a change in file A alters inferred types
235    /// in file B, which the user never edited so was never otherwise re-pulled).
236    LspInlayHintRefresh { language: String },
237
238    /// LSP server requests a semantic-tokens refresh
239    /// (workspace/semanticTokens/refresh). Client should re-pull semantic
240    /// tokens for all open documents.
241    LspSemanticTokensRefresh { language: String },
242
243    /// LSP server registered (`client/registerCapability`) or unregistered
244    /// (`client/unregisterCapability`) one or more capabilities dynamically.
245    /// Many servers advertise little or nothing statically in their
246    /// `initialize` result and instead register providers afterwards, so these
247    /// must update the stored `ServerCapabilities` or the features stay gated
248    /// off for the whole session. `register == false` means unregister.
249    /// Each entry is `(method, register_options)`.
250    LspDynamicCapabilities {
251        language: String,
252        server_name: String,
253        register: bool,
254        registrations: Vec<(String, Option<Value>)>,
255    },
256
257    /// File changed externally (future: file watching)
258    FileChanged { path: String },
259
260    /// Git status updated (future: git integration)
261    GitStatusChanged { status: String },
262
263    /// File explorer initialized with tree view. Carries the id of the window
264    /// that requested it: a background preview/materialize can init a
265    /// *non-active* window's explorer, so the view must land on that window —
266    /// applying it to whatever is active would clobber an unrelated explorer.
267    FileExplorerInitialized {
268        window: fresh_core::WindowId,
269        view: FileTreeView,
270    },
271
272    /// File explorer node toggle completed
273    FileExplorerToggleNode(NodeId),
274
275    /// File explorer node refresh completed
276    FileExplorerRefreshNode(NodeId),
277
278    /// File explorer expand to path completed. Carries the requesting window id
279    /// (see `FileExplorerInitialized`) so the expanded view returns to its own
280    /// window rather than the active one.
281    FileExplorerExpandedToPath {
282        window: fresh_core::WindowId,
283        view: FileTreeView,
284    },
285
286    /// Plugin-related async messages
287    Plugin(fresh_core::api::PluginAsyncMessage),
288
289    /// File open dialog: directory listing completed
290    FileOpenDirectoryLoaded(std::io::Result<Vec<crate::services::fs::DirEntry>>),
291
292    /// File open dialog: async shortcuts (Windows drive letters) loaded
293    FileOpenShortcutsLoaded(Vec<crate::app::file_open::NavigationShortcut>),
294
295    /// Terminal output received (triggers redraw). Tagged with the
296    /// owning window: terminal ids are only unique within a window, so a
297    /// bare id can't be attributed to a session without guessing.
298    TerminalOutput {
299        terminal: fresh_core::WindowTerminalId,
300    },
301
302    /// Result of an asynchronous system-clipboard read. The main loop
303    /// blocks input dispatch while a paste is in flight; the matching
304    /// `request_id` ensures a late result that arrived after the
305    /// timeout fallback fired is discarded as stale. `text` is `None`
306    /// when the read errored, returned empty, or was cancelled by the
307    /// deadline.
308    ClipboardPasteResult {
309        request_id: u64,
310        text: Option<String>,
311    },
312
313    /// File watcher delivered an event for a path under a
314    /// `WatchPath`-registered watcher. Routed to the
315    /// `path_changed` plugin hook by the main loop.
316    PathChanged {
317        /// Watch handle the event came from (matches the value
318        /// returned by `WatchPath`).
319        handle: u64,
320        path: std::path::PathBuf,
321        /// Conservative bucketing of `notify::EventKind`.
322        kind: PathChangeKind,
323    },
324
325    /// Terminal process exited.
326    ///
327    /// `exit_code` is `None` when the editor cannot determine a status
328    /// (the wait happens in a separate thread, signal exits, kill
329    /// before wait, etc.). Populated end-to-end is a follow-up; the
330    /// initial wiring sends `None` so plugin handlers see the variant
331    /// shape that matches `HookArgs::TerminalExited`.
332    TerminalExited {
333        terminal: fresh_core::WindowTerminalId,
334        exit_code: Option<i32>,
335    },
336
337    /// LSP progress notification ($/progress)
338    LspProgress {
339        language: String,
340        token: String,
341        value: LspProgressValue,
342    },
343
344    /// LSP window message (window/showMessage)
345    LspWindowMessage {
346        language: String,
347        message_type: LspMessageType,
348        message: String,
349    },
350
351    /// LSP log message (window/logMessage)
352    LspLogMessage {
353        language: String,
354        message_type: LspMessageType,
355        message: String,
356    },
357
358    /// LSP workspace/applyEdit (server -> client request)
359    /// Server asks client to apply a workspace edit (during executeCommand, etc.)
360    LspApplyEdit {
361        edit: lsp_types::WorkspaceEdit,
362        label: Option<String>,
363    },
364
365    /// LSP codeAction/resolve response
366    LspCodeActionResolved {
367        request_id: u64,
368        action: Result<lsp_types::CodeAction, String>,
369    },
370
371    /// LSP server request (server -> client)
372    /// Used for custom/extension methods that plugins can handle
373    LspServerRequest {
374        language: String,
375        server_command: String,
376        method: String,
377        params: Option<Value>,
378    },
379
380    /// Response for a plugin-initiated LSP request
381    PluginLspResponse {
382        language: String,
383        request_id: u64,
384        result: Result<Value, String>,
385    },
386
387    /// Plugin process completed with output
388    PluginProcessOutput {
389        /// Unique ID for this process (to match with callback)
390        process_id: u64,
391        /// Standard output
392        stdout: String,
393        /// Standard error
394        stderr: String,
395        /// Exit code
396        exit_code: i32,
397    },
398
399    /// LSP server status update (progress, messages, etc.)
400    LspStatusUpdate {
401        language: String,
402        /// Name of the specific server (for multi-server status tracking)
403        server_name: String,
404        status: LspServerStatus,
405        message: Option<String>,
406    },
407
408    /// Background grammar build completed — swap in the new registry.
409    /// `callback_ids` contains plugin callbacks to resolve (empty for the
410    /// initial startup build).
411    GrammarRegistryBuilt {
412        registry: std::sync::Arc<crate::primitives::grammar::GrammarRegistry>,
413        callback_ids: Vec<fresh_core::api::JsCallbackId>,
414    },
415
416    /// Quick Open file list loaded by a background task.
417    /// `complete` is `true` when the scan is finished, `false` for incremental
418    /// partial updates sent while the walk is still in progress.
419    QuickOpenFilesLoaded {
420        /// The working directory the files were enumerated under. Lets
421        /// the editor drop results that arrive after the user has
422        /// switched windows/projects (the cache is keyed by cwd).
423        cwd: String,
424        files: std::sync::Arc<Vec<crate::input::quick_open::providers::FileEntry>>,
425        complete: bool,
426    },
427
428    /// Startup-async: a single plugin directory finished loading on the
429    /// plugin thread. Carries the same payload as the blocking
430    /// `load_plugins_from_dir_with_config` return value.
431    PluginsDirLoaded {
432        dir: std::path::PathBuf,
433        errors: Vec<String>,
434        discovered_plugins: std::collections::HashMap<String, fresh_core::config::PluginConfig>,
435    },
436
437    /// Startup-async: every directory in the startup batch has loaded and
438    /// the resulting `.d.ts` declarations have been collected from the
439    /// plugin runtime. Triggers `init_script::write_plugin_declarations`.
440    PluginDeclarationsReady { declarations: Vec<(String, String)> },
441
442    /// Startup-async: `init.ts` (auto-loaded source plugin) finished
443    /// running its top level and has either succeeded, failed, or was
444    /// skipped/fused. The handler logs and applies the corresponding
445    /// status message, and (on `Loaded`) clears the crash fuse.
446    PluginInitScriptLoaded(PluginInitScriptOutcome),
447}
448
449/// Async equivalent of `init_script::InitOutcome`. Wraps the same set
450/// of states but is plain data so it can travel across the bridge.
451#[derive(Debug, Clone)]
452pub enum PluginInitScriptOutcome {
453    NotFound,
454    Disabled,
455    CrashFused { failures: u32 },
456    Loaded,
457    Failed { message: String },
458}
459
460/// Conservative bucketing of `notify::EventKind`. We don't expose
461/// the full notify enum to plugins because the kind set varies by
462/// platform and changes between notify releases. Plugins switch on
463/// these strings; refining requires a new variant + a new string
464/// (additive, no breakage).
465#[derive(Debug, Clone, Copy)]
466pub enum PathChangeKind {
467    Modify,
468    Create,
469    Delete,
470    Rename,
471    Other,
472}
473
474impl PathChangeKind {
475    pub fn as_str(&self) -> &'static str {
476        match self {
477            PathChangeKind::Modify => "modify",
478            PathChangeKind::Create => "create",
479            PathChangeKind::Delete => "delete",
480            PathChangeKind::Rename => "rename",
481            PathChangeKind::Other => "other",
482        }
483    }
484}
485
486/// LSP progress value types
487#[derive(Debug, Clone)]
488pub enum LspProgressValue {
489    Begin {
490        title: String,
491        message: Option<String>,
492        percentage: Option<u32>,
493    },
494    Report {
495        message: Option<String>,
496        percentage: Option<u32>,
497    },
498    End {
499        message: Option<String>,
500    },
501}
502
503/// LSP message type (corresponds to MessageType in LSP spec)
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub enum LspMessageType {
506    Error = 1,
507    Warning = 2,
508    Info = 3,
509    Log = 4,
510}
511
512/// LSP server status
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514pub enum LspServerStatus {
515    Starting,
516    Initializing,
517    Running,
518    Error,
519    Shutdown,
520}
521
522/// Bridge between async Tokio runtime and sync main loop
523///
524/// Design:
525/// - Lightweight, cloneable sender that can be passed to async tasks
526/// - Non-blocking receiver checked each frame in main loop
527/// - No locks needed in main loop (channel handles synchronization)
528#[derive(Clone)]
529pub struct AsyncBridge {
530    sender: mpsc::Sender<AsyncMessage>,
531    // Receiver wrapped in Arc<Mutex<>> to allow cloning
532    receiver: std::sync::Arc<std::sync::Mutex<mpsc::Receiver<AsyncMessage>>>,
533}
534
535impl AsyncBridge {
536    /// Create a new async bridge with an unbounded channel
537    ///
538    /// Unbounded is appropriate here because:
539    /// 1. Main loop processes messages every 16ms (60fps)
540    /// 2. LSP messages are infrequent (< 100/sec typically)
541    /// 3. Memory usage is bounded by message rate × frame time
542    pub fn new() -> Self {
543        let (sender, receiver) = mpsc::channel();
544        Self {
545            sender,
546            receiver: std::sync::Arc::new(std::sync::Mutex::new(receiver)),
547        }
548    }
549
550    /// Get a cloneable sender for async tasks
551    ///
552    /// This sender can be:
553    /// - Cloned freely (cheap Arc internally)
554    /// - Sent to async tasks
555    /// - Stored in LspClient instances
556    pub fn sender(&self) -> mpsc::Sender<AsyncMessage> {
557        self.sender.clone()
558    }
559
560    /// Try to receive pending messages (non-blocking)
561    ///
562    /// Called each frame in the main loop to process async messages.
563    /// Returns all pending messages without blocking.
564    pub fn try_recv_all(&self) -> Vec<AsyncMessage> {
565        let mut messages = Vec::new();
566
567        // Lock the receiver and drain all pending messages
568        if let Ok(receiver) = self.receiver.lock() {
569            while let Ok(msg) = receiver.try_recv() {
570                messages.push(msg);
571            }
572        }
573
574        messages
575    }
576
577    /// Check if there are pending messages (non-blocking)
578    pub fn has_messages(&self) -> bool {
579        // Note: This is racy but safe - only used for optimization
580        if let Ok(receiver) = self.receiver.lock() {
581            receiver.try_recv().is_ok()
582        } else {
583            false
584        }
585    }
586}
587
588impl Default for AsyncBridge {
589    fn default() -> Self {
590        Self::new()
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn test_async_bridge_send_receive() {
600        let bridge = AsyncBridge::new();
601        let sender = bridge.sender();
602
603        // Send a message
604        sender
605            .send(AsyncMessage::LspInitialized {
606                language: "rust".to_string(),
607                server_name: "test".to_string(),
608                capabilities: Default::default(),
609            })
610            .unwrap();
611
612        // Receive it
613        let messages = bridge.try_recv_all();
614        assert_eq!(messages.len(), 1);
615
616        match &messages[0] {
617            AsyncMessage::LspInitialized {
618                language,
619                server_name,
620                ..
621            } => {
622                assert_eq!(language, "rust");
623                assert_eq!(server_name, "test");
624            }
625            _ => panic!("Wrong message type"),
626        }
627    }
628
629    #[test]
630    fn test_async_bridge_multiple_messages() {
631        let bridge = AsyncBridge::new();
632        let sender = bridge.sender();
633
634        // Send multiple messages
635        sender
636            .send(AsyncMessage::LspInitialized {
637                language: "rust".to_string(),
638                server_name: "test".to_string(),
639                capabilities: Default::default(),
640            })
641            .unwrap();
642        sender
643            .send(AsyncMessage::LspInitialized {
644                language: "typescript".to_string(),
645                server_name: "test".to_string(),
646                capabilities: Default::default(),
647            })
648            .unwrap();
649
650        // Receive all at once
651        let messages = bridge.try_recv_all();
652        assert_eq!(messages.len(), 2);
653    }
654
655    #[test]
656    fn test_async_bridge_no_messages() {
657        let bridge = AsyncBridge::new();
658
659        // Try to receive with no messages
660        let messages = bridge.try_recv_all();
661        assert_eq!(messages.len(), 0);
662    }
663
664    #[test]
665    fn test_async_bridge_clone_sender() {
666        let bridge = AsyncBridge::new();
667        let sender1 = bridge.sender();
668        let sender2 = sender1.clone();
669
670        // Both senders work
671        sender1
672            .send(AsyncMessage::LspInitialized {
673                language: "rust".to_string(),
674                server_name: "test".to_string(),
675                capabilities: Default::default(),
676            })
677            .unwrap();
678        sender2
679            .send(AsyncMessage::LspInitialized {
680                language: "typescript".to_string(),
681                server_name: "test".to_string(),
682                capabilities: Default::default(),
683            })
684            .unwrap();
685
686        let messages = bridge.try_recv_all();
687        assert_eq!(messages.len(), 2);
688    }
689
690    #[test]
691    fn test_async_bridge_diagnostics() {
692        let bridge = AsyncBridge::new();
693        let sender = bridge.sender();
694
695        // Send diagnostic message
696        let diagnostics = vec![lsp_types::Diagnostic {
697            range: lsp_types::Range {
698                start: lsp_types::Position {
699                    line: 0,
700                    character: 0,
701                },
702                end: lsp_types::Position {
703                    line: 0,
704                    character: 5,
705                },
706            },
707            severity: Some(lsp_types::DiagnosticSeverity::ERROR),
708            code: None,
709            code_description: None,
710            source: Some("rust-analyzer".to_string()),
711            message: "test error".to_string(),
712            related_information: None,
713            tags: None,
714            data: None,
715        }];
716
717        sender
718            .send(AsyncMessage::LspDiagnostics {
719                uri: "file:///test.rs".to_string(),
720                diagnostics: diagnostics.clone(),
721                server_name: "rust-analyzer".to_string(),
722            })
723            .unwrap();
724
725        let messages = bridge.try_recv_all();
726        assert_eq!(messages.len(), 1);
727
728        match &messages[0] {
729            AsyncMessage::LspDiagnostics {
730                uri,
731                diagnostics: diags,
732                server_name,
733            } => {
734                assert_eq!(uri, "file:///test.rs");
735                assert_eq!(diags.len(), 1);
736                assert_eq!(diags[0].message, "test error");
737                assert_eq!(server_name, "rust-analyzer");
738            }
739            _ => panic!("Expected LspDiagnostics message"),
740        }
741    }
742
743    #[test]
744    fn test_async_bridge_error_message() {
745        let bridge = AsyncBridge::new();
746        let sender = bridge.sender();
747
748        sender
749            .send(AsyncMessage::LspError {
750                language: "rust".to_string(),
751                error: "Failed to initialize".to_string(),
752                stderr_log_path: None,
753            })
754            .unwrap();
755
756        let messages = bridge.try_recv_all();
757        assert_eq!(messages.len(), 1);
758
759        match &messages[0] {
760            AsyncMessage::LspError {
761                language,
762                error,
763                stderr_log_path,
764            } => {
765                assert_eq!(language, "rust");
766                assert_eq!(error, "Failed to initialize");
767                assert!(stderr_log_path.is_none());
768            }
769            _ => panic!("Expected LspError message"),
770        }
771    }
772
773    #[test]
774    fn test_async_bridge_clone_bridge() {
775        let bridge = AsyncBridge::new();
776        let bridge_clone = bridge.clone();
777        let sender = bridge.sender();
778
779        // Send via original bridge's sender
780        sender
781            .send(AsyncMessage::LspInitialized {
782                language: "rust".to_string(),
783                server_name: "test".to_string(),
784                capabilities: Default::default(),
785            })
786            .unwrap();
787
788        // Receive via cloned bridge
789        let messages = bridge_clone.try_recv_all();
790        assert_eq!(messages.len(), 1);
791    }
792
793    #[test]
794    fn test_async_bridge_multiple_calls_to_try_recv_all() {
795        let bridge = AsyncBridge::new();
796        let sender = bridge.sender();
797
798        sender
799            .send(AsyncMessage::LspInitialized {
800                language: "rust".to_string(),
801                server_name: "test".to_string(),
802                capabilities: Default::default(),
803            })
804            .unwrap();
805
806        // First call gets the message
807        let messages1 = bridge.try_recv_all();
808        assert_eq!(messages1.len(), 1);
809
810        // Second call gets nothing
811        let messages2 = bridge.try_recv_all();
812        assert_eq!(messages2.len(), 0);
813    }
814
815    #[test]
816    fn test_async_bridge_ordering() {
817        let bridge = AsyncBridge::new();
818        let sender = bridge.sender();
819
820        // Send messages in order
821        sender
822            .send(AsyncMessage::LspInitialized {
823                language: "rust".to_string(),
824                server_name: "test".to_string(),
825                capabilities: Default::default(),
826            })
827            .unwrap();
828        sender
829            .send(AsyncMessage::LspInitialized {
830                language: "typescript".to_string(),
831                server_name: "test".to_string(),
832                capabilities: Default::default(),
833            })
834            .unwrap();
835        sender
836            .send(AsyncMessage::LspInitialized {
837                language: "python".to_string(),
838                server_name: "test".to_string(),
839                capabilities: Default::default(),
840            })
841            .unwrap();
842
843        // Messages should be received in same order
844        let messages = bridge.try_recv_all();
845        assert_eq!(messages.len(), 3);
846
847        match (&messages[0], &messages[1], &messages[2]) {
848            (
849                AsyncMessage::LspInitialized { language: l1, .. },
850                AsyncMessage::LspInitialized { language: l2, .. },
851                AsyncMessage::LspInitialized { language: l3, .. },
852            ) => {
853                assert_eq!(l1, "rust");
854                assert_eq!(l2, "typescript");
855                assert_eq!(l3, "python");
856            }
857            _ => panic!("Expected ordered LspInitialized messages"),
858        }
859    }
860}