Skip to main content

trail/app/
tabs.rs

1//! Multi-tab support: `TabState` and tab management.
2//!
3//! Each tab holds an independent `{cwd, entries, selected, history, sort}`.
4//! `TabState` is embedded in `AppState.tabs`; Phase 8 enables creation,
5//! switching, and closing of additional tabs alongside the initial one.
6
7use std::path::PathBuf;
8
9use crate::app::history::NavigationHistory;
10use crate::app::sort::SortSettings;
11use crate::app::state::Entry;
12
13/// The complete navigation state for a single tab.
14///
15/// Every tab holds its own independent directory listing, selection index,
16/// and history stack so switching between tabs restores each tab's exact
17/// previous state.
18#[derive(Debug)]
19pub struct TabState {
20    /// The directory this tab is currently displaying.
21    pub cwd: PathBuf,
22    /// The sorted directory entries for `cwd`.
23    pub entries: Vec<Entry>,
24    /// Index into `entries` of the currently highlighted item.
25    pub selected: usize,
26    /// Back/forward navigation history for this tab.
27    pub history: NavigationHistory,
28    /// How this tab orders its listing.
29    ///
30    /// Per tab rather than per application, because a tab is a place you are
31    /// working: the order that suits a source tree is not the one that suits a
32    /// downloads folder, and having to re-pick it on every switch would make
33    /// the setting useless.
34    pub sort: SortSettings,
35}
36
37impl TabState {
38    /// Creates a `TabState` for `cwd` with an empty entry list and `sort` as
39    /// its ordering.
40    ///
41    /// The caller is responsible for populating `entries` by calling
42    /// `AppState::load_dir` or equivalent after construction.
43    pub fn new(cwd: PathBuf, sort: SortSettings) -> Self {
44        Self {
45            cwd,
46            entries: Vec::new(),
47            selected: 0,
48            history: NavigationHistory::new(),
49            sort,
50        }
51    }
52}
53
54/// Manages the collection of open tabs within `AppState`.
55///
56/// There is always at least one tab. `active` is the index of the
57/// currently focused tab in `tabs`. Invariant: `active < tabs.len()`.
58#[derive(Debug)]
59pub struct TabManager {
60    /// All open tabs; never empty.
61    pub tabs: Vec<TabState>,
62    /// Index of the active (focused) tab. Always `< tabs.len()`.
63    pub active: usize,
64}
65
66impl TabManager {
67    /// Creates a `TabManager` with a single initial tab rooted at `cwd`,
68    /// ordered by `sort`.
69    pub fn new(cwd: PathBuf, sort: SortSettings) -> Self {
70        Self {
71            tabs: vec![TabState::new(cwd, sort)],
72            active: 0,
73        }
74    }
75
76    /// Returns a shared reference to the active tab.
77    // clippy: dead_code — API consumed in Phase 9 UI
78    #[allow(dead_code)]
79    pub fn active_tab(&self) -> &TabState {
80        // SAFETY-invariant: `active` is always in bounds. Maintained by all
81        // mutating methods below; use `&self.tabs[self.active]` rather than
82        // `.get().unwrap()` to make the invariant explicit.
83        &self.tabs[self.active]
84    }
85
86    /// Returns a mutable reference to the active tab.
87    pub fn active_tab_mut(&mut self) -> &mut TabState {
88        &mut self.tabs[self.active]
89    }
90
91    /// Returns the number of open tabs.
92    ///
93    /// Read by the status bar for the `[2/3]` indicator.
94    pub fn len(&self) -> usize {
95        self.tabs.len()
96    }
97
98    /// Returns `true` if there are no tabs open.
99    // clippy: dead_code — API consumed in Phase 9 UI
100    #[allow(dead_code)]
101    pub fn is_empty(&self) -> bool {
102        self.tabs.is_empty()
103    }
104
105    /// Returns `true` when exactly one tab is open.
106    ///
107    /// The status bar shows its tab indicator only when this is false — with one
108    /// tab there is nothing to disambiguate — and a switch request with one tab
109    /// says so rather than doing nothing.
110    pub fn is_single(&self) -> bool {
111        self.tabs.len() == 1
112    }
113
114    /// Opens a new tab rooted at `cwd`, ordered by `sort`, and switches focus
115    /// to it.
116    ///
117    /// The new tab is inserted after the currently active tab. Callers pass the
118    /// outgoing tab's `sort` so a new tab inherits the order you were already
119    /// using rather than snapping back to the configured default.
120    pub fn open_tab(&mut self, cwd: PathBuf, sort: SortSettings) {
121        let insert_at = self.active + 1;
122        self.tabs.insert(insert_at, TabState::new(cwd, sort));
123        self.active = insert_at;
124    }
125
126    /// Closes the active tab.
127    ///
128    /// If this would leave zero tabs, the close is refused (returns `false`).
129    /// On success the active index is adjusted so it remains in bounds:
130    /// - If there are tabs to the right, the next one becomes active.
131    /// - Otherwise the previous tab becomes active.
132    ///
133    /// Returns `true` if the tab was closed, `false` if it was the last one.
134    pub fn close_active_tab(&mut self) -> bool {
135        if self.tabs.len() == 1 {
136            return false;
137        }
138        self.tabs.remove(self.active);
139        // Clamp to the new last index.
140        if self.active >= self.tabs.len() {
141            self.active = self.tabs.len() - 1;
142        }
143        true
144    }
145
146    /// Switches focus to the tab at `index`.
147    ///
148    /// Returns `true` if the switch happened (index was valid and different
149    /// from the current active tab), `false` otherwise.
150    // clippy: dead_code — API consumed in Phase 9 UI
151    #[allow(dead_code)]
152    pub fn switch_to(&mut self, index: usize) -> bool {
153        if index >= self.tabs.len() || index == self.active {
154            return false;
155        }
156        self.active = index;
157        true
158    }
159
160    /// Switches focus to the next tab, wrapping around from the last to the first.
161    ///
162    /// Returns `true` when there is more than one tab (i.e. a switch happened).
163    pub fn switch_next(&mut self) -> bool {
164        if self.tabs.len() <= 1 {
165            return false;
166        }
167        self.active = (self.active + 1) % self.tabs.len();
168        true
169    }
170
171    /// Switches focus to the previous tab, wrapping around from the first to
172    /// the last.
173    ///
174    /// Returns `true` when there is more than one tab.
175    pub fn switch_prev(&mut self) -> bool {
176        if self.tabs.len() <= 1 {
177            return false;
178        }
179        self.active = if self.active == 0 {
180            self.tabs.len() - 1
181        } else {
182            self.active - 1
183        };
184        true
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn p(s: &str) -> PathBuf {
193        PathBuf::from(s)
194    }
195
196    #[test]
197    fn new_tab_manager_has_one_tab() {
198        let mgr = TabManager::new(p("/start"), SortSettings::default());
199        assert_eq!(mgr.len(), 1);
200        assert_eq!(mgr.active, 0);
201        assert_eq!(mgr.active_tab().cwd, p("/start"));
202    }
203
204    #[test]
205    fn open_tab_inserts_after_active() {
206        let mut mgr = TabManager::new(p("/a"), SortSettings::default());
207        mgr.open_tab(p("/b"), SortSettings::default());
208        assert_eq!(mgr.len(), 2);
209        assert_eq!(mgr.active, 1);
210        assert_eq!(mgr.active_tab().cwd, p("/b"));
211    }
212
213    #[test]
214    fn close_tab_not_allowed_when_last() {
215        let mut mgr = TabManager::new(p("/a"), SortSettings::default());
216        let closed = mgr.close_active_tab();
217        assert!(!closed);
218        assert_eq!(mgr.len(), 1);
219    }
220
221    #[test]
222    fn close_active_tab_switches_to_remaining() {
223        let mut mgr = TabManager::new(p("/a"), SortSettings::default());
224        mgr.open_tab(p("/b"), SortSettings::default());
225        // Active is now 1 (/b). Close it; active should become 0 (/a).
226        let closed = mgr.close_active_tab();
227        assert!(closed);
228        assert_eq!(mgr.len(), 1);
229        assert_eq!(mgr.active, 0);
230        assert_eq!(mgr.active_tab().cwd, p("/a"));
231    }
232
233    #[test]
234    fn switch_next_wraps() {
235        let mut mgr = TabManager::new(p("/a"), SortSettings::default());
236        mgr.open_tab(p("/b"), SortSettings::default());
237        let _ = mgr.switch_to(0);
238        mgr.active = 0;
239        assert!(mgr.switch_next());
240        assert_eq!(mgr.active, 1);
241        assert!(mgr.switch_next()); // wraps back to 0
242        assert_eq!(mgr.active, 0);
243    }
244
245    #[test]
246    fn switch_prev_wraps() {
247        let mut mgr = TabManager::new(p("/a"), SortSettings::default());
248        mgr.open_tab(p("/b"), SortSettings::default());
249        mgr.active = 0;
250        assert!(mgr.switch_prev()); // wraps to last (1)
251        assert_eq!(mgr.active, 1);
252    }
253
254    #[test]
255    fn switch_next_single_tab_returns_false() {
256        let mut mgr = TabManager::new(p("/a"), SortSettings::default());
257        assert!(!mgr.switch_next());
258    }
259
260    #[test]
261    fn switch_to_out_of_bounds_returns_false() {
262        let mut mgr = TabManager::new(p("/a"), SortSettings::default());
263        assert!(!mgr.switch_to(99));
264    }
265
266    #[test]
267    fn switch_to_same_tab_returns_false() {
268        let mut mgr = TabManager::new(p("/a"), SortSettings::default());
269        mgr.open_tab(p("/b"), SortSettings::default());
270        mgr.active = 1;
271        assert!(!mgr.switch_to(1));
272    }
273
274    #[test]
275    fn a_new_tab_inherits_the_order_it_was_opened_from() {
276        use crate::app::sort::SortBy;
277        let mut mgr = TabManager::new(p("/a"), SortSettings::default());
278        let inherited = SortSettings {
279            by: SortBy::Size,
280            reverse: true,
281            dirs_first: false,
282        };
283        mgr.open_tab(p("/b"), inherited);
284        assert_eq!(mgr.active_tab().sort, inherited);
285        // ...and the tab it came from keeps its own.
286        assert_eq!(mgr.tabs[0].sort, SortSettings::default());
287    }
288
289    #[test]
290    fn is_single_reflects_tab_count() {
291        let mut mgr = TabManager::new(p("/a"), SortSettings::default());
292        assert!(mgr.is_single());
293        mgr.open_tab(p("/b"), SortSettings::default());
294        assert!(!mgr.is_single());
295    }
296}