Skip to main content

gwm/tui/state/
sidebar.rs

1//! Sidebar (git preview) panel state, extracted from `tui::app::App` per
2//! #127 / #102.
3//!
4//! Concerns:
5//!
6//! 1. **Visibility + focus** — `open` (toggled by `v`), `focused`
7//!    (toggled by `Tab`). A closed sidebar can never be focused; the
8//!    `toggle_open` invariant enforces that so `j` / `k` walks the
9//!    worktree list when the panel goes away.
10//!
11//! 2. **Scroll offset** — `scroll` is the first-visible line index of
12//!    the Recent Commits section; `max_scroll` is its upper bound,
13//!    republished every frame by the renderer (`tui/ui.rs::draw_sidebar`)
14//!    against the actual rendered content height. Scrolling is clamped
15//!    against `max_scroll` so the user can't push the panel content
16//!    entirely off-screen.
17//!
18//! 3. **Cache** — `cache` memoises the pre-rendered `SidebarSections`
19//!    keyed by the selected worktree's path. Without it, every TUI
20//!    redraw would re-shell `git log` / `git status` for the preview
21//!    panel; the cache means those run only on selection change (via
22//!    [`Self::on_navigation`]) or explicit invalidation (via
23//!    [`Self::invalidate`], called by `App::refresh` after the
24//!    worktrees list mutates).
25//!
26//! 4. **Navigation triple dedupe** — pre-extraction, the `App` body
27//!    repeated `sidebar_scroll = 0; invalidate_sidebar_cache();
28//!    refresh_link();` verbatim in `next`, `prev`, `first`, `last`,
29//!    and `clamp_selection_to_filter`'s neighbours. [`Self::on_navigation`]
30//!    collapses the first two pieces here; the `App` orchestrator
31//!    wraps them with `refresh_link()` in a single `App::on_navigation`
32//!    so the literal triple can't drift back into duplicated copies.
33
34use crate::config::SidebarPosition;
35use crate::tui::ui::SidebarSections;
36use std::path::PathBuf;
37
38/// Minimum total terminal width (in columns) required to render the
39/// sidebar *beside* the worktree table without squeezing the table
40/// beyond readability. At or above this width the `Auto` orientation
41/// picks the side-by-side split; below it, `Auto` stacks the sidebar
42/// under the table (issue #188) rather than hiding it (pre-#188).
43pub const SIDEBAR_MIN_WIDTH: u16 = 120;
44
45/// How the sidebar is arranged relative to the worktree table (issue
46/// #188). `Auto` is the default: the renderer picks side-by-side on a
47/// wide terminal and stacked on a narrow one. The other two variants
48/// pin the choice regardless of width, set by cycling with `V`.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum SidebarOrientation {
51  /// Width-driven: side-by-side at `>= SIDEBAR_MIN_WIDTH`, stacked
52  /// below it. Restores a usable sidebar on narrow terminals where it
53  /// was previously hidden entirely.
54  Auto,
55  /// Always beside the table (table | sidebar), even when narrow.
56  SideBySide,
57  /// Always stacked (table on top, sidebar below), even when wide.
58  /// Default since issue #217: the status pane reads best under the
59  /// worktrees table, where it gets the full terminal width.
60  #[default]
61  Stacked,
62}
63
64impl SidebarOrientation {
65  /// Status-bar label (`sidebar layout: auto`).
66  pub fn label(self) -> &'static str {
67    match self {
68      SidebarOrientation::Auto => "auto",
69      SidebarOrientation::SideBySide => "side-by-side",
70      SidebarOrientation::Stacked => "stacked",
71    }
72  }
73
74  /// Advance to the next orientation in the cycle
75  /// `Auto → SideBySide → Stacked → Auto`. Drives the `V` keybinding.
76  pub fn next(self) -> Self {
77    match self {
78      SidebarOrientation::Auto => SidebarOrientation::SideBySide,
79      SidebarOrientation::SideBySide => SidebarOrientation::Stacked,
80      SidebarOrientation::Stacked => SidebarOrientation::Auto,
81    }
82  }
83}
84
85/// The concrete layout the renderer should draw for the current frame,
86/// resolved from `open` + orientation + position + terminal width by
87/// [`SidebarState::resolve_layout`]. Kept ratatui-free so the decision
88/// is unit-testable against the width contract without a backend.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum ResolvedSidebarLayout {
91  /// Sidebar closed — draw the worktree table full-area.
92  Hidden,
93  /// Side-by-side split. `sidebar_left` mirrors [`SidebarPosition`]:
94  /// `true` draws the sidebar on the left of the table, `false` on the
95  /// right.
96  SideBySide { sidebar_left: bool },
97  /// Stacked split — table on top, sidebar below.
98  Stacked,
99}
100
101impl ResolvedSidebarLayout {
102  /// The `(table_pct, sidebar_pct)` split this layout draws, or `None`
103  /// when the sidebar is hidden (the table takes the whole area). Issue
104  /// #217 tuned the ratios per axis: stacked vertically the status pane
105  /// gets the larger share (42% table / 58% status) so commits + issue/PR
106  /// have room; side-by-side the table stays dominant (55% / 45%). Pure +
107  /// ratatui-free so the contract is pinned without a backend.
108  pub fn split_percentages(self) -> Option<(u16, u16)> {
109    match self {
110      ResolvedSidebarLayout::Hidden => None,
111      ResolvedSidebarLayout::SideBySide { .. } => Some((55, 45)),
112      ResolvedSidebarLayout::Stacked => Some((42, 58)),
113    }
114  }
115}
116
117/// Which content the sidebar previews (issue #34).
118///
119/// Toggled with the `s` key in the list view, dispatched through
120/// `Action::ToggleSidebarMode` in the rebindable keymap. Default is
121/// `Commits` so the pre-#34 sidebar behaviour is preserved verbatim.
122/// The mode is per-session — not persisted across `gwm` launches —
123/// because the low-frequency need to view stashes does not justify a
124/// new `.gwm.toml` knob.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
126pub enum SidebarMode {
127  /// `git log --oneline -n 10` + `git status --short`. Pre-#34
128  /// behaviour, kept as the default so existing users see no change
129  /// until they press `s`.
130  Commits,
131  /// `git stash list` + a per-stash quick view. New in #34.
132  Stashes,
133}
134
135impl SidebarMode {
136  /// Human-readable label rendered into the sidebar title bar
137  /// (` Details — commits ` vs. ` Details — stashes `).
138  pub fn label(self) -> &'static str {
139    match self {
140      SidebarMode::Commits => "commits",
141      SidebarMode::Stashes => "stashes",
142    }
143  }
144}
145
146/// Pure sidebar state. Use [`Self::new`] (or the [`Default`] impl below)
147/// to get the initial state that matches the previous `App::new_at`
148/// behaviour (open + unfocused + zero scroll + cold cache) — the
149/// `#[derive(Default)]` Copilot would normally synthesise here would
150/// set `open = false`, which contradicts both the doc above and
151/// `new()`. The hand-written `Default` keeps the contract single-sourced.
152#[derive(Debug)]
153pub struct SidebarState {
154  /// `true` when the sidebar is visible. On a narrow terminal the
155  /// renderer no longer hides it (pre-#188 behaviour) but stacks it
156  /// under the table instead — see [`Self::resolve_layout`]. Closing
157  /// the panel (`open = false`) is the only way to reclaim the full
158  /// width for the table.
159  pub open: bool,
160  /// Which side the sidebar sits on in the side-by-side layout
161  /// (issue #188). Seeded from `[tui] sidebar_position` at `App`
162  /// construction, toggled live by [`Self::toggle_position`] (`H`).
163  /// Ignored by the stacked layout (sidebar always at the bottom).
164  pub position: SidebarPosition,
165  /// How the sidebar is arranged relative to the table (issue #188).
166  /// Defaults to [`SidebarOrientation::Auto`] (width-driven); cycled
167  /// by [`Self::cycle_orientation`] (`V`). Runtime-only — not persisted
168  /// to `.gwm.toml`, unlike `position`.
169  pub orientation: SidebarOrientation,
170  /// `true` when keyboard navigation (`j` / `k`) targets the sidebar
171  /// (scrolling Recent Commits) instead of the worktree list.
172  /// Invariant: `focused` is `false` whenever `open` is `false`.
173  pub focused: bool,
174  /// First-visible line index of the Recent Commits section. Bumped
175  /// by [`Self::scroll_down`] / [`Self::scroll_up`]; reset to 0 by
176  /// [`Self::on_navigation`].
177  pub scroll: u16,
178  /// Upper bound for `scroll`, republished by the renderer every
179  /// frame against the actual rendered Recent Commits height. Used
180  /// by [`Self::scroll_down`] to clamp so the panel content can never
181  /// be pushed entirely off-screen.
182  pub max_scroll: u16,
183  /// Cached pre-rendered sections keyed by the selected worktree's
184  /// path **and** the active mode (issue #34). `None` = cold cache
185  /// (the renderer will rebuild and store). Invalidated on selection
186  /// change ([`Self::on_navigation`]), worktree list mutation
187  /// (`App::refresh` calls [`Self::invalidate`]), filter narrowing
188  /// (`App::filter_push_char` / `filter_pop_char`), and mode toggle
189  /// ([`Self::cycle_mode`]). Two-tuple key so a re-toggle re-shells
190  /// `git stash list` / `git log` rather than serving stale content
191  /// for the other mode.
192  pub cache: Option<((PathBuf, SidebarMode), SidebarSections)>,
193  /// Active preview mode. Defaults to [`SidebarMode::Commits`] so the
194  /// pre-#34 sidebar behaviour is unchanged until the user presses
195  /// `s`. Toggled by [`Self::cycle_mode`].
196  pub mode: SidebarMode,
197}
198
199impl Default for SidebarState {
200  fn default() -> Self {
201    Self::new()
202  }
203}
204
205impl SidebarState {
206  pub fn new() -> Self {
207    Self {
208      open: true,
209      position: SidebarPosition::default(),
210      orientation: SidebarOrientation::default(),
211      focused: false,
212      scroll: 0,
213      max_scroll: 0,
214      cache: None,
215      mode: SidebarMode::Commits,
216    }
217  }
218
219  /// Resolve the concrete layout for a frame of `width` columns from
220  /// the current visibility, orientation, and position. Pure and
221  /// ratatui-free so the width contract is unit-testable:
222  ///
223  /// - closed → [`ResolvedSidebarLayout::Hidden`];
224  /// - `Auto` → side-by-side at `width >= SIDEBAR_MIN_WIDTH`, else
225  ///   stacked;
226  /// - `SideBySide` / `Stacked` → that layout regardless of width.
227  ///
228  /// In a side-by-side result `sidebar_left` mirrors [`Self::position`].
229  pub fn resolve_layout(&self, width: u16) -> ResolvedSidebarLayout {
230    if !self.open {
231      return ResolvedSidebarLayout::Hidden;
232    }
233    let side_by_side = ResolvedSidebarLayout::SideBySide {
234      sidebar_left: self.position.is_left(),
235    };
236    match self.orientation {
237      SidebarOrientation::SideBySide => side_by_side,
238      SidebarOrientation::Stacked => ResolvedSidebarLayout::Stacked,
239      SidebarOrientation::Auto => {
240        if width >= SIDEBAR_MIN_WIDTH {
241          side_by_side
242        } else {
243          ResolvedSidebarLayout::Stacked
244        }
245      }
246    }
247  }
248
249  /// Cycle the orientation `Auto → SideBySide → Stacked → Auto`
250  /// (issue #188, `V`). The cache survives — orientation changes the
251  /// frame geometry, not the previewed git content.
252  pub fn cycle_orientation(&mut self) {
253    self.orientation = self.orientation.next();
254  }
255
256  /// Flip the side-by-side position left ↔ right (issue #188, `H`).
257  /// The cache survives for the same reason as [`Self::cycle_orientation`].
258  pub fn toggle_position(&mut self) {
259    self.position = match self.position {
260      SidebarPosition::Left => SidebarPosition::Right,
261      SidebarPosition::Right => SidebarPosition::Left,
262    };
263  }
264
265  /// Cycle the preview mode (issue #34). Pre-#34 the sidebar only
266  /// ever showed `git log` + `git status`; now `s` flips between
267  /// `Commits` and `Stashes`. The scroll offset resets to 0 because
268  /// the new content has its own length and the previous offset
269  /// becomes meaningless. The cache is invalidated because the key
270  /// (path + mode) changes — the new mode re-shells the right git
271  /// command on the next frame.
272  pub fn cycle_mode(&mut self) {
273    self.mode = match self.mode {
274      SidebarMode::Commits => SidebarMode::Stashes,
275      SidebarMode::Stashes => SidebarMode::Commits,
276    };
277    self.scroll = 0;
278    self.cache = None;
279  }
280
281  /// Navigation-driven reset: drop the scroll back to the top AND
282  /// invalidate the cache so the new selection's preview renders fresh.
283  /// Paired with `App::refresh_link()` inside `App::on_navigation` to
284  /// collapse the pre-extraction `sidebar_scroll = 0;
285  /// invalidate_sidebar_cache(); refresh_link();` triple that the
286  /// `App` body repeated 4+ times across `next` / `prev` / `first` /
287  /// `last`.
288  ///
289  /// Deliberately does NOT touch `open`, `focused`, or `max_scroll`:
290  /// navigation moves selection within the existing layout; visibility
291  /// is a separate concern owned by the toggle methods, and
292  /// `max_scroll` is owned by the renderer (a stale value resets
293  /// itself on the next frame anyway).
294  pub fn on_navigation(&mut self) {
295    self.scroll = 0;
296    self.cache = None;
297  }
298
299  /// Standalone cache flush. Used outside the navigation path —
300  /// `App::refresh` after the worktrees list mutates, and the filter
301  /// `push_char` / `pop_char` wrappers that re-narrow the visible set
302  /// without moving the cursor. Scroll state survives so a user
303  /// scrolled halfway through the preview keeps their viewport.
304  pub fn invalidate(&mut self) {
305    self.cache = None;
306  }
307
308  /// Scroll the Recent Commits viewport down by one line, clamped at
309  /// `max_scroll`. The clamp is the load-bearing invariant — without
310  /// it, `j` on a focused sidebar would walk the content entirely off
311  /// the bottom of the panel.
312  pub fn scroll_down(&mut self) {
313    self.scroll = self.scroll.saturating_add(1).min(self.max_scroll);
314  }
315
316  /// Scroll the Recent Commits viewport up by one line, saturating at
317  /// 0. Matches `k`-on-sidebar; safe to call from `scroll == 0`.
318  pub fn scroll_up(&mut self) {
319    self.scroll = self.scroll.saturating_sub(1);
320  }
321
322  /// Flip `open`. When closing, also drops `focused` — a hidden
323  /// sidebar can never hold the navigation focus, so the worktree
324  /// list takes back `j` / `k` automatically. Status-bar copy is the
325  /// `App` orchestrator's concern.
326  pub fn toggle_open(&mut self) {
327    self.open = !self.open;
328    if !self.open {
329      self.focused = false;
330    }
331  }
332
333  /// Flip `focused`. No-op when the sidebar is closed — focus cannot
334  /// move to a hidden panel. Matches the `Tab` keybinding semantics.
335  pub fn toggle_focus(&mut self) {
336    if !self.open {
337      return;
338    }
339    self.focused = !self.focused;
340  }
341
342  /// Direct-focus the worktree table (issue #217, `1`). Releases the
343  /// sidebar's navigation focus so `j` / `k` walk the worktree list. The
344  /// sidebar stays open — `1` is about *where the cursor is*, not
345  /// visibility (that's `v` / [`Self::toggle_open`]).
346  pub fn focus_table(&mut self) {
347    self.focused = false;
348  }
349
350  /// Direct-focus the status (sidebar) pane (issue #217, `2`). Opens the
351  /// sidebar if it was closed and moves the navigation focus onto it, so a
352  /// single keystroke both reveals and targets the pane.
353  pub fn focus_panel(&mut self) {
354    self.open = true;
355    self.focused = true;
356  }
357}