Skip to main content

fresh/server/
editor_server.rs

1//! Editor integration with the session server
2//!
3//! This module bridges the Editor with the server infrastructure:
4//! - Creates Editor with CaptureBackend for rendering
5//! - Processes input events from clients
6//! - Broadcasts rendered output to all clients
7
8use std::io;
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::mpsc;
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14
15use crossterm::event::{Event, KeyEventKind};
16use ratatui::Terminal;
17
18use crate::app::Editor;
19use crate::config::Config;
20use crate::config_io::DirectoryContext;
21use crate::model::filesystem::{FileSystem, StdFileSystem};
22use crate::server::capture_backend::{
23    terminal_setup_sequences, terminal_teardown_sequences, CaptureBackend,
24};
25use crate::server::input_parser::InputParser;
26use crate::server::ipc::{ServerConnection, ServerListener, SocketPaths, StreamWrapper};
27use crate::server::protocol::{
28    ClientControl, ServerControl, ServerHello, TermSize, VersionMismatch, PROTOCOL_VERSION,
29};
30use crate::view::color_support::ColorCapability;
31
32/// Configuration for the editor server
33#[derive(Debug, Clone)]
34pub struct EditorServerConfig {
35    /// Working directory for this session
36    pub working_dir: PathBuf,
37    /// Optional session name
38    pub session_name: Option<String>,
39    /// Idle timeout before auto-shutdown
40    pub idle_timeout: Option<Duration>,
41    /// Editor configuration
42    pub editor_config: Config,
43    /// Directory context for config/data paths
44    pub dir_context: DirectoryContext,
45    /// Whether plugins are enabled
46    pub plugins_enabled: bool,
47}
48
49/// Editor server that manages editor state and client connections
50pub struct EditorServer {
51    config: EditorServerConfig,
52    listener: ServerListener,
53    clients: Vec<ConnectedClient>,
54    editor: Option<Editor>,
55    terminal: Option<Terminal<CaptureBackend>>,
56    last_client_activity: Instant,
57    shutdown: Arc<AtomicBool>,
58    /// Effective terminal size (from the primary/first client)
59    term_size: TermSize,
60    /// Index of the client that most recently provided input (for per-client detach)
61    last_input_client: Option<usize>,
62    /// Next wait ID for --wait tracking
63    next_wait_id: u64,
64    /// Maps wait_id → client_id for clients waiting on file events
65    waiting_clients: std::collections::HashMap<u64, u64>,
66}
67
68/// Buffered writer for sending data to a client without blocking the server loop.
69///
70/// Spawns a background thread that receives data via a bounded channel and
71/// writes it to the client's data pipe. If the channel fills up (client is
72/// too slow to read), frames are dropped. If the pipe breaks, the `pipe_broken`
73/// flag is set so the main loop can disconnect the client.
74struct ClientDataWriter {
75    sender: mpsc::SyncSender<Vec<u8>>,
76    pipe_broken: Arc<AtomicBool>,
77}
78
79impl ClientDataWriter {
80    /// Create a new writer that spawns a background thread to write to the data stream.
81    fn new(data: StreamWrapper, client_id: u64) -> Self {
82        // 16 frames of buffer (~270ms at 60fps before dropping frames)
83        let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(16);
84        let pipe_broken = Arc::new(AtomicBool::new(false));
85        let pipe_broken_clone = pipe_broken.clone();
86
87        std::thread::Builder::new()
88            .name(format!("client-{}-writer", client_id))
89            .spawn(move || {
90                while let Ok(buf) = rx.recv() {
91                    if let Err(e) = data.write_all(&buf) {
92                        tracing::debug!("Client {} writer pipe error: {}", client_id, e);
93                        pipe_broken_clone.store(true, Ordering::Relaxed);
94                        break;
95                    }
96                    if let Err(e) = data.flush() {
97                        tracing::debug!("Client {} writer flush error: {}", client_id, e);
98                        pipe_broken_clone.store(true, Ordering::Relaxed);
99                        break;
100                    }
101                }
102                tracing::debug!("Client {} writer thread exiting", client_id);
103            })
104            .expect("Failed to spawn client writer thread");
105
106        Self {
107            sender: tx,
108            pipe_broken,
109        }
110    }
111
112    /// Try to send data without blocking. Returns false if the channel is full
113    /// (client too slow) or the writer thread has exited.
114    fn try_write(&self, data: &[u8]) -> bool {
115        self.sender.try_send(data.to_vec()).is_ok()
116    }
117
118    /// Check if the writer thread detected a broken pipe.
119    fn is_broken(&self) -> bool {
120        self.pipe_broken.load(Ordering::Relaxed)
121    }
122}
123
124/// A connected client with its own input parser
125struct ConnectedClient {
126    conn: ServerConnection,
127    /// Background writer for non-blocking data output
128    data_writer: ClientDataWriter,
129    term_size: TermSize,
130    env: std::collections::HashMap<String, Option<String>>,
131    id: u64,
132    input_parser: InputParser,
133    /// Whether this client needs a full screen render on next frame
134    needs_full_render: bool,
135    /// If set, this client is waiting for a --wait completion signal
136    wait_id: Option<u64>,
137}
138
139impl EditorServer {
140    /// Create a new editor server
141    pub fn new(config: EditorServerConfig) -> io::Result<Self> {
142        let socket_paths = if let Some(ref name) = config.session_name {
143            SocketPaths::for_session_name(name)?
144        } else {
145            SocketPaths::for_working_dir(&config.working_dir)?
146        };
147
148        let listener = ServerListener::bind(socket_paths)?;
149
150        // Write PID file so clients can detect stale sessions
151        let pid = std::process::id();
152        if let Err(e) = listener.paths().write_pid(pid) {
153            tracing::warn!("Failed to write PID file: {}", e);
154        }
155
156        Ok(Self {
157            config,
158            listener,
159            clients: Vec::new(),
160            editor: None,
161            terminal: None,
162            last_client_activity: Instant::now(),
163            shutdown: Arc::new(AtomicBool::new(false)),
164            term_size: TermSize::new(80, 24), // Default until first client connects
165            last_input_client: None,
166            next_wait_id: 1,
167            waiting_clients: std::collections::HashMap::new(),
168        })
169    }
170
171    /// Get a handle to request shutdown
172    pub fn shutdown_handle(&self) -> Arc<AtomicBool> {
173        self.shutdown.clone()
174    }
175
176    /// Get the socket paths
177    pub fn socket_paths(&self) -> &SocketPaths {
178        self.listener.paths()
179    }
180
181    /// Run the editor server main loop
182    pub fn run(&mut self) -> io::Result<()> {
183        tracing::info!("Editor server starting for {:?}", self.config.working_dir);
184
185        let mut next_client_id = 1u64;
186        let mut needs_render = true;
187        let mut last_render = Instant::now();
188        const FRAME_DURATION: Duration = Duration::from_millis(16); // 60fps
189
190        loop {
191            // Check for shutdown
192            if self.shutdown.load(Ordering::SeqCst) {
193                tracing::info!("Shutdown requested");
194                break;
195            }
196
197            // Check idle timeout
198            if let Some(timeout) = self.config.idle_timeout {
199                if self.clients.is_empty() && self.last_client_activity.elapsed() > timeout {
200                    tracing::info!("Idle timeout reached, shutting down");
201                    break;
202                }
203            }
204
205            // Accept new connections
206            tracing::debug!("[server] main loop: calling accept()");
207            match self.listener.accept() {
208                Ok(Some(conn)) => {
209                    // Get current cursor style from editor if it exists, otherwise from config
210                    let cursor_style = self
211                        .editor
212                        .as_ref()
213                        .map(|e| e.config().editor.cursor_style)
214                        .unwrap_or(self.config.editor_config.editor.cursor_style);
215                    match self.handle_new_connection(conn, next_client_id, cursor_style) {
216                        Ok(client) => {
217                            tracing::info!("Client {} connected", client.id);
218
219                            // Initialize editor on first-ever client, or update size if reconnecting
220                            if self.editor.is_none() {
221                                // First time - initialize editor
222                                self.term_size = client.term_size;
223                                self.initialize_editor()?;
224                            } else if self.clients.is_empty() {
225                                // Reconnecting after all clients disconnected - update terminal size
226                                if self.term_size != client.term_size {
227                                    self.term_size = client.term_size;
228                                    self.update_terminal_size()?;
229                                }
230                            }
231                            // Note: full redraw is handled via client.needs_full_render flag
232
233                            self.clients.push(client);
234                            self.last_client_activity = Instant::now();
235                            next_client_id += 1;
236                            needs_render = true;
237                        }
238                        Err(e) => {
239                            tracing::warn!("Failed to complete handshake: {}", e);
240                        }
241                    }
242                }
243                Ok(None) => {}
244                Err(e) => {
245                    tracing::error!("Accept error: {}", e);
246                }
247            }
248
249            // Process client messages and get input events
250            tracing::debug!("[server] main loop: calling process_clients");
251            let (input_events, resize_occurred, input_source) = self.process_clients()?;
252            if let Some(idx) = input_source {
253                self.last_input_client = Some(idx);
254            }
255            if !input_events.is_empty() {
256                tracing::debug!(
257                    "[server] process_clients returned {} events",
258                    input_events.len()
259                );
260            }
261
262            // Check if editor should quit
263            if let Some(ref editor) = self.editor {
264                if editor.should_quit() {
265                    tracing::info!("Editor requested quit");
266                    self.shutdown.store(true, Ordering::SeqCst);
267                    continue;
268                }
269            }
270
271            // Check if client should detach (keep server running)
272            let detach_requested = self
273                .editor
274                .as_ref()
275                .map(|e| e.should_detach())
276                .unwrap_or(false);
277            if detach_requested {
278                // Detach only the client that triggered it (via last input)
279                if let Some(idx) = self.last_input_client.take() {
280                    if idx < self.clients.len() {
281                        tracing::info!("Client {} requested detach", self.clients[idx].id);
282                        let client = self.clients.remove(idx);
283                        let teardown = terminal_teardown_sequences();
284                        // Best-effort: client may already be disconnected
285                        #[allow(clippy::let_underscore_must_use)]
286                        let _ = client.data_writer.try_write(&teardown);
287                        let quit_msg = serde_json::to_string(&ServerControl::Quit {
288                            reason: "Detached".to_string(),
289                        })
290                        .unwrap_or_default();
291                        // Best-effort: client may already be disconnected
292                        #[allow(clippy::let_underscore_must_use)]
293                        let _ = client.conn.write_control(&quit_msg);
294                    }
295                } else {
296                    // Fallback: if we can't determine which client, detach all
297                    tracing::info!("Detach requested but no input source, detaching all");
298                    self.disconnect_all_clients("Detached")?;
299                }
300                // Reset the detach flag
301                if let Some(ref mut editor) = self.editor {
302                    editor.clear_detach();
303                }
304                continue;
305            }
306
307            // Handle resize
308            if resize_occurred {
309                self.update_terminal_size()?;
310                needs_render = true;
311            }
312
313            // Process input events
314            if !input_events.is_empty() {
315                self.last_client_activity = Instant::now();
316                for event in input_events {
317                    if self.handle_event(event)? {
318                        needs_render = true;
319                    }
320                }
321            }
322
323            // Process async messages from editor
324            if let Some(ref mut editor) = self.editor {
325                if editor.process_async_messages() {
326                    needs_render = true;
327                }
328                if editor.process_pending_file_opens() {
329                    needs_render = true;
330                }
331
332                // Process completed --wait operations
333                for wait_id in editor.take_completed_waits() {
334                    if let Some(client_id) = self.waiting_clients.remove(&wait_id) {
335                        // Find the client and send WaitComplete
336                        if let Some(client) = self.clients.iter_mut().find(|c| c.id == client_id) {
337                            let msg = serde_json::to_string(&ServerControl::WaitComplete)
338                                .unwrap_or_default();
339                            #[allow(clippy::let_underscore_must_use)]
340                            let _ = client.conn.write_control(&msg);
341                            client.wait_id = None;
342                        }
343                    }
344                }
345
346                if editor.check_mouse_hover_timer() {
347                    needs_render = true;
348                }
349            }
350
351            // Render and broadcast if needed
352            if needs_render && last_render.elapsed() >= FRAME_DURATION {
353                self.render_and_broadcast()?;
354                last_render = Instant::now();
355                needs_render = false;
356            }
357
358            // Brief sleep to avoid busy-waiting
359            std::thread::sleep(Duration::from_millis(5));
360        }
361
362        // Clean shutdown
363        self.disconnect_all_clients("Server shutting down")?;
364
365        Ok(())
366    }
367
368    /// Initialize the editor with the current terminal size
369    fn initialize_editor(&mut self) -> io::Result<()> {
370        let backend = CaptureBackend::new(self.term_size.cols, self.term_size.rows);
371        let terminal = Terminal::new(backend)
372            .map_err(|e| io::Error::other(format!("Failed to create terminal: {}", e)))?;
373
374        let filesystem: Arc<dyn FileSystem + Send + Sync> = Arc::new(StdFileSystem);
375        let color_capability = ColorCapability::TrueColor; // Assume truecolor for now
376
377        let mut editor = Editor::with_working_dir(
378            self.config.editor_config.clone(),
379            self.term_size.cols,
380            self.term_size.rows,
381            Some(self.config.working_dir.clone()),
382            self.config.dir_context.clone(),
383            self.config.plugins_enabled,
384            color_capability,
385            filesystem,
386        )
387        .map_err(|e| io::Error::other(format!("Failed to create editor: {}", e)))?;
388
389        // Enable session mode - use hardware cursor only, no REVERSED software cursor
390        editor.set_session_mode(true);
391
392        // Set session name for status bar display
393        let session_display_name = self.config.session_name.clone().unwrap_or_else(|| {
394            // Use the directory name as a short display name for unnamed sessions
395            self.config
396                .working_dir
397                .file_name()
398                .and_then(|n| n.to_str())
399                .map(|s| s.to_string())
400                .unwrap_or_else(|| "session".to_string())
401        });
402        editor.set_session_name(Some(session_display_name));
403
404        self.terminal = Some(terminal);
405        self.editor = Some(editor);
406
407        tracing::info!(
408            "Editor initialized with size {}x{}",
409            self.term_size.cols,
410            self.term_size.rows
411        );
412
413        Ok(())
414    }
415
416    /// Handle a new client connection
417    fn handle_new_connection(
418        &self,
419        conn: ServerConnection,
420        client_id: u64,
421        cursor_style: crate::config::CursorStyle,
422    ) -> io::Result<ConnectedClient> {
423        // Read client hello
424        // On Windows, don't toggle blocking mode - named pipes don't support mode switching
425        // after connection. The read_control() method handles this internally.
426        #[cfg(not(windows))]
427        conn.control.set_nonblocking(false)?;
428        let hello_json = conn
429            .read_control()?
430            .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "No hello received"))?;
431
432        let client_msg: ClientControl = serde_json::from_str(&hello_json)
433            .map_err(|e| io::Error::other(format!("Invalid hello: {}", e)))?;
434
435        let hello = match client_msg {
436            ClientControl::Hello(h) => h,
437            _ => {
438                return Err(io::Error::other("Expected Hello message"));
439            }
440        };
441
442        // Check protocol version
443        if hello.protocol_version != PROTOCOL_VERSION {
444            let mismatch = VersionMismatch {
445                server_version: env!("CARGO_PKG_VERSION").to_string(),
446                client_version: hello.client_version.clone(),
447                action: if hello.protocol_version > PROTOCOL_VERSION {
448                    "upgrade_server".to_string()
449                } else {
450                    "restart_server".to_string()
451                },
452                message: format!(
453                    "Protocol version mismatch: server={}, client={}",
454                    PROTOCOL_VERSION, hello.protocol_version
455                ),
456            };
457
458            let response = serde_json::to_string(&ServerControl::VersionMismatch(mismatch))
459                .map_err(|e| io::Error::other(e.to_string()))?;
460            conn.write_control(&response)?;
461
462            return Err(io::Error::other("Version mismatch"));
463        }
464
465        // Send server hello
466        let session_id = self.config.session_name.clone().unwrap_or_else(|| {
467            crate::workspace::encode_path_for_filename(&self.config.working_dir)
468        });
469
470        let server_hello = ServerHello::new(session_id);
471        let response = serde_json::to_string(&ServerControl::Hello(server_hello))
472            .map_err(|e| io::Error::other(e.to_string()))?;
473        conn.write_control(&response)?;
474
475        // Set sockets back to non-blocking
476        // On Windows, don't toggle mode - named pipes don't support mode switching
477        #[cfg(not(windows))]
478        conn.control.set_nonblocking(true)?;
479
480        // Send terminal setup sequences
481        let setup = terminal_setup_sequences();
482        conn.write_data(&setup)?;
483
484        // Send cursor style (from editor if running, otherwise from config)
485        conn.write_data(cursor_style.to_escape_sequence())?;
486
487        tracing::debug!(
488            "Client {} connected: {}x{}, TERM={:?}",
489            client_id,
490            hello.term_size.cols,
491            hello.term_size.rows,
492            hello.term()
493        );
494
495        // Create background writer for non-blocking render output
496        let data_writer = ClientDataWriter::new(conn.data.clone(), client_id);
497
498        Ok(ConnectedClient {
499            conn,
500            data_writer,
501            term_size: hello.term_size,
502            env: hello.env,
503            id: client_id,
504            input_parser: InputParser::new(),
505            needs_full_render: true,
506            wait_id: None,
507        })
508    }
509
510    /// Process messages from connected clients
511    /// Returns (input_events, resize_occurred, index of client that provided input)
512    fn process_clients(&mut self) -> io::Result<(Vec<Event>, bool, Option<usize>)> {
513        let mut disconnected = Vec::new();
514        let mut input_source_client: Option<usize> = None;
515        let mut input_events = Vec::new();
516        let mut resize_occurred = false;
517        let mut control_messages: Vec<(usize, ClientControl)> = Vec::new();
518
519        for (idx, client) in self.clients.iter_mut().enumerate() {
520            // Read from data socket
521            let mut buf = [0u8; 4096];
522            let mut data_eof = false;
523            tracing::debug!("[server] reading from client {} data socket", client.id);
524            match client.conn.read_data(&mut buf) {
525                Ok(0) => {
526                    tracing::debug!("[server] Client {} data stream closed (EOF)", client.id);
527                    // Don't disconnect waiting clients on data EOF - they're not sending data
528                    if client.wait_id.is_none() {
529                        disconnected.push(idx);
530                    }
531                    data_eof = true;
532                    // Don't continue - still need to check control socket for pending messages
533                }
534                Ok(n) => {
535                    tracing::debug!(
536                        "[server] Client {} read {} bytes from data socket",
537                        client.id,
538                        n
539                    );
540                    let events = client.input_parser.parse(&buf[..n]);
541                    tracing::debug!(
542                        "[server] Client {} parsed {} events",
543                        client.id,
544                        events.len()
545                    );
546                    if !events.is_empty() {
547                        input_source_client = Some(idx);
548                    }
549                    input_events.extend(events);
550                }
551                Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
552                    // No data available - check if we have a pending escape sequence
553                    // that should be flushed due to timeout
554                    let timeout_events = client.input_parser.flush_timeout();
555                    if !timeout_events.is_empty() {
556                        input_source_client = Some(idx);
557                        input_events.extend(timeout_events);
558                    }
559                }
560                Err(e) => {
561                    tracing::warn!("[server] Client {} data read error: {}", client.id, e);
562                    disconnected.push(idx);
563                    data_eof = true;
564                    // Don't continue - still need to check control socket for pending messages
565                }
566            }
567            let _ = data_eof; // Suppress unused warning
568
569            // Check control socket
570            // On Windows, don't toggle nonblocking mode - it fails on named pipes
571            // Best-effort: nonblocking mode for control socket polling
572            #[cfg(not(windows))]
573            #[allow(clippy::let_underscore_must_use)]
574            let _ = client.conn.control.set_nonblocking(true);
575
576            // On Windows, use try_read pattern instead of blocking read_line
577            #[cfg(windows)]
578            {
579                let mut buf = [0u8; 1024];
580                match client.conn.control.try_read(&mut buf) {
581                    Ok(0) => {
582                        tracing::debug!("Client {} control stream closed (EOF)", client.id);
583                        disconnected.push(idx);
584                    }
585                    Ok(n) => {
586                        // Try to parse as control message
587                        if let Ok(s) = std::str::from_utf8(&buf[..n]) {
588                            for line in s.lines() {
589                                if !line.trim().is_empty() {
590                                    if let Ok(msg) = serde_json::from_str::<ClientControl>(line) {
591                                        control_messages.push((idx, msg));
592                                    }
593                                }
594                            }
595                        }
596                    }
597                    Err(e) if e.kind() == io::ErrorKind::WouldBlock => {}
598                    Err(e) => {
599                        tracing::warn!("Client {} control read error: {}", client.id, e);
600                    }
601                }
602            }
603
604            #[cfg(not(windows))]
605            {
606                let mut reader = std::io::BufReader::new(&client.conn.control);
607                let mut line = String::new();
608                match std::io::BufRead::read_line(&mut reader, &mut line) {
609                    Ok(0) => {
610                        tracing::debug!("Client {} control stream closed (EOF)", client.id);
611                        disconnected.push(idx);
612                    }
613                    Ok(_) if !line.trim().is_empty() => {
614                        if let Ok(msg) = serde_json::from_str::<ClientControl>(&line) {
615                            control_messages.push((idx, msg));
616                        }
617                    }
618                    Ok(_) => {}
619                    Err(e) if e.kind() == io::ErrorKind::WouldBlock => {}
620                    Err(e) => {
621                        tracing::warn!("Client {} control read error: {}", client.id, e);
622                    }
623                }
624            }
625        }
626
627        // Process control messages
628        eprintln!(
629            "[server] Processing {} control messages",
630            control_messages.len()
631        );
632        for (idx, msg) in control_messages {
633            eprintln!("[server] Control message from client {}: {:?}", idx, msg);
634            // Always process Quit, even from disconnected clients
635            if let ClientControl::Quit = msg {
636                tracing::info!("Client requested quit, shutting down");
637                self.shutdown.store(true, Ordering::SeqCst);
638                continue;
639            }
640
641            // Always process OpenFiles - it's a one-shot command from clients that disconnect immediately
642            if let ClientControl::OpenFiles { .. } = msg {
643                // Fall through to process it
644            } else if disconnected.contains(&idx) {
645                // Skip other messages from disconnected clients
646                continue;
647            }
648
649            match msg {
650                ClientControl::Hello(_) => {
651                    tracing::warn!("Unexpected Hello from client");
652                }
653                ClientControl::Resize { cols, rows } => {
654                    if let Some(client) = self.clients.get_mut(idx) {
655                        client.term_size = TermSize::new(cols, rows);
656                        // Update server size to match first client
657                        if idx == 0 {
658                            self.term_size = TermSize::new(cols, rows);
659                            resize_occurred = true;
660                        }
661                    }
662                }
663                ClientControl::Ping => {
664                    if let Some(client) = self.clients.get_mut(idx) {
665                        let pong = serde_json::to_string(&ServerControl::Pong).unwrap_or_default();
666                        // Best-effort pong reply
667                        #[allow(clippy::let_underscore_must_use)]
668                        let _ = client.conn.write_control(&pong);
669                    }
670                }
671                ClientControl::Detach => {
672                    tracing::info!("Client {} detached", idx);
673                    disconnected.push(idx);
674                }
675                ClientControl::OpenFiles { files, wait } => {
676                    if let Some(ref mut editor) = self.editor {
677                        // Assign a wait_id if --wait was requested
678                        let wait_id = if wait {
679                            let id = self.next_wait_id;
680                            self.next_wait_id += 1;
681                            Some(id)
682                        } else {
683                            None
684                        };
685
686                        let file_count = files.len();
687                        for (i, file_req) in files.iter().enumerate() {
688                            let path = std::path::PathBuf::from(&file_req.path);
689                            tracing::debug!(
690                                "Queuing file open: {:?} line={:?} col={:?} end_line={:?} end_col={:?} message={:?}",
691                                path,
692                                file_req.line,
693                                file_req.column,
694                                file_req.end_line,
695                                file_req.end_column,
696                                file_req.message,
697                            );
698                            // Only the last file gets the wait_id (it's the one that will be active)
699                            let file_wait_id = if i == file_count - 1 { wait_id } else { None };
700                            editor.queue_file_open(
701                                path,
702                                file_req.line,
703                                file_req.column,
704                                file_req.end_line,
705                                file_req.end_column,
706                                file_req.message.clone(),
707                                file_wait_id,
708                            );
709                        }
710
711                        // Track the waiting client
712                        if let Some(wait_id) = wait_id {
713                            if let Some(client) = self.clients.get_mut(idx) {
714                                self.waiting_clients.insert(wait_id, client.id);
715                                client.wait_id = Some(wait_id);
716                            }
717                        }
718
719                        resize_occurred = true; // Force re-render
720                    }
721                }
722                ClientControl::Quit => unreachable!(), // Handled above
723            }
724        }
725
726        // Check for clients with broken write pipes
727        for (idx, client) in self.clients.iter().enumerate() {
728            if client.data_writer.is_broken() && !disconnected.contains(&idx) {
729                tracing::info!("Client {} write pipe broken, disconnecting", client.id);
730                disconnected.push(idx);
731            }
732        }
733
734        // Deduplicate and sort for safe reverse removal
735        disconnected.sort_unstable();
736        disconnected.dedup();
737
738        // Remove disconnected clients
739        for idx in disconnected.into_iter().rev() {
740            let client = self.clients.remove(idx);
741            // Clean up --wait tracking if this client was waiting
742            if let Some(wait_id) = client.wait_id {
743                self.waiting_clients.remove(&wait_id);
744                // Also clean up editor wait_tracking for this wait_id
745                if let Some(ref mut editor) = self.editor {
746                    editor.remove_wait_tracking(wait_id);
747                }
748            }
749            // Best-effort teardown via the non-blocking writer
750            let teardown = terminal_teardown_sequences();
751            let _ = client.data_writer.try_write(&teardown);
752            tracing::info!("Client {} disconnected", client.id);
753            // Invalidate input source if that client disconnected
754            if input_source_client == Some(idx) {
755                input_source_client = None;
756            }
757        }
758
759        Ok((input_events, resize_occurred, input_source_client))
760    }
761
762    /// Update terminal size after resize
763    fn update_terminal_size(&mut self) -> io::Result<()> {
764        if let Some(ref mut terminal) = self.terminal {
765            let backend = terminal.backend_mut();
766            backend.resize(self.term_size.cols, self.term_size.rows);
767        }
768
769        if let Some(ref mut editor) = self.editor {
770            editor.resize(self.term_size.cols, self.term_size.rows);
771        }
772
773        Ok(())
774    }
775
776    /// Handle an input event
777    fn handle_event(&mut self, event: Event) -> io::Result<bool> {
778        let Some(ref mut editor) = self.editor else {
779            return Ok(false);
780        };
781
782        match event {
783            Event::Key(key_event) => {
784                if key_event.kind == KeyEventKind::Press {
785                    editor
786                        .handle_key(key_event.code, key_event.modifiers)
787                        .map_err(|e| io::Error::other(e.to_string()))?;
788                    Ok(true)
789                } else {
790                    Ok(false)
791                }
792            }
793            Event::Mouse(mouse_event) => editor
794                .handle_mouse(mouse_event)
795                .map_err(|e| io::Error::other(e.to_string())),
796            Event::Resize(w, h) => {
797                editor.resize(w, h);
798                Ok(true)
799            }
800            Event::Paste(text) => {
801                editor.paste_text(text);
802                Ok(true)
803            }
804            _ => Ok(false),
805        }
806    }
807
808    /// Render the editor and broadcast output to all clients
809    fn render_and_broadcast(&mut self) -> io::Result<()> {
810        let Some(ref mut editor) = self.editor else {
811            return Ok(());
812        };
813
814        let Some(ref mut terminal) = self.terminal else {
815            return Ok(());
816        };
817
818        // Check if any client needs a full render (e.g., newly connected)
819        let any_needs_full = self.clients.iter().any(|c| c.needs_full_render);
820        if any_needs_full {
821            tracing::info!(
822                "Full render requested for {} client(s)",
823                self.clients.iter().filter(|c| c.needs_full_render).count()
824            );
825            // Force full redraw by invalidating terminal state
826            terminal.backend_mut().reset_style_state();
827            // Best-effort terminal clear for full redraw
828            #[allow(clippy::let_underscore_must_use)]
829            let _ = terminal.clear();
830        }
831
832        // Take any pending escape sequences (e.g., cursor style changes)
833        let pending_sequences = editor.take_pending_escape_sequences();
834
835        // Render to capture backend
836        terminal
837            .draw(|frame| editor.render(frame))
838            .map_err(|e| io::Error::other(e.to_string()))?;
839
840        // Get the captured output
841        let output = terminal.backend_mut().take_buffer();
842
843        if output.is_empty() && pending_sequences.is_empty() {
844            return Ok(());
845        }
846
847        // Broadcast to all clients via non-blocking writer threads (skip waiting clients)
848        for client in &mut self.clients {
849            if client.wait_id.is_some() {
850                continue;
851            }
852            // Combine pending sequences and output into a single frame
853            let frame = if !pending_sequences.is_empty() && !output.is_empty() {
854                let mut combined = Vec::with_capacity(pending_sequences.len() + output.len());
855                combined.extend_from_slice(&pending_sequences);
856                combined.extend_from_slice(&output);
857                combined
858            } else if !pending_sequences.is_empty() {
859                pending_sequences.clone()
860            } else {
861                output.clone()
862            };
863
864            if !frame.is_empty() && !client.data_writer.try_write(&frame) {
865                tracing::warn!("Client {} output buffer full, dropping frame", client.id);
866            }
867            // Clear full render flag after sending
868            client.needs_full_render = false;
869        }
870
871        Ok(())
872    }
873
874    /// Disconnect all clients
875    fn disconnect_all_clients(&mut self, reason: &str) -> io::Result<()> {
876        let teardown = terminal_teardown_sequences();
877        for client in &mut self.clients {
878            // Best-effort: client may already be disconnected
879            #[allow(clippy::let_underscore_must_use)]
880            let _ = client.data_writer.try_write(&teardown);
881            let quit_msg = serde_json::to_string(&ServerControl::Quit {
882                reason: reason.to_string(),
883            })
884            .unwrap_or_default();
885            // Best-effort: client may already be disconnected
886            #[allow(clippy::let_underscore_must_use)]
887            let _ = client.conn.write_control(&quit_msg);
888        }
889        self.clients.clear();
890        Ok(())
891    }
892}
893
894impl ConnectedClient {
895    /// Get the client's TERM environment variable
896    #[allow(dead_code)]
897    pub fn term(&self) -> Option<&str> {
898        self.env.get("TERM").and_then(|v| v.as_deref())
899    }
900
901    /// Check if the client supports truecolor
902    #[allow(dead_code)]
903    pub fn supports_truecolor(&self) -> bool {
904        self.env
905            .get("COLORTERM")
906            .and_then(|v| v.as_deref())
907            .map(|v| v == "truecolor" || v == "24bit")
908            .unwrap_or(false)
909    }
910}