Skip to main content

reovim_server/session/
session.rs

1//! Session - a named editing context.
2//!
3//! # Per-Client State (Phase 11.2)
4//!
5//! Sessions track connected clients via the `clients` map. Each client has a role:
6//! - **Owner**: Owns editing state (mode, cursor, etc.)
7//! - **Follow**: Read-only spectator
8//! - **Share**: Bidirectional co-edit with owner
9//!
10//! The `presence` map tracks display preferences (cursor position for rendering).
11//! The `clients` map tracks editing roles and state ownership.
12
13use std::collections::HashMap;
14
15use reovim_kernel::api::v1::ServiceRegistry;
16
17use parking_lot::RwLock;
18#[cfg(feature = "grpc")]
19use {reovim_protocol::v2::Notification, tokio::sync::broadcast};
20
21#[cfg(feature = "grpc")]
22use super::CaptureTracker;
23#[cfg(feature = "grpc")]
24use super::PresenceMap;
25use {reovim_driver_session::ExtensionMap, reovim_kernel::api::v1::RegisterContent};
26
27use super::{Client, ClientId, SessionId, SessionState};
28
29/// Default channel capacity for notifications.
30#[cfg(feature = "grpc")]
31const NOTIFICATION_CHANNEL_CAPACITY: usize = 256;
32
33/// A session is a named editing context.
34///
35/// Sessions hold the kernel state (buffers, options, etc.) and can have
36/// multiple clients attached. Think of it like a tmux session.
37///
38/// # Client Management (Phase 11.2)
39///
40/// The session tracks connected clients via two maps:
41/// - `clients`: Role and editing state ownership (Owner/Follow/Share)
42/// - `presence`: Display preferences and cursor positions (for rendering)
43pub struct Session {
44    /// Unique session identifier.
45    id: SessionId,
46
47    /// Session state protected by `RwLock`.
48    state: RwLock<SessionState>,
49
50    /// Per-client roles and editing state (Phase 11.2).
51    ///
52    /// Maps `ClientId` to `Client` enum which tracks:
53    /// - Owner: Has own `EditingState`
54    /// - Follow: References another client (read-only)
55    /// - Share: Co-edits with owner (bidirectional)
56    clients: RwLock<HashMap<ClientId, Client>>,
57
58    /// Notification broadcast channel (gRPC only).
59    #[cfg(feature = "grpc")]
60    notification_tx: broadcast::Sender<Notification>,
61
62    /// Capture request tracker for CLI→Server→TUI→Server→CLI relay (gRPC only).
63    #[cfg(feature = "grpc")]
64    capture_tracker: CaptureTracker,
65
66    /// Multi-client presence tracking (Phase 14, gRPC only).
67    #[cfg(feature = "grpc")]
68    presence: PresenceMap,
69}
70
71/// Log client disconnect with crash dump path.
72///
73/// Dumps the client's ring buffer to a file and logs the path via `pr_info!`.
74/// The `pr_info!` macro requires a global logger (`OnceLock`) which is not
75/// reliably initialized in unit tests, making this path untestable.
76#[cfg_attr(coverage_nightly, coverage(off))]
77fn log_client_disconnect(client_id: ClientId, ring_buffer: &super::ring_buffer::ClientRingBuffer) {
78    use reovim_kernel::api::v1::pr_info;
79
80    if let Some(path) = super::crash_dump::try_dump_client_to_file(client_id, ring_buffer) {
81        pr_info!("CLIENT_DISCONNECT client_id={} dump={}", client_id.as_usize(), path.display());
82    }
83}
84
85impl Session {
86    /// Create a new session with the given ID.
87    #[must_use]
88    pub fn new(id: SessionId) -> Self {
89        #[cfg(feature = "grpc")]
90        let (notification_tx, _) = broadcast::channel(NOTIFICATION_CHANNEL_CAPACITY);
91
92        Self {
93            id,
94            state: RwLock::new(SessionState::default()),
95            clients: RwLock::new(HashMap::new()),
96            #[cfg(feature = "grpc")]
97            notification_tx,
98            #[cfg(feature = "grpc")]
99            capture_tracker: CaptureTracker::new(),
100            #[cfg(feature = "grpc")]
101            presence: PresenceMap::new(),
102        }
103    }
104
105    /// Create a new session with a custom state.
106    ///
107    /// This allows the runner to inject module-initialized registries into sessions.
108    /// The state should be created with populated registries from module initialization.
109    ///
110    /// # Example
111    ///
112    /// ```ignore
113    /// use reovim_server::{Session, SessionId, SessionState};
114    ///
115    /// // Create state with populated registries from modules
116    /// let state = SessionState::with_registries(
117    ///     kernel, initial_mode, vfs,
118    ///     mode_registry, command_registry, keymap_registry, resolver_registry,
119    ///     compositor,
120    /// );
121    ///
122    /// let session = Session::from_state(SessionId::new("main"), state);
123    /// ```
124    #[must_use]
125    #[allow(clippy::missing_const_for_fn)] // Contains RwLock::new which is not const
126    pub fn from_state(id: SessionId, state: SessionState) -> Self {
127        #[cfg(feature = "grpc")]
128        let (notification_tx, _) = broadcast::channel(NOTIFICATION_CHANNEL_CAPACITY);
129
130        Self {
131            id,
132            state: RwLock::new(state),
133            clients: RwLock::new(HashMap::new()),
134            #[cfg(feature = "grpc")]
135            notification_tx,
136            #[cfg(feature = "grpc")]
137            capture_tracker: CaptureTracker::new(),
138            #[cfg(feature = "grpc")]
139            presence: PresenceMap::new(),
140        }
141    }
142
143    /// Subscribe to notifications (gRPC only).
144    ///
145    /// Returns a receiver for the notification broadcast channel.
146    /// Used by `NotificationService` to stream updates to clients.
147    #[cfg(feature = "grpc")]
148    #[must_use]
149    pub fn subscribe_notifications(&self) -> broadcast::Receiver<Notification> {
150        self.notification_tx.subscribe()
151    }
152
153    /// Emit a notification to all subscribers (gRPC only).
154    ///
155    /// Sends a notification to all connected clients via the broadcast channel.
156    /// If no clients are subscribed, the notification is silently dropped.
157    #[cfg(feature = "grpc")]
158    pub fn emit_notification(&self, notification: Notification) {
159        // Ignore send errors (no subscribers)
160        let _ = self.notification_tx.send(notification);
161    }
162
163    /// Get the capture tracker for CLI→Server→TUI→Server→CLI relay (gRPC only).
164    #[cfg(feature = "grpc")]
165    #[must_use]
166    pub const fn capture_tracker(&self) -> &CaptureTracker {
167        &self.capture_tracker
168    }
169
170    /// Get the presence map for multi-client tracking (Phase 14, gRPC only).
171    #[cfg(feature = "grpc")]
172    #[must_use]
173    pub const fn presence(&self) -> &PresenceMap {
174        &self.presence
175    }
176
177    // =========================================================================
178    // Client Management (Phase 11.2)
179    // =========================================================================
180
181    /// Add a client to the session as independent.
182    ///
183    /// New clients default to independent (no relation) with their own editing state.
184    /// The client's mode stack is initialized with the session's home mode.
185    /// The client's windows are initialized with the session's active buffer.
186    /// Call `set_client_relation()` to change to Following/Sharing.
187    ///
188    /// # Per-Client Windows (#471)
189    ///
190    /// Each client gets their own `WindowLayout` with independent cursors.
191    /// If the session has an active buffer, a window is created for it.
192    pub fn add_client(&self, client_id: ClientId) {
193        self.add_client_with_metadata(client_id, super::ClientMetadata::default());
194    }
195
196    /// Add a client with metadata.
197    ///
198    /// Creates an independent client with the given metadata.
199    /// This is the preferred method for gRPC handlers that have client info.
200    pub fn add_client_with_metadata(&self, client_id: ClientId, metadata: super::ClientMetadata) {
201        use {reovim_driver_session::Window, reovim_kernel::api::v1::ModeStack};
202
203        // Per-client state (#471, #491): Initialize new clients with session's home mode
204        // stored in SessionShared. After this, the client's per-client mode stack is used.
205        // active_buffer: new clients get the first kernel buffer (scratch buffer).
206        let state = self.state.read();
207        let home_mode = state.home_mode().clone();
208        let active_buffer = state.app.kernel.buffers.list().first().copied();
209        // #474: Clone shared compositor for per-client ownership
210        let compositor = state
211            .driver_session
212            .shared
213            .compositor
214            .as_ref()
215            .map(|c| c.boxed_clone());
216        drop(state); // Release lock before acquiring clients lock
217
218        tracing::debug!(
219            %client_id,
220            mode_module = %home_mode.module(),
221            mode_name = %home_mode.name(),
222            ?active_buffer,
223            has_compositor = compositor.is_some(),
224            "Initializing client with home mode and per-client windows"
225        );
226
227        let mode_stack = ModeStack::new(home_mode);
228        let mut client = Client::with_mode_stack(client_id, metadata, mode_stack);
229
230        // Per-client active_buffer: initialize with first kernel buffer
231        client.state.active_buffer = active_buffer;
232
233        // #474: Set per-client compositor and create windows with matching IDs.
234        // The compositor's window IDs must match the per-client WindowLayout IDs
235        // so that cursor notifications (which use WindowLayout IDs) align with
236        // layout notifications (which use compositor IDs).
237        if let Some(compositor) = compositor {
238            if let Some(buffer_id) = active_buffer {
239                let (tw, th) = client.state.terminal_size;
240                let screen = reovim_driver_layout::Rect::new(0, 0, tw, th);
241                let result = compositor.composite(screen);
242                for p in &result.placements {
243                    let window = Window::with_id_and_buffer(p.window_id, buffer_id);
244                    client.state.windows.add(window);
245                }
246                if let Some(focused) = result.focused {
247                    client.state.windows.set_active(focused);
248                }
249            }
250            client.state.compositor = Some(compositor);
251        } else if let Some(buffer_id) = active_buffer {
252            // No compositor — create window with new ID (fallback)
253            let window = Window::with_buffer(buffer_id);
254            client.state.windows.add(window);
255        }
256
257        let mut clients = self.clients.write();
258        clients.insert(client_id, client);
259    }
260
261    /// Add a client with a specific initial state.
262    ///
263    /// Used for restoring clients or creating clients with pre-configured state.
264    pub fn add_client_with_state(&self, client: Client) {
265        let mut clients = self.clients.write();
266        clients.insert(client.id, client);
267    }
268
269    /// Remove a client from the session.
270    ///
271    /// Returns the removed client if found.
272    ///
273    /// # Debug Infrastructure (#481)
274    ///
275    /// Before removing a client, this method dumps the client's ring buffer
276    /// to a file at `~/.local/share/reovim/crash/client-{id}-{timestamp}.log` for
277    /// post-mortem analysis. A `CLIENT_DISCONNECT` entry is logged to the server
278    /// ring buffer with the dump file path.
279    pub fn remove_client(&self, client_id: ClientId) -> Option<Client> {
280        let mut clients = self.clients.write();
281
282        // Phase #481: Dump ring buffer before removal
283        if let Some(client) = clients.get(&client_id) {
284            log_client_disconnect(client_id, &client.ring_buffer);
285        }
286
287        clients.remove(&client_id)
288    }
289
290    /// Get a client's role (immutable).
291    #[must_use]
292    pub fn get_client(&self, client_id: ClientId) -> Option<Client> {
293        let clients = self.clients.read();
294        clients.get(&client_id).cloned()
295    }
296
297    /// Set a client's relation with validation.
298    ///
299    /// Use this to change between Independent/Following/Sharing modes.
300    /// Pass `None` for independent, `Some(ClientRelation::Following { target })`
301    /// for following, or `Some(ClientRelation::Sharing { with })` for sharing.
302    ///
303    /// # Validation
304    ///
305    /// Validates the transition:
306    /// - Cannot target self
307    /// - Target must exist
308    /// - Cannot create cycles (A → B → A)
309    /// - Following → Sharing upgrade may require cursor sync (returns `RequiresCursorSync`)
310    ///
311    /// # Errors
312    ///
313    /// Returns `Err(TransitionResult)` if:
314    /// - Client not found (`TargetNotFound`)
315    /// - Attempting to target self (`CannotTargetSelf`)
316    /// - Change would create a cycle (`WouldCreateCycle`)
317    /// - Following → Sharing requires cursor sync first (`RequiresCursorSync`)
318    pub fn set_client_relation(
319        &self,
320        client_id: ClientId,
321        relation: Option<super::ClientRelation>,
322    ) -> Result<(), super::TransitionResult> {
323        let mut clients = self.clients.write();
324
325        // First validate without mutation
326        let validation_result = {
327            let Some(client) = clients.get(&client_id) else {
328                return Err(super::TransitionResult::TargetNotFound(client_id));
329            };
330            Client::validate_relation_change(client, relation, &clients)
331        };
332
333        // If validation passed, apply the change
334        let result = match validation_result {
335            super::TransitionResult::Ok => {
336                if let Some(client) = clients.get_mut(&client_id) {
337                    client.set_relation_unchecked(relation);
338                }
339                Ok(())
340            }
341            other => Err(other),
342        };
343
344        drop(clients);
345        result
346    }
347
348    /// Set a client's relation without validation.
349    ///
350    /// **Use sparingly** - prefer `set_client_relation()` for safety.
351    /// This is useful for initialization where validation isn't needed.
352    ///
353    /// # Returns
354    ///
355    /// `true` if the client was found and relation set, `false` otherwise.
356    pub fn set_client_relation_unchecked(
357        &self,
358        client_id: ClientId,
359        relation: Option<super::ClientRelation>,
360    ) -> bool {
361        let mut clients = self.clients.write();
362        clients.get_mut(&client_id).is_some_and(|client| {
363            client.set_relation_unchecked(relation);
364            true
365        })
366    }
367
368    /// Sync cursor and set relation.
369    ///
370    /// Use this when `set_client_relation()` returns `RequiresCursorSync`.
371    /// This syncs the cursor first, then sets the relation.
372    ///
373    /// # Errors
374    ///
375    /// Returns `Err(TransitionResult)` if:
376    /// - Client or target not found (`TargetNotFound`)
377    /// - Attempting to target self (`CannotTargetSelf`)
378    /// - Change would create a cycle (`WouldCreateCycle`)
379    pub fn sync_and_set_relation(
380        &self,
381        client_id: ClientId,
382        target_id: ClientId,
383        relation: Option<super::ClientRelation>,
384    ) -> Result<(), super::TransitionResult> {
385        let mut clients = self.clients.write();
386
387        // Sync cursor first
388        let target_cursor = clients
389            .get(&target_id)
390            .and_then(|c| c.state.windows.active())
391            .map(|w| w.cursor);
392
393        if let (Some(cursor), Some(client)) = (target_cursor, clients.get_mut(&client_id))
394            && let Some(window) = client.state.windows.active_mut()
395        {
396            window.cursor = cursor;
397        }
398
399        // Validate without mutation
400        let validation_result = {
401            let Some(client) = clients.get(&client_id) else {
402                return Err(super::TransitionResult::TargetNotFound(client_id));
403            };
404            Client::validate_relation_change(client, relation, &clients)
405        };
406
407        // If validation passed, apply the change
408        let result = match validation_result {
409            super::TransitionResult::Ok => {
410                if let Some(client) = clients.get_mut(&client_id) {
411                    client.set_relation_unchecked(relation);
412                }
413                Ok(())
414            }
415            other => Err(other),
416        };
417
418        drop(clients);
419        result
420    }
421
422    /// Get the effective editing state for a client.
423    ///
424    /// - Owner: Returns own state
425    /// - Follow: Returns target's state (read-only access)
426    /// - Share: Returns owner's state (for display)
427    ///
428    /// Returns `None` if client not found or target chain is broken.
429    #[must_use]
430    pub fn client_state(&self, client_id: ClientId) -> Option<super::EditingState> {
431        let clients = self.clients.read();
432        clients
433            .get(&client_id)
434            .and_then(|c| c.effective_state(&clients))
435            .cloned()
436    }
437
438    /// Update a client's editing state via closure.
439    ///
440    /// - Independent: Updates own state
441    /// - Following: No-op (input ignored)
442    /// - Sharing: Updates target's state
443    ///
444    /// Returns `true` if state was updated.
445    pub fn update_client_state<F>(&self, client_id: ClientId, f: F) -> bool
446    where
447        F: FnOnce(&mut super::EditingState),
448    {
449        let mut clients = self.clients.write();
450
451        // Find the target client ID based on relation
452        let Some(client) = clients.get(&client_id) else {
453            return false;
454        };
455
456        let target_id = match client.relation {
457            None => client_id, // Independent - update own state
458            Some(super::ClientRelation::Sharing { with }) => with, // Sharing - update target's state
459            Some(super::ClientRelation::Following { .. }) => return false, // Following - input ignored
460        };
461
462        // Update the target's state
463        if let Some(target_client) = clients.get_mut(&target_id) {
464            f(&mut target_client.state);
465            true
466        } else {
467            false
468        }
469    }
470
471    /// Execute a closure with read access to the clients map.
472    pub fn with_clients<F, R>(&self, f: F) -> R
473    where
474        F: FnOnce(&HashMap<ClientId, Client>) -> R,
475    {
476        let clients = self.clients.read();
477        f(&clients)
478    }
479
480    /// Execute a closure with write access to the clients map.
481    pub fn with_clients_mut<F, R>(&self, f: F) -> R
482    where
483        F: FnOnce(&mut HashMap<ClientId, Client>) -> R,
484    {
485        let mut clients = self.clients.write();
486        f(&mut clients)
487    }
488
489    /// Run a closure on a client's `ExtensionMap` without cloning.
490    ///
491    /// `EditingState::clone()` creates an empty `ExtensionMap` because
492    /// `Box<dyn SessionExtensionDyn>` is not `Clone`. This method provides
493    /// direct read access to extensions through the clients lock.
494    ///
495    /// Respects Follow/Share relations via `effective_state()`.
496    pub fn with_client_extensions<F, R>(&self, client_id: ClientId, f: F) -> Option<R>
497    where
498        F: FnOnce(&ExtensionMap) -> R,
499    {
500        let clients = self.clients.read();
501        let client = clients.get(&client_id)?;
502        let state = client.effective_state(&clients)?;
503        let result = f(&state.extensions);
504        drop(clients);
505        Some(result)
506    }
507
508    /// Run a closure with mutable access to a client's `ExtensionMap`.
509    ///
510    /// Used by bridge lifecycle hooks that need to mutate per-client state
511    /// (e.g., auto-dismiss on mode change). Respects Follow/Share relations
512    /// via [`find_input_target`](Self::find_input_target).
513    ///
514    /// Returns `None` if the client doesn't exist or input is ignored (Following).
515    pub fn with_client_extensions_mut<F, R>(&self, client_id: ClientId, f: F) -> Option<R>
516    where
517        F: FnOnce(&mut ExtensionMap) -> R,
518    {
519        let mut clients = self.clients.write();
520        let target_id = Self::find_input_target(&clients, client_id)?;
521        let target_client = clients.get_mut(&target_id)?;
522        let result = f(&mut target_client.state.extensions);
523        drop(clients);
524        Some(result)
525    }
526
527    /// Execute a tick closure with mutable access to client + shared extensions (#546).
528    ///
529    /// Lock order: clients (write) → state (write). Same order as
530    /// [`resolve_key_for_client`](Self::resolve_key_for_client).
531    /// Returns `None` if client not connected or input is ignored (Following).
532    ///
533    /// Used by `TokioTickScheduler` for periodic state advancement.
534    #[cfg(feature = "grpc")]
535    pub fn with_tick_mut<F, R>(&self, client_id: ClientId, f: F) -> Option<R>
536    where
537        F: FnOnce(&mut ExtensionMap, &mut ExtensionMap, &ServiceRegistry) -> R,
538    {
539        let mut clients = self.clients.write();
540        let target_id = Self::find_input_target(&clients, client_id)?;
541        let target_client = clients.get_mut(&target_id)?;
542
543        let mut state = self.state.write();
544        // Clone the Arc before taking mutable borrows on extensions (#555).
545        let services = std::sync::Arc::clone(&state.app.services);
546        let result = f(&mut target_client.state.extensions, &mut state.app.extensions, &services);
547        drop(state);
548        drop(clients);
549        Some(result)
550    }
551
552    /// Get count of connected clients.
553    #[must_use]
554    pub fn client_count(&self) -> usize {
555        self.clients.read().len()
556    }
557
558    /// Check if a client is connected.
559    #[must_use]
560    pub fn has_client(&self, client_id: ClientId) -> bool {
561        self.clients.read().contains_key(&client_id)
562    }
563
564    /// Get the session ID.
565    #[must_use]
566    pub const fn id(&self) -> &SessionId {
567        &self.id
568    }
569
570    /// Execute a closure with read access to the session state.
571    ///
572    /// This is the primary way to query session data.
573    ///
574    /// Note: Currently synchronous but kept async for future I/O operations.
575    #[allow(clippy::unused_async)]
576    pub async fn with_state<F, R>(&self, f: F) -> R
577    where
578        F: FnOnce(&SessionState) -> R,
579    {
580        let state = self.state.read();
581        f(&state)
582    }
583
584    /// Execute a closure with write access to the session state.
585    ///
586    /// Use this for mutations like inserting text, moving cursor, etc.
587    ///
588    /// Note: Currently synchronous but kept async for future I/O operations.
589    #[allow(clippy::unused_async)]
590    pub async fn with_state_mut<F, R>(&self, f: F) -> R
591    where
592        F: FnOnce(&mut SessionState) -> R,
593    {
594        let mut state = self.state.write();
595        f(&mut state)
596    }
597
598    /// Synchronous read access (for contexts where async isn't needed).
599    pub fn with_state_sync<F, R>(&self, f: F) -> R
600    where
601        F: FnOnce(&SessionState) -> R,
602    {
603        let state = self.state.read();
604        f(&state)
605    }
606
607    /// Synchronous write access.
608    pub fn with_state_mut_sync<F, R>(&self, f: F) -> R
609    where
610        F: FnOnce(&mut SessionState) -> R,
611    {
612        let mut state = self.state.write();
613        f(&mut state)
614    }
615
616    /// Execute a closure with combined read access to a client's extensions,
617    /// shared extensions, and pre-collected opponent extension maps (#543).
618    ///
619    /// Acquires locks in established order: `clients` (read) first, then `state`
620    /// (read). Resolves `effective_state()` for all clients to collect opponent
621    /// data as driver-layer `ClientId` + `&ExtensionMap` pairs.
622    ///
623    /// Returns `None` if `client_id` is not connected or has no effective state.
624    pub fn with_bridge_context<F, R>(&self, client_id: ClientId, f: F) -> Option<R>
625    where
626        F: FnOnce(
627            &ExtensionMap,
628            &ExtensionMap,
629            &[(reovim_driver_session::ClientId, &ExtensionMap)],
630        ) -> R,
631    {
632        let clients = self.clients.read();
633        let client = clients.get(&client_id)?;
634        let own_ext = &client.effective_state(&clients)?.extensions;
635
636        // Pre-collect opponent extension maps with driver-layer ClientId.
637        // The driver crate cannot see `Client`, so we resolve here.
638        let opponents: Vec<(reovim_driver_session::ClientId, &ExtensionMap)> = clients
639            .iter()
640            .filter(|&(&id, _)| id != client_id)
641            .filter_map(|(&id, c)| {
642                c.effective_state(&clients).map(|state| {
643                    (reovim_driver_session::ClientId::new(id.as_usize()), &state.extensions)
644                })
645            })
646            .collect();
647
648        let state = self.state.read();
649        let shared_ext = &state.app.extensions;
650        let result = f(own_ext, shared_ext, &opponents);
651        drop(state);
652        drop(clients);
653        Some(result)
654    }
655
656    // =========================================================================
657    // Per-Client Key Resolution (#471)
658    // =========================================================================
659
660    /// Ensure a client's per-client windows are populated.
661    ///
662    /// When a client joins before any buffers exist, their windows are empty.
663    /// Later, when a buffer is created (e.g., via `:e`), only the shared session
664    /// windows are updated. This helper syncs per-client windows with the session's
665    /// active buffer when needed.
666    ///
667    /// # When this matters
668    ///
669    /// 1. Client connects (no buffers yet) → empty windows
670    /// 2. `:e filename` creates buffer → shared windows updated
671    /// 3. Client tries to move cursor → per-client windows still empty!
672    ///
673    /// This helper fixes step 3 by creating a window for the active buffer.
674    fn ensure_client_has_window(editing_state: &mut super::EditingState) {
675        use reovim_driver_session::Window;
676
677        // Only sync if per-client windows are empty AND client has an active buffer
678        if editing_state.windows.is_empty()
679            && let Some(buffer_id) = editing_state.active_buffer
680        {
681            // #474: If per-client compositor exists, create windows with matching IDs
682            if let Some(ref compositor) = editing_state.compositor {
683                let (tw, th) = editing_state.terminal_size;
684                let screen = reovim_driver_layout::Rect::new(0, 0, tw, th);
685                let result = compositor.composite(screen);
686                for p in &result.placements {
687                    let window = Window::with_id_and_buffer(p.window_id, buffer_id);
688                    editing_state.windows.add(window);
689                }
690                if let Some(focused) = result.focused {
691                    editing_state.windows.set_active(focused);
692                }
693            } else {
694                let window = Window::with_buffer(buffer_id);
695                editing_state.windows.add(window);
696            }
697            tracing::debug!(?buffer_id, "Synced per-client windows with active buffer");
698        }
699    }
700
701    /// Resolve a key with per-client mode stack (#471).
702    ///
703    /// This method provides access to both session state AND per-client mode stack,
704    /// enabling multi-client mode isolation. The key is resolved using the client's
705    /// mode stack instead of the shared session mode stack.
706    ///
707    /// # Arguments
708    ///
709    /// * `client_id` - Client ID to resolve for
710    /// * `key` - Key event to resolve
711    ///
712    /// # Returns
713    ///
714    /// - `Some((ResolveResult, StateChanges))` - if key was resolved
715    /// - `None` - if client not found, client is Following, or no resolver
716    ///
717    /// # Relation Behavior
718    ///
719    /// - **Independent**: Uses own mode stack and windows
720    /// - **Following**: Returns `None` (input ignored)
721    /// - **Sharing**: Uses target's mode stack and windows
722    #[allow(clippy::unused_async, clippy::significant_drop_tightening)]
723    pub async fn resolve_key_for_client(
724        &self,
725        client_id: ClientId,
726        key: &reovim_driver_input::KeyEvent,
727    ) -> Option<(reovim_driver_input::ResolveResult, reovim_driver_session::api::StateChanges)>
728    {
729        // Acquire both locks in consistent order to avoid deadlocks
730        let mut clients = self.clients.write();
731        let mut state = self.state.write();
732
733        // Find the target client ID based on relation
734        let target_id = Self::find_input_target(&clients, client_id)?;
735
736        // Phase #471/#477/#480: Get mutable references to per-client state
737        let target_client = clients.get_mut(&target_id)?;
738        let editing_state = &mut target_client.state;
739
740        // Ensure per-client windows are populated (fixes buffer-after-client-join issue)
741        Self::ensure_client_has_window(editing_state);
742
743        // Resolve key with per-client state (#471 Phase 5: pass client_id for undo origin)
744        state.resolve_key_for_client(target_id.as_usize(), editing_state.client_context(), key)
745    }
746
747    /// Try `on_command_complete` with per-client state (#471, #477).
748    ///
749    /// Like `resolve_key_for_client`, but for post-command mode transitions.
750    #[allow(clippy::unused_async, clippy::significant_drop_tightening)]
751    pub async fn try_on_command_complete_for_client(
752        &self,
753        client_id: ClientId,
754    ) -> Option<reovim_driver_input::ModeTransition> {
755        // Acquire both locks in consistent order
756        let mut clients = self.clients.write();
757        let mut state = self.state.write();
758
759        // Find the target client ID based on relation
760        let target_id = Self::find_input_target(&clients, client_id)?;
761
762        // Phase #471/#477/#480: Get mutable references to per-client state
763        let target_client = clients.get_mut(&target_id)?;
764        let editing_state = &mut target_client.state;
765
766        // Ensure per-client windows are populated (fixes buffer-after-client-join issue)
767        Self::ensure_client_has_window(editing_state);
768
769        state.try_on_command_complete_for_client(
770            target_id.as_usize(),
771            editing_state.client_context(),
772        )
773    }
774
775    /// Execute a command with per-client state (Phase #471).
776    ///
777    /// This enables multi-client mode isolation by operating on per-client
778    /// mode and cursor state instead of shared session state.
779    ///
780    /// # Arguments
781    ///
782    /// * `client_id` - Client ID to execute for
783    /// * `cmd_id` - Command ID to execute
784    /// * `args` - Command arguments (count, register, etc.)
785    ///
786    /// # Returns
787    ///
788    /// - `Some((CommandResult, StateChanges))` - if command executed
789    /// - `None` - if client not found, client is Following, or command not registered
790    ///
791    /// # Relation Behavior
792    ///
793    /// - **Independent**: Uses own mode stack, windows, and extensions
794    /// - **Following**: Returns `None` (input ignored)
795    /// - **Sharing**: Uses target's state
796    #[allow(clippy::significant_drop_tightening)]
797    pub fn execute_command_for_client(
798        &self,
799        client_id: ClientId,
800        cmd_id: &reovim_kernel::api::v1::CommandId,
801        args: &reovim_driver_command_types::CommandContext,
802    ) -> Option<(
803        reovim_driver_command::CommandResult,
804        reovim_driver_session::api::StateChanges,
805        Vec<reovim_driver_command_types::RuntimeSignal>,
806    )> {
807        // Acquire both locks in consistent order to avoid deadlocks
808        let mut clients = self.clients.write();
809        let mut state = self.state.write();
810
811        // Find the target client ID based on relation
812        let target_id = Self::find_input_target(&clients, client_id)?;
813
814        // Phase #471/#477/#480: Get mutable references to per-client state
815        let target_client = clients.get_mut(&target_id)?;
816        let editing_state = &mut target_client.state;
817
818        // Ensure per-client windows are populated (fixes buffer-after-client-join issue)
819        Self::ensure_client_has_window(editing_state);
820
821        // Execute command with per-client state, passing client_id for per-client undo (#471, #515)
822        state.execute_command_for_client(
823            target_id.as_usize(),
824            editing_state.client_context(),
825            cmd_id,
826            args,
827        )
828    }
829
830    /// Insert a character for a client, checking per-client extensions first (#477).
831    ///
832    /// For `InputTarget::Buffer`, inserts into the active buffer.
833    /// For `InputTarget::Extension(type_id)`, looks in per-client extensions first,
834    /// then falls back to shared session extensions.
835    ///
836    /// # Returns
837    ///
838    /// - `Some((BufferId, Some(Modification)))` if character was inserted into a buffer
839    /// - `None` if inserted into extension or failed
840    #[allow(clippy::significant_drop_tightening)]
841    pub fn insert_char_for_client(
842        &self,
843        client_id: ClientId,
844        ch: char,
845        target: reovim_driver_input::InputTarget,
846    ) -> Option<(
847        reovim_kernel::api::v1::BufferId,
848        Option<reovim_kernel::api::v1::events::kernel::Modification>,
849    )> {
850        use {
851            reovim_driver_input::InputTarget,
852            reovim_driver_undo::{UndoKey, UndoProviderRegistry},
853            reovim_kernel::api::v1::{Edit, Position, events::kernel::Modification},
854        };
855
856        match target {
857            InputTarget::Buffer => {
858                // Insert into active buffer at client's cursor position
859                // active_buffer is per-client (#471)
860                let clients = self.clients.read();
861                let buffer_id = clients.get(&client_id)?.state.active_buffer?;
862                drop(clients);
863
864                let state = self.state.read();
865                let buffer_arc = state.buffer(buffer_id)?;
866
867                // Get undo registry for recording edit (#471)
868                let undo_registry = state.app.kernel.services.get::<UndoProviderRegistry>();
869
870                drop(state); // Release lock before getting client
871
872                // Get cursor position from client's window
873                let mut clients = self.clients.write();
874                let client = clients.get_mut(&client_id)?;
875                let active_window = client.state.windows.active_mut()?;
876                let cursor_before =
877                    Position::new(active_window.cursor.line, active_window.cursor.column);
878
879                // Compute start_byte BEFORE the mutation for incremental syntax parsing
880                let start_byte = buffer_arc.read().position_to_byte(cursor_before);
881
882                tracing::debug!(?buffer_id, ?ch, ?cursor_before, "Inserting into buffer");
883                let ch_str = ch.to_string();
884                buffer_arc.write().insert_at(cursor_before, &ch_str);
885
886                // Update cursor position after insertion
887                // For regular characters, move cursor one position right
888                // For newlines, move to start of next line
889                if ch == '\n' {
890                    active_window.cursor.line += 1;
891                    active_window.cursor.column = 0;
892                } else {
893                    active_window.cursor.column += 1;
894                }
895
896                let cursor_after =
897                    Position::new(active_window.cursor.line, active_window.cursor.column);
898
899                drop(clients);
900
901                // Build Modification for incremental syntax parsing
902                #[allow(clippy::cast_possible_truncation)] // cursor positions fit in u32
903                let modification = Modification::Insert {
904                    start: (cursor_before.line as u32, cursor_before.column as u32),
905                    text: ch_str.clone(),
906                    start_byte,
907                };
908
909                // Record edit for undo with client origin (#471)
910                if let Some(undo_reg) = undo_registry
911                    && let Some(undo_provider) = undo_reg.get(&UndoKey::Buffer)
912                {
913                    let edit = Edit::Insert {
914                        position: cursor_before,
915                        text: ch_str,
916                    };
917                    undo_provider.record_for_client(
918                        buffer_id,
919                        client_id.as_usize(),
920                        vec![edit],
921                        cursor_before,
922                        cursor_after,
923                    );
924                }
925
926                Some((buffer_id, Some(modification)))
927            }
928            InputTarget::Extension(type_id) => {
929                // Phase #477: Check per-client extensions FIRST, then shared
930                tracing::debug!(?type_id, ?ch, %client_id, "Routing to extension via TextInputSink");
931
932                // Try per-client extensions first
933                let mut clients = self.clients.write();
934                if let Some(client) = clients.get_mut(&client_id)
935                    && let Some(sink) = client.state.extensions.get_text_input_sink_by_id(type_id)
936                {
937                    sink.insert_char(ch);
938                    tracing::debug!(?type_id, "Inserted char via per-client extension");
939                    return None;
940                }
941                drop(clients);
942
943                // Fallback to shared session extensions (#491)
944                let mut state = self.state.write();
945                if let Some(sink) = state.app.extensions.get_text_input_sink_by_id(type_id) {
946                    sink.insert_char(ch);
947                    tracing::debug!(?type_id, "Inserted char via shared extension");
948                } else {
949                    tracing::warn!(
950                        ?type_id,
951                        "Extension not found or doesn't implement TextInputSink"
952                    );
953                }
954                drop(state); // Release lock early
955                None
956            }
957        }
958    }
959
960    /// Get the current mode for a specific client (#471).
961    ///
962    /// Returns the mode from the client's per-client mode stack (if Independent/Sharing)
963    /// or `None` for Following clients.
964    #[must_use]
965    pub fn client_current_mode(
966        &self,
967        client_id: ClientId,
968    ) -> Option<reovim_kernel::api::v1::ModeId> {
969        let clients = self.clients.read();
970
971        // Find the target client ID based on relation
972        let target_id = Self::find_input_target(&clients, client_id)?;
973
974        // Get mode from target's mode stack
975        clients
976            .get(&target_id)
977            .map(|c| c.state.mode_stack.current().clone())
978    }
979
980    /// Find the target client ID for input routing.
981    ///
982    /// - Independent: returns self
983    /// - Following: returns None (input ignored)
984    /// - Sharing: returns target
985    fn find_input_target(
986        clients: &HashMap<ClientId, Client>,
987        client_id: ClientId,
988    ) -> Option<ClientId> {
989        let client = clients.get(&client_id)?;
990        if client.is_independent() {
991            Some(client_id)
992        } else if client.is_sharing() {
993            client.target_id()
994        } else {
995            // Following - input ignored
996            None
997        }
998    }
999
1000    /// Get access to a client's ring buffer.
1001    ///
1002    /// Returns `None` if the client doesn't exist.
1003    pub fn with_client_ring_buffer<F, R>(&self, client_id: ClientId, f: F) -> Option<R>
1004    where
1005        F: FnOnce(&super::ring_buffer::ClientRingBuffer) -> R,
1006    {
1007        let clients = self.clients.read();
1008        clients.get(&client_id).map(|c| f(&c.ring_buffer))
1009    }
1010
1011    /// Dump a client's ring buffer for debugging.
1012    ///
1013    /// Returns `None` if the client doesn't exist.
1014    #[must_use]
1015    pub fn dump_client_ring_buffer(&self, client_id: ClientId) -> Option<String> {
1016        self.with_client_ring_buffer(client_id, super::ring_buffer::ClientRingBuffer::dump)
1017    }
1018
1019    // ========================================================================
1020    // Session-scoped registers (#515 Phase 5)
1021    // ========================================================================
1022
1023    /// Get a session-shared register.
1024    ///
1025    /// Returns `None` if the register has not been set. Session registers
1026    /// are shared across all clients in this session.
1027    #[must_use]
1028    pub fn get_session_register(&self, key: char) -> Option<RegisterContent> {
1029        let state = self.state.read();
1030        state.session_registers.get(&key).cloned()
1031    }
1032
1033    /// Set a session-shared register.
1034    ///
1035    /// The content is immediately visible to all clients in this session.
1036    pub fn set_session_register(&self, key: char, content: RegisterContent) {
1037        let mut state = self.state.write();
1038        state.session_registers.insert(key, content);
1039    }
1040
1041    /// Read another client's history ring entry (`PeerHistory`).
1042    ///
1043    /// Returns `None` if the client doesn't exist or the index is out of range.
1044    /// This is a read-only operation - you cannot modify another client's history.
1045    #[must_use]
1046    pub fn get_peer_history(&self, client_id: ClientId, index: u8) -> Option<RegisterContent> {
1047        let clients = self.clients.read();
1048        clients
1049            .get(&client_id)
1050            .and_then(|client| client.state.clipboard_history.get_by_index(index))
1051            .cloned()
1052    }
1053
1054    /// List connected client IDs (for peer history navigation).
1055    ///
1056    /// Returns a sorted list of client IDs currently in this session.
1057    #[must_use]
1058    pub fn connected_client_ids(&self) -> Vec<ClientId> {
1059        let mut ids: Vec<_> = self.clients.read().keys().copied().collect();
1060        ids.sort_unstable_by_key(ClientId::as_usize);
1061        ids
1062    }
1063}
1064
1065#[cfg(test)]
1066#[path = "session_tests.rs"]
1067mod tests;