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