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;