Skip to main content

rmux_core/
session.rs

1use std::collections::BTreeMap;
2use std::ops::Bound::{Excluded, Unbounded};
3use std::path::PathBuf;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use rmux_proto::{RmuxError, SessionName, SplitDirection, TerminalSize};
7
8use crate::{AlertFlags, Pane, PaneGeometry, PaneId, SessionId, Window, WindowId};
9
10#[path = "session/accessors.rs"]
11mod accessors;
12#[path = "session/layout_cycle.rs"]
13mod layout_cycle;
14#[path = "session/pane_transfer.rs"]
15mod pane_transfer;
16#[path = "session/pane_transfer_cross.rs"]
17mod pane_transfer_cross;
18#[path = "session/pane_transfer_shared.rs"]
19mod pane_transfer_shared;
20#[path = "session/recency.rs"]
21mod recency;
22#[path = "session/resize.rs"]
23mod resize;
24#[path = "session/store.rs"]
25mod store;
26#[path = "session/target_error.rs"]
27mod target_error;
28#[path = "session/types.rs"]
29mod types;
30#[path = "session/window_ops.rs"]
31mod window_ops;
32
33pub use recency::SessionRecency;
34pub use store::SessionStore;
35use target_error::{invalid_pane_target, invalid_window_target};
36pub(crate) use types::WindowIdAllocator;
37pub use types::{
38    BreakPaneOptions, KillPaneOutcome, PaneJoinOptions, PaneSwapOptions, SessionPaneTarget,
39};
40
41/// A single detached RMUX session.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Session {
44    id: SessionId,
45    name: SessionName,
46    group_name: Option<SessionName>,
47    terminal_size: TerminalSize,
48    windows: BTreeMap<u32, Window>,
49    winlink_alert_flags: BTreeMap<u32, AlertFlags>,
50    active_window: u32,
51    last_window: Option<u32>,
52    /// First grouped-peer winlink by stable identity, retained until real local navigation.
53    group_initial_window_id: Option<WindowId>,
54    next_pane_id: u32,
55    next_window_id: WindowIdAllocator,
56    created_at: i64,
57    activity_at: i64,
58    last_attached_at: Option<i64>,
59    /// Internal total order behind the whole-second `activity_at`/`created_at`
60    /// pair, so same-second lifetime and interaction events stay orderable.
61    recency: SessionRecency,
62    cwd: Option<PathBuf>,
63}
64
65impl Session {
66    /// Creates a new session with its initial pane active.
67    #[must_use]
68    pub fn new(name: SessionName, size: TerminalSize) -> Self {
69        Self::new_with_initial_window(name, size, 0, PaneId::new(0), WindowId::new(0))
70    }
71
72    /// Creates a new session with an explicitly seeded initial window and pane identity.
73    #[must_use]
74    pub(crate) fn new_with_initial_window(
75        name: SessionName,
76        size: TerminalSize,
77        window_index: u32,
78        pane_id: PaneId,
79        window_id: WindowId,
80    ) -> Self {
81        let now = current_unix_timestamp();
82        Self {
83            id: SessionId::new(0),
84            name,
85            group_name: None,
86            terminal_size: size,
87            windows: BTreeMap::from([(
88                window_index,
89                Window::new_with_initial_pane(size, pane_id, window_id),
90            )]),
91            winlink_alert_flags: BTreeMap::from([(window_index, AlertFlags::empty())]),
92            active_window: window_index,
93            last_window: None,
94            group_initial_window_id: None,
95            next_pane_id: pane_id.as_u32().saturating_add(1),
96            next_window_id: WindowIdAllocator::new(window_id.as_u32().saturating_add(1)),
97            created_at: now,
98            activity_at: now,
99            last_attached_at: None,
100            recency: SessionRecency::next(),
101            cwd: None,
102        }
103    }
104
105    /// Splits the current active pane and returns the new pane index, making the new pane active.
106    pub fn split_active_pane(&mut self) -> Result<u32, RmuxError> {
107        self.split_active_pane_with_direction(SplitDirection::Vertical)
108    }
109
110    /// Splits the current active pane in the requested direction.
111    pub fn split_active_pane_with_direction(
112        &mut self,
113        direction: SplitDirection,
114    ) -> Result<u32, RmuxError> {
115        self.split_pane_with_direction(self.active_pane_index(), direction)
116    }
117
118    /// Splits the addressed pane, inserting the new pane immediately after the split target in window order.
119    pub fn split_pane(&mut self, pane_index: u32) -> Result<u32, RmuxError> {
120        self.split_pane_with_direction(pane_index, SplitDirection::Vertical)
121    }
122
123    /// Splits the addressed pane in the active window using the requested direction.
124    pub fn split_pane_with_direction(
125        &mut self,
126        pane_index: u32,
127        direction: SplitDirection,
128    ) -> Result<u32, RmuxError> {
129        self.split_pane_in_window_with_direction(self.active_window, pane_index, direction)
130    }
131
132    /// Splits the addressed pane in the addressed window and returns the new pane index.
133    pub fn split_pane_in_window(
134        &mut self,
135        window_index: u32,
136        pane_index: u32,
137    ) -> Result<u32, RmuxError> {
138        self.split_pane_in_window_with_direction(window_index, pane_index, SplitDirection::Vertical)
139    }
140
141    /// Splits the addressed pane in the addressed window using the requested direction.
142    pub fn split_pane_in_window_with_direction(
143        &mut self,
144        window_index: u32,
145        pane_index: u32,
146        direction: SplitDirection,
147    ) -> Result<u32, RmuxError> {
148        self.split_pane_in_window_with_direction_before(window_index, pane_index, direction, false)
149    }
150
151    /// Splits the addressed pane in the addressed window using the requested
152    /// direction, controlling whether the new pane is inserted before the
153    /// target on the chosen axis (tmux `-b`).
154    pub fn split_pane_in_window_with_direction_before(
155        &mut self,
156        window_index: u32,
157        pane_index: u32,
158        direction: SplitDirection,
159        before: bool,
160    ) -> Result<u32, RmuxError> {
161        let pane_id = self.allocate_pane_id();
162        self.split_pane_in_window_with_id_and_direction_before(
163            window_index,
164            pane_index,
165            pane_id,
166            direction,
167            before,
168        )
169    }
170
171    /// Splits the addressed pane in the addressed window using the provided pane identity.
172    ///
173    /// The new pane is inserted after the target on the chosen axis. Callers
174    /// that need tmux `-b` semantics (insert before) should use
175    /// [`Session::split_pane_in_window_with_id_and_direction_before`].
176    pub fn split_pane_in_window_with_id_and_direction(
177        &mut self,
178        window_index: u32,
179        pane_index: u32,
180        pane_id: PaneId,
181        direction: SplitDirection,
182    ) -> Result<u32, RmuxError> {
183        self.split_pane_in_window_with_id_and_direction_before(
184            window_index,
185            pane_index,
186            pane_id,
187            direction,
188            false,
189        )
190    }
191
192    /// Splits the addressed pane, controlling whether the new pane lands
193    /// before (`-b`) or after the target on the chosen axis.
194    pub fn split_pane_in_window_with_id_and_direction_before(
195        &mut self,
196        window_index: u32,
197        pane_index: u32,
198        pane_id: PaneId,
199        direction: SplitDirection,
200        before: bool,
201    ) -> Result<u32, RmuxError> {
202        self.split_pane_in_window_with_id_and_direction_before_detached(
203            window_index,
204            pane_index,
205            pane_id,
206            direction,
207            before,
208            false,
209        )
210    }
211
212    /// Splits like [`Self::split_pane_in_window_with_id_and_direction_before`]
213    /// but honors tmux `-d`: a detached spawn leaves the active pane, the
214    /// last pane, and the active_point counters untouched.
215    pub fn split_pane_in_window_with_id_and_direction_before_detached(
216        &mut self,
217        window_index: u32,
218        pane_index: u32,
219        pane_id: PaneId,
220        direction: SplitDirection,
221        before: bool,
222        detached: bool,
223    ) -> Result<u32, RmuxError> {
224        let window = self
225            .window_at(window_index)
226            .ok_or_else(|| invalid_window_target(&self.name, window_index))?;
227        let position = window.pane_position(pane_index).ok_or_else(|| {
228            invalid_pane_target(
229                &self.name,
230                window_index,
231                pane_index,
232                "pane index does not exist in session",
233            )
234        })?;
235        if !window.can_split_pane(pane_index, direction) {
236            return Err(RmuxError::Message("no space for new pane".to_owned()));
237        }
238        Ok(self
239            .window_at_mut(window_index)
240            .expect("addressed session window must exist")
241            .split_at_position_with_id_and_direction_detached(
242                position, pane_id, direction, before, detached,
243            ))
244    }
245
246    /// Splits the full addressed window root and returns the new pane index.
247    pub fn split_pane_full_size_in_window_with_id_and_direction_before(
248        &mut self,
249        window_index: u32,
250        pane_index: u32,
251        pane_id: PaneId,
252        direction: SplitDirection,
253        before: bool,
254    ) -> Result<u32, RmuxError> {
255        self.split_pane_full_size_in_window_with_id_and_direction_before_detached(
256            window_index,
257            pane_index,
258            pane_id,
259            direction,
260            before,
261            false,
262        )
263    }
264
265    /// Full-size split honoring tmux `-d` like
266    /// [`Self::split_pane_in_window_with_id_and_direction_before_detached`].
267    pub fn split_pane_full_size_in_window_with_id_and_direction_before_detached(
268        &mut self,
269        window_index: u32,
270        pane_index: u32,
271        pane_id: PaneId,
272        direction: SplitDirection,
273        before: bool,
274        detached: bool,
275    ) -> Result<u32, RmuxError> {
276        let window = self
277            .window_at(window_index)
278            .ok_or_else(|| invalid_window_target(&self.name, window_index))?;
279        if window.pane(pane_index).is_none() {
280            return Err(invalid_pane_target(
281                &self.name,
282                window_index,
283                pane_index,
284                "pane index does not exist in session",
285            ));
286        }
287
288        let window = self
289            .window_at_mut(window_index)
290            .expect("addressed session window must exist");
291        let previous_active_pane_id = window.active_pane().map(Pane::id);
292        let previous_last_pane_id = window
293            .last_pane_index()
294            .and_then(|pane_index| window.pane(pane_index))
295            .map(Pane::id);
296        let new_index = window
297            .panes()
298            .iter()
299            .map(Pane::index)
300            .max()
301            .unwrap_or(0)
302            .saturating_add(1);
303        window.insert_pane_full_size(
304            Pane::new_with_id(pane_id, new_index, PaneGeometry::new(0, 0, 0, 0)),
305            direction,
306            before,
307        )?;
308        window.commit_split_renumber(
309            pane_id,
310            previous_active_pane_id,
311            previous_last_pane_id,
312            detached,
313        );
314        window
315            .panes()
316            .iter()
317            .find(|pane| pane.id() == pane_id)
318            .map(Pane::index)
319            .ok_or_else(|| {
320                RmuxError::Server(format!(
321                    "pane id {} disappeared after full-size split",
322                    pane_id.as_u32()
323                ))
324            })
325    }
326
327    /// Removes the addressed pane in the active window.
328    pub fn kill_pane(&mut self, pane_index: u32) -> Result<KillPaneOutcome, RmuxError> {
329        self.kill_pane_in_window(self.active_window, pane_index)
330    }
331
332    /// Removes the addressed pane or destroys its window when it is the last pane there.
333    pub fn kill_pane_in_window(
334        &mut self,
335        window_index: u32,
336        pane_index: u32,
337    ) -> Result<KillPaneOutcome, RmuxError> {
338        let window = self
339            .window_at(window_index)
340            .ok_or_else(|| invalid_window_target(&self.name, window_index))?;
341        let pane_id = window.pane_id(pane_index).ok_or_else(|| {
342            invalid_pane_target(
343                &self.name,
344                window_index,
345                pane_index,
346                "pane index does not exist in session",
347            )
348        })?;
349
350        if window.pane_count() == 1 {
351            let removed_window = self.remove_window(window_index)?;
352            let removed_pane_ids = removed_window.panes().iter().map(Pane::id).collect();
353            return Ok(KillPaneOutcome::new(removed_pane_ids, true));
354        }
355
356        let removed_pane = self
357            .window_at_mut(window_index)
358            .expect("addressed session window must exist")
359            .remove_pane(pane_index)
360            .expect("prevalidated pane removal must succeed");
361        debug_assert_eq!(removed_pane.id(), pane_id);
362
363        Ok(KillPaneOutcome::new(vec![removed_pane.id()], false))
364    }
365
366    /// Removes every pane except the addressed pane, matching `kill-pane -a`.
367    pub fn kill_other_panes_in_window(
368        &mut self,
369        window_index: u32,
370        pane_index: u32,
371    ) -> Result<KillPaneOutcome, RmuxError> {
372        let window = self
373            .window_at(window_index)
374            .ok_or_else(|| invalid_window_target(&self.name, window_index))?;
375        if window.pane(pane_index).is_none() {
376            return Err(invalid_pane_target(
377                &self.name,
378                window_index,
379                pane_index,
380                "pane index does not exist in session",
381            ));
382        }
383
384        let removed_pane_ids = self
385            .window_at_mut(window_index)
386            .expect("addressed session window must exist")
387            .remove_other_panes(pane_index)
388            .expect("prevalidated pane removal must succeed");
389
390        Ok(KillPaneOutcome::new(removed_pane_ids, false))
391    }
392
393    /// Selects the active pane for the session.
394    pub fn select_pane(&mut self, pane_index: u32) -> Result<(), RmuxError> {
395        self.select_pane_in_window(self.active_window, pane_index)
396    }
397
398    /// Selects the active pane for the addressed window.
399    pub fn select_pane_in_window(
400        &mut self,
401        window_index: u32,
402        pane_index: u32,
403    ) -> Result<(), RmuxError> {
404        self.select_pane_in_window_with_zoom(window_index, pane_index, false)
405    }
406
407    /// Selects the active pane for the addressed window, preserving zoom when requested.
408    pub fn select_pane_in_window_with_zoom(
409        &mut self,
410        window_index: u32,
411        pane_index: u32,
412        preserve_zoom: bool,
413    ) -> Result<(), RmuxError> {
414        if self.window_at(window_index).is_none() {
415            return Err(invalid_window_target(&self.name, window_index));
416        }
417
418        if self
419            .window_at(window_index)
420            .expect("addressed session window must exist")
421            .pane(pane_index)
422            .is_none()
423        {
424            return Err(invalid_pane_target(
425                &self.name,
426                window_index,
427                pane_index,
428                "pane index does not exist in session",
429            ));
430        }
431
432        let window = self
433            .window_at_mut(window_index)
434            .expect("addressed session window must exist");
435        if preserve_zoom {
436            let _ = window.push_zoom(true);
437        }
438        let selected = window.select_pane(pane_index);
439        if preserve_zoom {
440            window.pop_zoom();
441        }
442        debug_assert!(selected, "validated pane target must be selectable");
443        Ok(())
444    }
445
446    /// Selects the pane adjacent to the addressed pane in the requested direction.
447    pub fn select_adjacent_pane_in_window(
448        &mut self,
449        window_index: u32,
450        pane_index: u32,
451        direction: rmux_proto::SelectPaneDirection,
452    ) -> Result<u32, RmuxError> {
453        self.select_adjacent_pane_in_window_with_zoom(window_index, pane_index, direction, false)
454    }
455
456    /// Selects an adjacent pane, preserving zoom when requested.
457    pub fn select_adjacent_pane_in_window_with_zoom(
458        &mut self,
459        window_index: u32,
460        pane_index: u32,
461        direction: rmux_proto::SelectPaneDirection,
462        preserve_zoom: bool,
463    ) -> Result<u32, RmuxError> {
464        if self.window_at(window_index).is_none() {
465            return Err(invalid_window_target(&self.name, window_index));
466        }
467
468        if self
469            .window_at(window_index)
470            .expect("addressed session window must exist")
471            .pane(pane_index)
472            .is_none()
473        {
474            return Err(invalid_pane_target(
475                &self.name,
476                window_index,
477                pane_index,
478                "pane index does not exist in session",
479            ));
480        }
481
482        let window = self
483            .window_at_mut(window_index)
484            .expect("addressed session window must exist");
485        let was_zoomed = window.is_zoomed();
486        let original_active_pane = window.active_pane_index();
487        if was_zoomed {
488            let _ = window.push_zoom(false);
489        }
490        let selected = window
491            .select_adjacent_pane(pane_index, direction)
492            .expect("validated adjacent pane anchor must be selectable");
493        let moved = selected != original_active_pane;
494        if was_zoomed && (preserve_zoom || !moved) {
495            let _ = window.toggle_zoom(window.active_pane_index());
496        }
497        Ok(selected)
498    }
499
500    /// Updates the terminal and content sizes together for all windows.
501    ///
502    /// This is the explicit/manual-size path. Attached clients should use
503    /// [`Session::resize_active_window_geometry`] so status rows never enter a
504    /// window layout.
505    pub fn resize_terminal(&mut self, size: TerminalSize) {
506        self.terminal_size = size;
507        for window in self.windows.values_mut() {
508            window.set_size(size);
509        }
510    }
511
512    /// Updates only the external terminal size used to render this session.
513    pub fn set_terminal_size(&mut self, size: TerminalSize) {
514        self.terminal_size = size;
515    }
516
517    /// Updates the external terminal and active-window content geometry.
518    ///
519    /// Attached clients drive the size of the window they are currently viewing. Inactive
520    /// windows can have independent policies and may share a runtime with another session, so
521    /// resizing every window here would leak the active window's size into unrelated runtimes.
522    pub fn resize_active_window_geometry(
523        &mut self,
524        terminal_size: TerminalSize,
525        content_size: TerminalSize,
526    ) {
527        self.terminal_size = terminal_size;
528        self.window_mut().set_size(content_size);
529    }
530
531    fn resolve_window_target_mut(&mut self, window_index: u32) -> Result<&mut Window, RmuxError> {
532        if !self.windows.contains_key(&window_index) {
533            return Err(invalid_window_target(&self.name, window_index));
534        }
535
536        Ok(self
537            .window_at_mut(window_index)
538            .expect("addressed session window must exist"))
539    }
540
541    pub(crate) fn lowest_available_window_index_at_or_above(
542        &self,
543        minimum_index: u32,
544    ) -> Result<u32, RmuxError> {
545        let mut next_index = minimum_index;
546
547        for window_index in self.windows.keys().copied() {
548            if window_index < next_index {
549                continue;
550            }
551            if window_index > next_index {
552                break;
553            }
554
555            if window_index == next_index {
556                next_index = next_index.checked_add(1).ok_or_else(|| {
557                    RmuxError::Server(format!(
558                        "window index space exhausted for session {}",
559                        self.name
560                    ))
561                })?;
562            }
563        }
564
565        Ok(next_index)
566    }
567
568    fn next_active_window_after_removal(&self, removed_index: u32) -> u32 {
569        if let Some(last_window) = self.last_window {
570            if last_window != removed_index && self.windows.contains_key(&last_window) {
571                return last_window;
572            }
573        }
574
575        if let Some(group_initial_window_id) = self.group_initial_window_id {
576            if let Some((window_index, _)) = self.windows.iter().find(|(window_index, window)| {
577                **window_index != removed_index && window.id() == group_initial_window_id
578            }) {
579                return *window_index;
580            }
581        }
582
583        cyclic_previous_window_index(&self.windows, removed_index)
584            .expect("a non-empty session must have a replacement window")
585    }
586
587    fn allocate_pane_id(&mut self) -> PaneId {
588        let mut next_pane_id = self.next_pane_id;
589
590        loop {
591            let pane_id = PaneId::new(next_pane_id);
592            if !self.contains_pane_id(pane_id) {
593                self.next_pane_id = next_pane_id.saturating_add(1);
594                return pane_id;
595            }
596
597            assert_ne!(next_pane_id, u32::MAX, "pane id space exhausted");
598            next_pane_id += 1;
599        }
600    }
601
602    fn contains_pane_id(&self, pane_id: PaneId) -> bool {
603        self.windows
604            .values()
605            .flat_map(Window::panes)
606            .any(|pane| pane.id() == pane_id)
607    }
608
609    /// Resolves the owning window index for the given pane identity when present.
610    pub fn window_index_for_pane_id(&self, pane_id: PaneId) -> Option<u32> {
611        self.windows.iter().find_map(|(window_index, window)| {
612            window
613                .panes()
614                .iter()
615                .any(|pane| pane.id() == pane_id)
616                .then_some(*window_index)
617        })
618    }
619
620    fn allocate_window_id(&self) -> WindowId {
621        self.next_window_id.allocate()
622    }
623}
624
625fn synchronized_active_window(
626    windows: &BTreeMap<u32, Window>,
627    previous_active: u32,
628    previous_last: Option<u32>,
629) -> u32 {
630    if windows.contains_key(&previous_active) {
631        return previous_active;
632    }
633
634    if let Some(last_window) = previous_last {
635        if last_window != previous_active && windows.contains_key(&last_window) {
636            return last_window;
637        }
638    }
639
640    cyclic_previous_window_index(windows, previous_active)
641        .expect("group synchronization requires at least one window")
642}
643
644fn cyclic_previous_window_index(
645    windows: &BTreeMap<u32, Window>,
646    removed_index: u32,
647) -> Option<u32> {
648    windows
649        .range(..removed_index)
650        .next_back()
651        .or_else(|| {
652            windows
653                .range((Excluded(removed_index), Unbounded))
654                .next_back()
655        })
656        .map(|(window_index, _)| *window_index)
657}
658
659fn current_unix_timestamp() -> i64 {
660    SystemTime::now()
661        .duration_since(UNIX_EPOCH)
662        .ok()
663        .and_then(|duration| i64::try_from(duration.as_secs()).ok())
664        .unwrap_or_default()
665}
666
667#[cfg(test)]
668mod tests;
669
670#[cfg(test)]
671#[path = "session/zoom_tests.rs"]
672mod zoom_tests;
673
674#[cfg(test)]
675#[path = "session/layout_tests.rs"]
676mod layout_tests;