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;
21// Filesystem is now owned by `self.current_authority`; the server no
22// longer constructs a `StdFileSystem` directly.
23use crate::server::capture_backend::{
24    terminal_setup_sequences, terminal_teardown_sequences, CaptureBackend,
25};
26use crate::server::input_parser::InputParser;
27use crate::server::ipc::{ServerConnection, ServerListener, SocketPaths, StreamWrapper};
28use crate::server::protocol::{
29    ClientControl, ServerControl, ServerHello, TermSize, VersionMismatch, PROTOCOL_VERSION,
30};
31use crate::view::color_support::ColorCapability;
32
33/// Configuration for the editor server
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    /// Whether to auto-load ~/.config/fresh/init.ts (requires `plugins_enabled`).
48    pub init_enabled: bool,
49    /// Authority to install at boot.  `None` means `Authority::local()`,
50    /// which is the standard session-mode default (principle 6 of
51    /// `AUTHORITY_DESIGN.md`).  The CLI `ssh://` / `user@host:path`
52    /// forms construct an `Authority::ssh(...)` and pass it here so
53    /// the daemon boots already attached to the remote host.  Plugins
54    /// can still replace this post-boot via `setAuthority`.
55    pub startup_authority: Option<crate::services::authority::Authority>,
56    /// Workspace Trust handle, created by the caller (`main.rs`) before the
57    /// startup authority so the same `Arc` backs both the authority's spawners
58    /// and the server. Mandatory: every spawner holds it, so there's no
59    /// ungated path. Tests pass `Arc::new(WorkspaceTrust::permissive())`.
60    pub workspace_trust: Arc<crate::services::workspace_trust::WorkspaceTrust>,
61    /// Live environment provider, created by the caller (`main.rs`) before the
62    /// startup authority so the same `Arc` backs the authority's spawners and
63    /// the server. Tests pass `Arc::new(EnvProvider::inactive())`.
64    pub env_provider: Arc<crate::services::env_provider::EnvProvider>,
65    /// Opaque handle kept alive for the server's lifetime alongside
66    /// `startup_authority`.  SSH authorities back this with the Tokio
67    /// runtime, the `SshConnection`, and the reconnect task — dropping
68    /// any of those tears down the remote session — so the caller
69    /// bundles them here and the server just holds on until shutdown.
70    /// Local authorities leave this `None`.
71    pub session_keepalive: Option<Box<dyn std::any::Any + Send>>,
72}
73
74/// Editor server that manages editor state and client connections
75pub struct EditorServer {
76    config: EditorServerConfig,
77    listener: ServerListener,
78    clients: Vec<ConnectedClient>,
79    editor: Option<Editor>,
80    terminal: Option<Terminal<CaptureBackend>>,
81    last_client_activity: Instant,
82    shutdown: Arc<AtomicBool>,
83    /// Effective terminal size (from the primary/first client)
84    term_size: TermSize,
85    /// Index of the client that most recently provided input (for per-client detach)
86    last_input_client: Option<usize>,
87    /// Next wait ID for --wait tracking
88    next_wait_id: u64,
89    /// Maps wait_id → client_id for clients waiting on file events
90    waiting_clients: std::collections::HashMap<u64, u64>,
91    /// Current authority. Carried across editor rebuilds so plugin-
92    /// installed authorities (e.g. a devcontainer attach) survive the
93    /// restart-based transition: the old editor is dropped, a new one
94    /// is built with this authority in effect, and clients stay
95    /// connected the whole time. Starts as
96    /// `config.startup_authority.unwrap_or_else(Authority::local)`.
97    current_authority: crate::services::authority::Authority,
98    /// Workspace Trust state, gating process execution. Held here (not on
99    /// the `Editor`) so the chosen level survives editor rebuilds. Shared
100    /// by `Arc` into the authority's guarding spawners on every build via
101    /// `Authority::with_trust`; its root is updated when the working
102    /// directory changes.
103    workspace_trust: Arc<crate::services::workspace_trust::WorkspaceTrust>,
104    /// Live env provider, shared into the authority's spawners across rebuilds.
105    env_provider: Arc<crate::services::env_provider::EnvProvider>,
106    /// Keepalive bundle paired with the startup authority — held for
107    /// the server's lifetime so SSH runtimes, reconnect tasks, and
108    /// similar resources outlive the editor rebuilds that happen on
109    /// authority transitions.  Never inspected; dropped only when the
110    /// server is dropped.
111    #[allow(dead_code)]
112    session_keepalive: Option<Box<dyn std::any::Any + Send>>,
113}
114
115/// Buffered writer for sending data to a client without blocking the server loop.
116///
117/// Spawns a background thread that receives data via a bounded channel and
118/// writes it to the client's data pipe. If the channel fills up (client is
119/// too slow to read), frames are dropped. If the pipe breaks, the `pipe_broken`
120/// flag is set so the main loop can disconnect the client.
121struct ClientDataWriter {
122    sender: mpsc::SyncSender<Vec<u8>>,
123    pipe_broken: Arc<AtomicBool>,
124}
125
126impl ClientDataWriter {
127    /// Create a new writer that spawns a background thread to write to the data stream.
128    fn new(data: StreamWrapper, client_id: u64) -> Self {
129        // 16 frames of buffer (~270ms at 60fps before dropping frames)
130        let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(16);
131        let pipe_broken = Arc::new(AtomicBool::new(false));
132        let pipe_broken_clone = pipe_broken.clone();
133
134        std::thread::Builder::new()
135            .name(format!("client-{}-writer", client_id))
136            .spawn(move || {
137                while let Ok(buf) = rx.recv() {
138                    if let Err(e) = data.write_all(&buf) {
139                        tracing::debug!("Client {} writer pipe error: {}", client_id, e);
140                        pipe_broken_clone.store(true, Ordering::Relaxed);
141                        break;
142                    }
143                    if let Err(e) = data.flush() {
144                        tracing::debug!("Client {} writer flush error: {}", client_id, e);
145                        pipe_broken_clone.store(true, Ordering::Relaxed);
146                        break;
147                    }
148                }
149                tracing::debug!("Client {} writer thread exiting", client_id);
150            })
151            .expect("Failed to spawn client writer thread");
152
153        Self {
154            sender: tx,
155            pipe_broken,
156        }
157    }
158
159    /// Try to send data without blocking. Returns false if the channel is full
160    /// (client too slow) or the writer thread has exited.
161    fn try_write(&self, data: &[u8]) -> bool {
162        self.sender.try_send(data.to_vec()).is_ok()
163    }
164
165    /// Check if the writer thread detected a broken pipe.
166    fn is_broken(&self) -> bool {
167        self.pipe_broken.load(Ordering::Relaxed)
168    }
169}
170
171/// A connected client with its own input parser
172struct ConnectedClient {
173    conn: ServerConnection,
174    /// Background writer for non-blocking data output
175    data_writer: ClientDataWriter,
176    term_size: TermSize,
177    env: std::collections::HashMap<String, Option<String>>,
178    id: u64,
179    input_parser: InputParser,
180    /// Whether this client needs a full screen render on next frame
181    needs_full_render: bool,
182    /// If set, this client is waiting for a --wait completion signal
183    wait_id: Option<u64>,
184}
185
186impl EditorServer {
187    /// Create a new editor server
188    pub fn new(mut config: EditorServerConfig) -> io::Result<Self> {
189        let socket_paths = if let Some(ref name) = config.session_name {
190            SocketPaths::for_session_name(name)?
191        } else {
192            SocketPaths::for_working_dir(&config.working_dir)?
193        };
194
195        let listener = ServerListener::bind(socket_paths)?;
196
197        // Write PID file so clients can detect stale sessions
198        let pid = std::process::id();
199        if let Err(e) = listener.paths().write_pid(pid) {
200            tracing::warn!("Failed to write PID file: {}", e);
201        }
202
203        // Workspace Trust is born in `main.rs` (already rooted at the working
204        // dir, store attached, persisted level loaded) and threaded in via the
205        // config — the same `Arc` the startup authority's spawners hold. The
206        // server just keeps a handle so rebuilds can re-anchor it on a
207        // working-dir change and the prompt can read it.
208        let workspace_trust = Arc::clone(&config.workspace_trust);
209        let env_provider = Arc::clone(&config.env_provider);
210
211        // Move the startup authority + its keepalive off the config —
212        // they are consumed once and belong to the server from here on.
213        // A missing startup authority defaults to a local one carrying the
214        // same trust + env handles.
215        let current_authority = config.startup_authority.take().unwrap_or_else(|| {
216            crate::services::authority::Authority::local(
217                Arc::clone(&workspace_trust),
218                Arc::clone(&env_provider),
219            )
220        });
221        let session_keepalive = config.session_keepalive.take();
222
223        Ok(Self {
224            config,
225            listener,
226            clients: Vec::new(),
227            editor: None,
228            terminal: None,
229            last_client_activity: Instant::now(),
230            shutdown: Arc::new(AtomicBool::new(false)),
231            term_size: TermSize::new(80, 24), // Default until first client connects
232            last_input_client: None,
233            next_wait_id: 1,
234            waiting_clients: std::collections::HashMap::new(),
235            current_authority,
236            workspace_trust,
237            env_provider,
238            session_keepalive,
239        })
240    }
241
242    /// Get a handle to request shutdown
243    pub fn shutdown_handle(&self) -> Arc<AtomicBool> {
244        self.shutdown.clone()
245    }
246
247    /// Get the socket paths
248    pub fn socket_paths(&self) -> &SocketPaths {
249        self.listener.paths()
250    }
251
252    /// Access the editor instance (available after initialize_editor).
253    pub fn editor(&self) -> Option<&Editor> {
254        self.editor.as_ref()
255    }
256
257    /// Mutable access to the editor instance (available after initialize_editor).
258    pub fn editor_mut(&mut self) -> Option<&mut Editor> {
259        self.editor.as_mut()
260    }
261
262    /// Run the editor server main loop
263    pub fn run(&mut self) -> io::Result<()> {
264        tracing::info!("Editor server starting for {:?}", self.config.working_dir);
265
266        let mut next_client_id = 1u64;
267        let mut needs_render = true;
268        let mut last_render = Instant::now();
269        const FRAME_DURATION: Duration = Duration::from_millis(16); // 60fps
270
271        loop {
272            // Check for shutdown
273            if self.shutdown.load(Ordering::SeqCst) {
274                tracing::info!("Shutdown requested");
275                break;
276            }
277
278            // Check idle timeout
279            if let Some(timeout) = self.config.idle_timeout {
280                if self.clients.is_empty() && self.last_client_activity.elapsed() > timeout {
281                    tracing::info!("Idle timeout reached, shutting down");
282                    break;
283                }
284            }
285
286            // Accept new connections
287            tracing::debug!("[server] main loop: calling accept()");
288            match self.listener.accept() {
289                Ok(Some(conn)) => {
290                    // Get current cursor style from editor if it exists, otherwise from config
291                    let cursor_style = self
292                        .editor
293                        .as_ref()
294                        .map(|e| e.config().editor.cursor_style)
295                        .unwrap_or(self.config.editor_config.editor.cursor_style);
296                    match self.handle_new_connection(conn, next_client_id, cursor_style) {
297                        Ok(client) => {
298                            tracing::info!("Client {} connected", client.id);
299
300                            // Initialize editor on first-ever client, or update size if reconnecting
301                            if self.editor.is_none() {
302                                // First time - initialize editor
303                                self.term_size = client.term_size;
304                                self.initialize_editor()?;
305                            } else if self.clients.is_empty() {
306                                // Reconnecting after all clients disconnected - update terminal size
307                                if self.term_size != client.term_size {
308                                    self.term_size = client.term_size;
309                                    self.update_terminal_size()?;
310                                }
311                            }
312                            // Note: full redraw is handled via client.needs_full_render flag
313
314                            self.clients.push(client);
315                            self.last_client_activity = Instant::now();
316                            next_client_id += 1;
317                            needs_render = true;
318                        }
319                        Err(e) => {
320                            tracing::warn!("Failed to complete handshake: {}", e);
321                        }
322                    }
323                }
324                Ok(None) => {}
325                Err(e) => {
326                    tracing::error!("Accept error: {}", e);
327                }
328            }
329
330            // Process client messages and get input events
331            tracing::debug!("[server] main loop: calling process_clients");
332            let (input_events, resize_occurred, input_source) = self.process_clients()?;
333            if let Some(idx) = input_source {
334                self.last_input_client = Some(idx);
335            }
336            if !input_events.is_empty() {
337                tracing::debug!(
338                    "[server] process_clients returned {} events",
339                    input_events.len()
340                );
341            }
342
343            // Check if editor should quit. `should_quit` is set both
344            // by a genuine user quit and by `request_restart`
345            // (triggered by `change_working_dir` and by
346            // `install_authority`).  Distinguish the two by peeking at
347            // the editor's pending-restart fields: if either carries a
348            // value, rebuild the editor in place and keep clients
349            // attached; otherwise this is a real shutdown.
350            if let Some(ref mut editor) = self.editor {
351                if editor.should_quit() {
352                    let pending_authority = editor.take_pending_authority();
353                    let restart_dir = editor.take_restart_dir();
354                    if pending_authority.is_some() || restart_dir.is_some() {
355                        tracing::info!(
356                            "Session rebuild requested (authority={}, dir={})",
357                            pending_authority.is_some(),
358                            restart_dir.is_some()
359                        );
360                        if let Err(e) = self.rebuild_editor(restart_dir, pending_authority) {
361                            tracing::error!("Session rebuild failed, shutting down: {}", e);
362                            self.shutdown.store(true, Ordering::SeqCst);
363                            continue;
364                        }
365                        needs_render = true;
366                        continue;
367                    }
368                    tracing::info!("Editor requested quit");
369                    self.shutdown.store(true, Ordering::SeqCst);
370                    continue;
371                }
372            }
373
374            // Check if client should detach (keep server running)
375            let detach_requested = self
376                .editor
377                .as_ref()
378                .map(|e| e.should_detach())
379                .unwrap_or(false);
380            if detach_requested {
381                // Detach only the client that triggered it (via last input)
382                if let Some(idx) = self.last_input_client.take() {
383                    if idx < self.clients.len() {
384                        tracing::info!("Client {} requested detach", self.clients[idx].id);
385                        let client = self.clients.remove(idx);
386                        let teardown = terminal_teardown_sequences();
387                        // Best-effort: client may already be disconnected
388                        #[allow(clippy::let_underscore_must_use)]
389                        let _ = client.data_writer.try_write(&teardown);
390                        let quit_msg = serde_json::to_string(&ServerControl::Quit {
391                            reason: "Detached".to_string(),
392                        })
393                        .unwrap_or_default();
394                        // Best-effort: client may already be disconnected
395                        #[allow(clippy::let_underscore_must_use)]
396                        let _ = client.conn.write_control(&quit_msg);
397                    }
398                } else {
399                    // Fallback: if we can't determine which client, detach all
400                    tracing::info!("Detach requested but no input source, detaching all");
401                    self.disconnect_all_clients("Detached")?;
402                }
403                // Reset the detach flag
404                if let Some(ref mut editor) = self.editor {
405                    editor.clear_detach();
406                }
407                continue;
408            }
409
410            // Check if the client should suspend itself (SIGTSTP). The server
411            // keeps running — only the client drops back to the shell. On
412            // resume, the client sends a Resize to nudge a full redraw; we
413            // pre-mark `needs_full_render` so the next rendered frame after
414            // resume re-emits setup sequences and a complete paint.
415            let suspend_requested = self
416                .editor
417                .as_mut()
418                .map(|e| e.take_suspend_request())
419                .unwrap_or(false);
420            if suspend_requested {
421                if let Some(idx) = self.last_input_client {
422                    if idx < self.clients.len() {
423                        let client_id = self.clients[idx].id;
424                        tracing::info!("Client {} requested suspend", client_id);
425                        let suspend_msg = serde_json::to_string(&ServerControl::SuspendClient)
426                            .unwrap_or_default();
427                        // Best-effort: client may already be disconnected
428                        #[allow(clippy::let_underscore_must_use)]
429                        let _ = self.clients[idx].conn.write_control(&suspend_msg);
430                        // Mark so the next render re-emits setup sequences and a
431                        // full paint once the client resumes and reconnects its
432                        // terminal.
433                        self.clients[idx].needs_full_render = true;
434                    }
435                } else {
436                    tracing::warn!("Suspend requested but no input source; ignoring");
437                }
438                continue;
439            }
440
441            // Handle resize
442            if resize_occurred {
443                self.update_terminal_size()?;
444                needs_render = true;
445            }
446
447            // Process input events
448            if !input_events.is_empty() {
449                self.last_client_activity = Instant::now();
450                for event in input_events {
451                    if self.handle_event(event)? {
452                        needs_render = true;
453                    }
454                }
455            }
456
457            // Process async messages from editor
458            if let Some(ref mut editor) = self.editor {
459                if editor.process_async_messages() {
460                    needs_render = true;
461                }
462                if editor.process_pending_file_opens() {
463                    needs_render = true;
464                }
465
466                // Process completed --wait operations
467                for wait_id in editor.take_completed_waits() {
468                    if let Some(client_id) = self.waiting_clients.remove(&wait_id) {
469                        // Find the client and send WaitComplete
470                        if let Some(client) = self.clients.iter_mut().find(|c| c.id == client_id) {
471                            let msg = serde_json::to_string(&ServerControl::WaitComplete)
472                                .unwrap_or_default();
473                            #[allow(clippy::let_underscore_must_use)]
474                            let _ = client.conn.write_control(&msg);
475                            client.wait_id = None;
476                        }
477                    }
478                }
479
480                // Send pending clipboard data to clients via control message
481                if let Some(cb) = editor.take_pending_clipboard() {
482                    let msg = serde_json::to_string(&ServerControl::SetClipboard {
483                        text: cb.text,
484                        use_osc52: cb.use_osc52,
485                        use_system_clipboard: cb.use_system_clipboard,
486                    })
487                    .unwrap_or_default();
488                    for client in &mut self.clients {
489                        #[allow(clippy::let_underscore_must_use)]
490                        let _ = client.conn.write_control(&msg);
491                    }
492                }
493
494                if editor.check_mouse_hover_timer() {
495                    needs_render = true;
496                }
497
498                // Active animations force a render every FRAME_DURATION so
499                // the slide settles on its own. Without this the loop only
500                // ticks when an external event (input, resize, async
501                // message) flips `needs_render`, so under tmux a buffer
502                // switch paints its first frame and then freezes mid-slide
503                // until the user nudges the terminal. Mirrors the direct
504                // (non-server) loop in `main.rs`.
505                if editor.active_window().animations.is_active() {
506                    needs_render = true;
507                }
508            }
509
510            // Render and broadcast if needed
511            if needs_render && last_render.elapsed() >= FRAME_DURATION {
512                self.render_and_broadcast()?;
513                last_render = Instant::now();
514                needs_render = false;
515            }
516
517            // Brief sleep to avoid busy-waiting
518            std::thread::sleep(Duration::from_millis(5));
519        }
520
521        // Perform the same shutdown sequence as the normal (non-session) exit path
522        // in run_event_loop_common: auto-save, end recovery session, save workspace.
523        if let Some(ref mut editor) = self.editor {
524            // Auto-save file-backed buffers to disk before exiting
525            if editor.config().editor.auto_save_enabled {
526                match editor.save_all_on_exit() {
527                    Ok(count) if count > 0 => {
528                        tracing::info!("Auto-saved {} buffer(s) on exit", count);
529                    }
530                    Ok(_) => {}
531                    Err(e) => {
532                        tracing::warn!("Failed to auto-save on exit: {}", e);
533                    }
534                }
535            }
536
537            // End recovery session first (flushes dirty buffers + assigns recovery IDs),
538            // then save workspace (captures those IDs for next session restore).
539            if let Err(e) = editor.end_recovery_session() {
540                tracing::warn!("Failed to end recovery session: {}", e);
541            }
542            if let Err(e) = editor.save_all_windows_workspaces() {
543                tracing::warn!("Failed to save workspaces: {}", e);
544            } else {
545                tracing::debug!("Workspaces saved successfully");
546            }
547        }
548
549        // Clean shutdown
550        self.disconnect_all_clients("Server shutting down")?;
551
552        Ok(())
553    }
554
555    /// Build a fresh `Editor` instance using the current configuration
556    /// and stored authority.  Shared between first-boot initialization
557    /// and post-restart rebuild.
558    fn build_editor_instance(&self) -> io::Result<(Editor, Terminal<CaptureBackend>)> {
559        let backend = CaptureBackend::new(self.term_size.cols, self.term_size.rows);
560        let terminal = Terminal::new(backend)
561            .map_err(|e| io::Error::other(format!("Failed to create terminal: {}", e)))?;
562
563        // The Editor constructor still takes a filesystem; the real
564        // authority is installed via `set_boot_authority` right after
565        // construction so plugins and init.ts load against the correct
566        // backend from the first tick.
567        let filesystem = self.current_authority.filesystem.clone();
568        let color_capability = ColorCapability::TrueColor; // Assume truecolor for now
569
570        let mut editor = Editor::with_working_dir(
571            self.config.editor_config.clone(),
572            self.term_size.cols,
573            self.term_size.rows,
574            Some(self.config.working_dir.clone()),
575            self.config.dir_context.clone(),
576            self.config.plugins_enabled,
577            color_capability,
578            filesystem,
579        )
580        .map_err(|e| io::Error::other(format!("Failed to create editor: {}", e)))?;
581
582        // The authority already carries the shared trust handle (every
583        // spawner holds it), so adoption needs no extra wrapping.
584        editor.set_boot_authority(self.current_authority.clone());
585
586        // Auto-load init.ts via the same pipeline as the non-server entry point.
587        editor.load_init_script(self.config.init_enabled);
588
589        // Enable session mode - use hardware cursor only, no REVERSED software cursor
590        editor.set_session_mode(true);
591
592        // Set session name for status bar display
593        let session_display_name = self.config.session_name.clone().unwrap_or_else(|| {
594            // Use the directory name as a short display name for unnamed sessions
595            self.config
596                .working_dir
597                .file_name()
598                .and_then(|n| n.to_str())
599                .map(|s| s.to_string())
600                .unwrap_or_else(|| "session".to_string())
601        });
602        editor.set_session_name(Some(session_display_name));
603
604        Ok((editor, terminal))
605    }
606
607    /// Initialize the editor on first client connection.
608    ///
609    /// Performs the full first-boot sequence: build editor, restore
610    /// workspace, recover buffers from hot exit, start recovery
611    /// session.  Subsequent rebuilds (on authority/working-dir change)
612    /// go through [`rebuild_editor`].
613    pub fn initialize_editor(&mut self) -> io::Result<()> {
614        let (mut editor, terminal) = self.build_editor_instance()?;
615
616        // Restore workspace and recovery data (mirrors the standalone startup
617        // path in handle_first_run_setup in main.rs).
618        match editor.try_restore_workspace() {
619            Ok(true) => {
620                tracing::info!("Session workspace restored successfully");
621            }
622            Ok(false) => {
623                tracing::debug!("No previous session workspace found");
624            }
625            Err(e) => {
626                tracing::warn!("Failed to restore session workspace: {}", e);
627            }
628        }
629
630        // Recover buffers from hot exit recovery files
631        if editor.has_recovery_files().unwrap_or(false) {
632            tracing::info!("Recovery files found for session, recovering...");
633            match editor.recover_all_buffers() {
634                Ok(count) if count > 0 => {
635                    tracing::info!("Recovered {} buffer(s) for session", count);
636                }
637                Ok(_) => {
638                    tracing::info!("No buffers to recover for session");
639                }
640                Err(e) => {
641                    tracing::warn!("Failed to recover session buffers: {}", e);
642                }
643            }
644        }
645
646        // Start the recovery session (periodic saves of dirty buffers)
647        if let Err(e) = editor.start_recovery_session() {
648            tracing::warn!("Failed to start recovery session: {}", e);
649        }
650
651        self.terminal = Some(terminal);
652        self.editor = Some(editor);
653
654        self.maybe_prompt_workspace_trust();
655
656        tracing::info!(
657            "Editor initialized with size {}x{}",
658            self.term_size.cols,
659            self.term_size.rows
660        );
661
662        Ok(())
663    }
664
665    /// Surface the workspace-trust prompt after the editor is built. Delegates
666    /// to `Editor::maybe_prompt_workspace_trust` (single source of truth shared
667    /// with the in-process run path).
668    fn maybe_prompt_workspace_trust(&mut self) {
669        if let Some(editor) = self.editor.as_mut() {
670            editor.maybe_prompt_workspace_trust();
671        }
672    }
673
674    /// Rebuild the editor in place after an authority transition or a
675    /// working-directory change.
676    ///
677    /// Mirrors the restart loop in `main.rs`: save the workspace so
678    /// open buffers come back, drop the old editor (which cascades
679    /// into shutting down terminals, LSP servers, and plugin state),
680    /// swap in any new authority / working-dir, build a fresh editor,
681    /// and restore the workspace under the new backend.  The TCP
682    /// clients stay connected throughout; each is flagged for a full
683    /// redraw on the next frame so they see the new editor from a
684    /// clean state rather than a mid-transition frame.
685    pub(crate) fn rebuild_editor(
686        &mut self,
687        new_working_dir: Option<PathBuf>,
688        new_authority: Option<crate::services::authority::Authority>,
689    ) -> io::Result<()> {
690        // Flush buffer saves + workspace before dropping the old editor,
691        // mirroring the standalone exit path.  On failure we log and
692        // continue — rebuild should still succeed.
693        if let Some(ref mut editor) = self.editor {
694            if editor.config().editor.auto_save_enabled {
695                if let Err(e) = editor.save_all_on_exit() {
696                    tracing::warn!("Rebuild: failed to auto-save on exit: {}", e);
697                }
698            }
699            if let Err(e) = editor.end_recovery_session() {
700                tracing::warn!("Rebuild: failed to end recovery session: {}", e);
701            }
702            if let Err(e) = editor.save_all_windows_workspaces() {
703                tracing::warn!("Rebuild: failed to save workspaces: {}", e);
704            }
705        }
706
707        // Drop old editor + terminal.  Drop impls shut down PTYs, LSP
708        // servers, and plugin threads.
709        self.editor = None;
710        self.terminal = None;
711
712        // Apply the pending changes before building the next editor.
713        if let Some(dir) = new_working_dir {
714            tracing::info!("Rebuild: switching working dir to {}", dir.display());
715            self.config.working_dir = dir;
716            // Re-anchor trust to the new workspace: move the containment
717            // root and repoint persistence at the new project's trust file,
718            // adopting that project's stored decision.
719            self.workspace_trust
720                .set_root(Some(self.config.working_dir.clone()));
721            self.workspace_trust.set_store(Some(
722                crate::services::workspace_trust::TrustStore::for_project_dir(
723                    &self
724                        .config
725                        .dir_context
726                        .project_state_dir(&self.config.working_dir),
727                ),
728            ));
729            // New project ⇒ the old env recipe no longer applies; deactivate
730            // and let the env-manager plugin re-detect for the new workspace.
731            self.env_provider.clear();
732        }
733        if let Some(auth) = new_authority {
734            tracing::info!(
735                "Rebuild: installing authority with label {:?}",
736                auth.display_label
737            );
738            self.current_authority = auth;
739        }
740
741        let (mut editor, terminal) = self.build_editor_instance()?;
742
743        // Bring buffers back under the new backend.  `try_restore_workspace`
744        // reads the workspace file we wrote above and re-opens the
745        // same splits/buffers.
746        match editor.try_restore_workspace() {
747            Ok(true) => tracing::info!("Rebuild: workspace restored"),
748            Ok(false) => tracing::debug!("Rebuild: no workspace to restore"),
749            Err(e) => tracing::warn!("Rebuild: failed to restore workspace: {}", e),
750        }
751
752        if let Err(e) = editor.start_recovery_session() {
753            tracing::warn!("Rebuild: failed to start recovery session: {}", e);
754        }
755
756        self.terminal = Some(terminal);
757        self.editor = Some(editor);
758
759        // A working-dir change lands us in a possibly-undecided project;
760        // re-evaluate the trust prompt. (A rebuild triggered by a trust
761        // decision just recorded one, so this is a no-op there.)
762        self.maybe_prompt_workspace_trust();
763
764        // Force every attached client to repaint from scratch — the
765        // previous frame described the old editor's screen.
766        for client in &mut self.clients {
767            client.needs_full_render = true;
768        }
769
770        tracing::info!(
771            "Rebuild: complete, {} clients kept attached",
772            self.clients.len()
773        );
774
775        Ok(())
776    }
777
778    /// Handle a new client connection
779    fn handle_new_connection(
780        &self,
781        conn: ServerConnection,
782        client_id: u64,
783        cursor_style: crate::config::CursorStyle,
784    ) -> io::Result<ConnectedClient> {
785        // Read client hello
786        // On Windows, don't toggle blocking mode - named pipes don't support mode switching
787        // after connection. The read_control() method handles this internally.
788        #[cfg(not(windows))]
789        conn.control.set_nonblocking(false)?;
790        let hello_json = conn
791            .read_control()?
792            .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "No hello received"))?;
793
794        let client_msg: ClientControl = serde_json::from_str(&hello_json)
795            .map_err(|e| io::Error::other(format!("Invalid hello: {}", e)))?;
796
797        let hello = match client_msg {
798            ClientControl::Hello(h) => h,
799            _ => {
800                return Err(io::Error::other("Expected Hello message"));
801            }
802        };
803
804        // Check protocol version
805        if hello.protocol_version != PROTOCOL_VERSION {
806            let mismatch = VersionMismatch {
807                server_version: env!("CARGO_PKG_VERSION").to_string(),
808                client_version: hello.client_version.clone(),
809                action: if hello.protocol_version > PROTOCOL_VERSION {
810                    "upgrade_server".to_string()
811                } else {
812                    "restart_server".to_string()
813                },
814                message: format!(
815                    "Protocol version mismatch: server={}, client={}",
816                    PROTOCOL_VERSION, hello.protocol_version
817                ),
818            };
819
820            let response = serde_json::to_string(&ServerControl::VersionMismatch(mismatch))
821                .map_err(|e| io::Error::other(e.to_string()))?;
822            conn.write_control(&response)?;
823
824            return Err(io::Error::other("Version mismatch"));
825        }
826
827        // Send server hello
828        let session_id = self.config.session_name.clone().unwrap_or_else(|| {
829            crate::workspace::encode_path_for_filename(&self.config.working_dir)
830        });
831
832        let server_hello = ServerHello::new(session_id);
833        let response = serde_json::to_string(&ServerControl::Hello(server_hello))
834            .map_err(|e| io::Error::other(e.to_string()))?;
835        conn.write_control(&response)?;
836
837        // Set sockets back to non-blocking
838        // On Windows, don't toggle mode - named pipes don't support mode switching
839        #[cfg(not(windows))]
840        conn.control.set_nonblocking(true)?;
841
842        // Send terminal setup sequences
843        let mouse_hover_enabled = self.config.editor_config.editor.mouse_hover_enabled;
844        let setup = terminal_setup_sequences(mouse_hover_enabled);
845        conn.write_data(&setup)?;
846
847        // Send cursor style (from editor if running, otherwise from config)
848        conn.write_data(cursor_style.to_escape_sequence())?;
849
850        tracing::debug!(
851            "Client {} connected: {}x{}, TERM={:?}",
852            client_id,
853            hello.term_size.cols,
854            hello.term_size.rows,
855            hello.term()
856        );
857
858        // Create background writer for non-blocking render output
859        let data_writer = ClientDataWriter::new(conn.data.clone(), client_id);
860
861        Ok(ConnectedClient {
862            conn,
863            data_writer,
864            term_size: hello.term_size,
865            env: hello.env,
866            id: client_id,
867            input_parser: InputParser::new(),
868            needs_full_render: true,
869            wait_id: None,
870        })
871    }
872
873    /// Process messages from connected clients
874    /// Returns (input_events, resize_occurred, index of client that provided input)
875    fn process_clients(&mut self) -> io::Result<(Vec<Event>, bool, Option<usize>)> {
876        let mut disconnected = Vec::new();
877        let mut input_source_client: Option<usize> = None;
878        let mut input_events = Vec::new();
879        let mut resize_occurred = false;
880        let mut control_messages: Vec<(usize, ClientControl)> = Vec::new();
881
882        for (idx, client) in self.clients.iter_mut().enumerate() {
883            // Read from data socket
884            let mut buf = [0u8; 4096];
885            let mut data_eof = false;
886            tracing::debug!("[server] reading from client {} data socket", client.id);
887            match client.conn.read_data(&mut buf) {
888                Ok(0) => {
889                    tracing::debug!("[server] Client {} data stream closed (EOF)", client.id);
890                    // Don't disconnect waiting clients on data EOF - they're not sending data
891                    if client.wait_id.is_none() {
892                        disconnected.push(idx);
893                    }
894                    data_eof = true;
895                    // Don't continue - still need to check control socket for pending messages
896                }
897                Ok(n) => {
898                    tracing::debug!(
899                        "[server] Client {} read {} bytes from data socket",
900                        client.id,
901                        n
902                    );
903                    let events = client.input_parser.parse(&buf[..n]);
904                    tracing::debug!(
905                        "[server] Client {} parsed {} events",
906                        client.id,
907                        events.len()
908                    );
909                    if !events.is_empty() {
910                        input_source_client = Some(idx);
911                    }
912                    input_events.extend(events);
913                }
914                Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
915                    // No data available
916                }
917                Err(e) => {
918                    tracing::warn!("[server] Client {} data read error: {}", client.id, e);
919                    disconnected.push(idx);
920                    data_eof = true;
921                    // Don't continue - still need to check control socket for pending messages
922                }
923            }
924            let _ = data_eof; // Suppress unused warning
925
926            // Check control socket
927            // On Windows, don't toggle nonblocking mode - it fails on named pipes
928            // Best-effort: nonblocking mode for control socket polling
929            #[cfg(not(windows))]
930            #[allow(clippy::let_underscore_must_use)]
931            let _ = client.conn.control.set_nonblocking(true);
932
933            // On Windows, use try_read pattern instead of blocking read_line
934            #[cfg(windows)]
935            {
936                let mut buf = [0u8; 1024];
937                match client.conn.control.try_read(&mut buf) {
938                    Ok(0) => {
939                        tracing::debug!("Client {} control stream closed (EOF)", client.id);
940                        disconnected.push(idx);
941                    }
942                    Ok(n) => {
943                        // Try to parse as control message
944                        if let Ok(s) = std::str::from_utf8(&buf[..n]) {
945                            for line in s.lines() {
946                                if !line.trim().is_empty() {
947                                    if let Ok(msg) = serde_json::from_str::<ClientControl>(line) {
948                                        control_messages.push((idx, msg));
949                                    }
950                                }
951                            }
952                        }
953                    }
954                    Err(e) if e.kind() == io::ErrorKind::WouldBlock => {}
955                    Err(e) => {
956                        tracing::warn!("Client {} control read error: {}", client.id, e);
957                    }
958                }
959            }
960
961            #[cfg(not(windows))]
962            {
963                let mut reader = std::io::BufReader::new(&client.conn.control);
964                let mut line = String::new();
965                match std::io::BufRead::read_line(&mut reader, &mut line) {
966                    Ok(0) => {
967                        tracing::debug!("Client {} control stream closed (EOF)", client.id);
968                        disconnected.push(idx);
969                    }
970                    Ok(_) if !line.trim().is_empty() => {
971                        if let Ok(msg) = serde_json::from_str::<ClientControl>(&line) {
972                            control_messages.push((idx, msg));
973                        }
974                    }
975                    Ok(_) => {}
976                    Err(e) if e.kind() == io::ErrorKind::WouldBlock => {}
977                    Err(e) => {
978                        tracing::warn!("Client {} control read error: {}", client.id, e);
979                    }
980                }
981            }
982        }
983
984        // Process control messages
985        eprintln!(
986            "[server] Processing {} control messages",
987            control_messages.len()
988        );
989        for (idx, msg) in control_messages {
990            eprintln!("[server] Control message from client {}: {:?}", idx, msg);
991            // Always process Quit, even from disconnected clients
992            if let ClientControl::Quit = msg {
993                tracing::info!("Client requested quit, shutting down");
994                self.shutdown.store(true, Ordering::SeqCst);
995                continue;
996            }
997
998            // Always process OpenFiles - it's a one-shot command from clients that disconnect immediately
999            if let ClientControl::OpenFiles { .. } = msg {
1000                // Fall through to process it
1001            } else if disconnected.contains(&idx) {
1002                // Skip other messages from disconnected clients
1003                continue;
1004            }
1005
1006            match msg {
1007                ClientControl::Hello(_) => {
1008                    tracing::warn!("Unexpected Hello from client");
1009                }
1010                ClientControl::Resize { cols, rows } => {
1011                    if let Some(client) = self.clients.get_mut(idx) {
1012                        client.term_size = TermSize::new(cols, rows);
1013                        // Update server size to match first client
1014                        if idx == 0 {
1015                            self.term_size = TermSize::new(cols, rows);
1016                            resize_occurred = true;
1017                        }
1018                    }
1019                }
1020                ClientControl::Ping => {
1021                    if let Some(client) = self.clients.get_mut(idx) {
1022                        let pong = serde_json::to_string(&ServerControl::Pong).unwrap_or_default();
1023                        // Best-effort pong reply
1024                        #[allow(clippy::let_underscore_must_use)]
1025                        let _ = client.conn.write_control(&pong);
1026                    }
1027                }
1028                ClientControl::Detach => {
1029                    tracing::info!("Client {} detached", idx);
1030                    disconnected.push(idx);
1031                }
1032                ClientControl::OpenFiles { files, wait } => {
1033                    if let Some(ref mut editor) = self.editor {
1034                        // Assign a wait_id if --wait was requested
1035                        let wait_id = if wait {
1036                            let id = self.next_wait_id;
1037                            self.next_wait_id += 1;
1038                            Some(id)
1039                        } else {
1040                            None
1041                        };
1042
1043                        let file_count = files.len();
1044                        for (i, file_req) in files.iter().enumerate() {
1045                            let path = std::path::PathBuf::from(&file_req.path);
1046                            tracing::debug!(
1047                                "Queuing file open: {:?} line={:?} col={:?} end_line={:?} end_col={:?} message={:?}",
1048                                path,
1049                                file_req.line,
1050                                file_req.column,
1051                                file_req.end_line,
1052                                file_req.end_column,
1053                                file_req.message,
1054                            );
1055                            // Only the last file gets the wait_id (it's the one that will be active)
1056                            let file_wait_id = if i == file_count - 1 { wait_id } else { None };
1057                            editor.queue_file_open(
1058                                path,
1059                                file_req.line,
1060                                file_req.column,
1061                                file_req.end_line,
1062                                file_req.end_column,
1063                                file_req.message.clone(),
1064                                file_wait_id,
1065                            );
1066                        }
1067
1068                        // Track the waiting client
1069                        if let Some(wait_id) = wait_id {
1070                            if let Some(client) = self.clients.get_mut(idx) {
1071                                self.waiting_clients.insert(wait_id, client.id);
1072                                client.wait_id = Some(wait_id);
1073                            }
1074                        }
1075
1076                        resize_occurred = true; // Force re-render
1077                    }
1078                }
1079                ClientControl::Quit => unreachable!(), // Handled above
1080            }
1081        }
1082
1083        // Check for clients with broken write pipes
1084        for (idx, client) in self.clients.iter().enumerate() {
1085            if client.data_writer.is_broken() && !disconnected.contains(&idx) {
1086                tracing::info!("Client {} write pipe broken, disconnecting", client.id);
1087                disconnected.push(idx);
1088            }
1089        }
1090
1091        // Deduplicate and sort for safe reverse removal
1092        disconnected.sort_unstable();
1093        disconnected.dedup();
1094
1095        // Remove disconnected clients
1096        for idx in disconnected.into_iter().rev() {
1097            let client = self.clients.remove(idx);
1098            // Clean up --wait tracking if this client was waiting
1099            if let Some(wait_id) = client.wait_id {
1100                self.waiting_clients.remove(&wait_id);
1101                // Also clean up editor wait_tracking for this wait_id
1102                if let Some(ref mut editor) = self.editor {
1103                    editor.remove_wait_tracking(wait_id);
1104                }
1105            }
1106            // Best-effort teardown via the non-blocking writer
1107            let teardown = terminal_teardown_sequences();
1108            let _ = client.data_writer.try_write(&teardown);
1109            tracing::info!("Client {} disconnected", client.id);
1110            // Invalidate input source if that client disconnected
1111            if input_source_client == Some(idx) {
1112                input_source_client = None;
1113            }
1114        }
1115
1116        Ok((input_events, resize_occurred, input_source_client))
1117    }
1118
1119    /// Update terminal size after resize
1120    fn update_terminal_size(&mut self) -> io::Result<()> {
1121        if let Some(ref mut terminal) = self.terminal {
1122            let backend = terminal.backend_mut();
1123            backend.resize(self.term_size.cols, self.term_size.rows);
1124        }
1125
1126        if let Some(ref mut editor) = self.editor {
1127            editor.resize(self.term_size.cols, self.term_size.rows);
1128        }
1129
1130        Ok(())
1131    }
1132
1133    /// Handle an input event
1134    fn handle_event(&mut self, event: Event) -> io::Result<bool> {
1135        let Some(ref mut editor) = self.editor else {
1136            return Ok(false);
1137        };
1138
1139        match event {
1140            Event::Key(key_event) => {
1141                if key_event.kind == KeyEventKind::Press {
1142                    editor
1143                        .handle_key(key_event.code, key_event.modifiers)
1144                        .map_err(|e| io::Error::other(e.to_string()))?;
1145                    Ok(true)
1146                } else {
1147                    Ok(false)
1148                }
1149            }
1150            Event::Mouse(mouse_event) => editor
1151                .handle_mouse(mouse_event)
1152                .map_err(|e| io::Error::other(e.to_string())),
1153            Event::Resize(w, h) => {
1154                editor.resize(w, h);
1155                Ok(true)
1156            }
1157            Event::Paste(text) => {
1158                editor.paste_text(text);
1159                Ok(true)
1160            }
1161            _ => Ok(false),
1162        }
1163    }
1164
1165    /// Render the editor and broadcast output to all clients
1166    fn render_and_broadcast(&mut self) -> io::Result<()> {
1167        let Some(ref mut editor) = self.editor else {
1168            return Ok(());
1169        };
1170
1171        let Some(ref mut terminal) = self.terminal else {
1172            return Ok(());
1173        };
1174
1175        // Check if any client needs a full render (e.g., newly connected)
1176        let any_needs_full = self.clients.iter().any(|c| c.needs_full_render);
1177        if any_needs_full {
1178            tracing::info!(
1179                "Full render requested for {} client(s)",
1180                self.clients.iter().filter(|c| c.needs_full_render).count()
1181            );
1182            // Force full redraw by invalidating terminal state
1183            terminal.backend_mut().reset_style_state();
1184            // Best-effort terminal clear for full redraw
1185            #[allow(clippy::let_underscore_must_use)]
1186            let _ = terminal.clear();
1187        }
1188
1189        // Take any pending escape sequences (e.g., cursor style changes)
1190        let pending_sequences = editor.take_pending_escape_sequences();
1191
1192        // Render to capture backend
1193        terminal
1194            .draw(|frame| editor.render(frame))
1195            .map_err(|e| io::Error::other(e.to_string()))?;
1196
1197        // Get the captured output
1198        let output = terminal.backend_mut().take_buffer();
1199
1200        if output.is_empty() && pending_sequences.is_empty() {
1201            return Ok(());
1202        }
1203
1204        // Broadcast to all clients via non-blocking writer threads (skip waiting clients)
1205        for client in &mut self.clients {
1206            if client.wait_id.is_some() {
1207                continue;
1208            }
1209            // Combine pending sequences and output into a single frame
1210            let frame = if !pending_sequences.is_empty() && !output.is_empty() {
1211                let mut combined = Vec::with_capacity(pending_sequences.len() + output.len());
1212                combined.extend_from_slice(&pending_sequences);
1213                combined.extend_from_slice(&output);
1214                combined
1215            } else if !pending_sequences.is_empty() {
1216                pending_sequences.clone()
1217            } else {
1218                output.clone()
1219            };
1220
1221            if !frame.is_empty() && !client.data_writer.try_write(&frame) {
1222                tracing::warn!("Client {} output buffer full, dropping frame", client.id);
1223            }
1224            // Clear full render flag after sending
1225            client.needs_full_render = false;
1226        }
1227
1228        Ok(())
1229    }
1230
1231    /// Disconnect all clients
1232    fn disconnect_all_clients(&mut self, reason: &str) -> io::Result<()> {
1233        let teardown = terminal_teardown_sequences();
1234        for client in &mut self.clients {
1235            // Best-effort: client may already be disconnected
1236            #[allow(clippy::let_underscore_must_use)]
1237            let _ = client.data_writer.try_write(&teardown);
1238            let quit_msg = serde_json::to_string(&ServerControl::Quit {
1239                reason: reason.to_string(),
1240            })
1241            .unwrap_or_default();
1242            // Best-effort: client may already be disconnected
1243            #[allow(clippy::let_underscore_must_use)]
1244            let _ = client.conn.write_control(&quit_msg);
1245        }
1246        self.clients.clear();
1247        Ok(())
1248    }
1249}
1250
1251impl ConnectedClient {
1252    /// Get the client's TERM environment variable
1253    #[allow(dead_code)]
1254    pub fn term(&self) -> Option<&str> {
1255        self.env.get("TERM").and_then(|v| v.as_deref())
1256    }
1257
1258    /// Check if the client supports truecolor
1259    #[allow(dead_code)]
1260    pub fn supports_truecolor(&self) -> bool {
1261        self.env
1262            .get("COLORTERM")
1263            .and_then(|v| v.as_deref())
1264            .map(|v| v == "truecolor" || v == "24bit")
1265            .unwrap_or(false)
1266    }
1267}