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