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