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::tui::ui::SidebarSections;
35use std::path::PathBuf;
36
37/// Re-exported from [`crate::config`], where both sidebar knobs live now
38/// that the orientation is persisted to `.gwm.toml` alongside the
39/// position (issue #365). Kept re-exported here so the state module
40/// still reads as the owner of the sidebar contract.
41pub use crate::config::{SidebarOrientation, SidebarPosition};
42
43/// Minimum total terminal width (in columns) required to render the
44/// sidebar *beside* the worktree table without squeezing the table
45/// beyond readability. At or above this width the `Auto` orientation
46/// picks the side-by-side split; below it, `Auto` stacks the sidebar
47/// under the table (issue #188) rather than hiding it (pre-#188).
48pub const SIDEBAR_MIN_WIDTH: u16 = 120;
49
50/// The concrete layout the renderer should draw for the current frame,
51/// resolved from `open` + orientation + position + terminal width by
52/// [`SidebarState::resolve_layout`]. Kept ratatui-free so the decision
53/// is unit-testable against the width contract without a backend.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ResolvedSidebarLayout {
56  /// Sidebar closed — draw the worktree table full-area.
57  Hidden,
58  /// Side-by-side split. `sidebar_left` mirrors [`SidebarPosition`]:
59  /// `true` draws the sidebar on the left of the table, `false` on the
60  /// right.
61  SideBySide { sidebar_left: bool },
62  /// Stacked split — table on top, sidebar below.
63  Stacked,
64}
65
66impl ResolvedSidebarLayout {
67  /// The `(table_pct, sidebar_pct)` split this layout draws, or `None`
68  /// when the sidebar is hidden (the table takes the whole area). Issue
69  /// #217 tuned the ratios per axis: stacked vertically the status pane
70  /// gets the larger share (42% table / 58% status) so commits + issue/PR
71  /// have room; side-by-side the table stays dominant (55% / 45%). Pure +
72  /// ratatui-free so the contract is pinned without a backend.
73  pub fn split_percentages(self) -> Option<(u16, u16)> {
74    match self {
75      ResolvedSidebarLayout::Hidden => None,
76      ResolvedSidebarLayout::SideBySide { .. } => Some((55, 45)),
77      ResolvedSidebarLayout::Stacked => Some((42, 58)),
78    }
79  }
80}
81
82/// Which content the sidebar previews (issue #34).
83///
84/// Toggled with the `s` key in the list view, dispatched through
85/// `Action::ToggleSidebarMode` in the rebindable keymap. Default is
86/// `Commits` so the pre-#34 sidebar behaviour is preserved verbatim.
87/// The mode is per-session — not persisted across `gwm` launches —
88/// because the low-frequency need to view stashes does not justify a
89/// new `.gwm.toml` knob.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91pub enum SidebarMode {
92  /// `git log --oneline -n 10` + `git status --short`. Pre-#34
93  /// behaviour, kept as the default so existing users see no change
94  /// until they press `s`.
95  Commits,
96  /// `git stash list` + a per-stash quick view. New in #34.
97  Stashes,
98}
99
100impl SidebarMode {
101  /// Human-readable label rendered into the sidebar title bar
102  /// (` Details — commits ` vs. ` Details — stashes `).
103  pub fn label(self) -> &'static str {
104    match self {
105      SidebarMode::Commits => "commits",
106      SidebarMode::Stashes => "stashes",
107    }
108  }
109}
110
111/// Split the sidebar height left over for the three variable sections —
112/// Agents / Working Tree / Recent Commits — into per-section block
113/// heights, borders included (issue #438). Pure and ratatui-free so the
114/// policy is unit-testable without a backend, like [`SidebarState::resolve_layout`].
115///
116/// - When every natural height (`content + 2` borders) fits, each section
117///   keeps it and Recent Commits absorbs the slack — the exact behaviour
118///   the old `Min(3)` constraint produced.
119/// - On overflow, every **visible** scrollable section (Working Tree,
120///   Recent Commits) is guaranteed `min(natural, 5)` lines (border + at
121///   least 3 content rows) and the surplus is distributed proportionally
122///   to content size, capped at the natural height; the integer-division
123///   residue cascades to Recent Commits, then Working Tree, then Agents.
124///   The total need exceeds the surplus by construction, so the residue
125///   is always absorbed.
126/// - The Agents pane has **no scroll**, so clamping it below its content
127///   would permanently hide the trailing `+N more` row — it keeps its
128///   natural height outright (Codex review, PR #454). Safe: the pane is
129///   bounded to 4 content rows by construction (`agent_pane_lines` caps
130///   at 3 pinned rows plus the overflow indicator).
131/// - A section with no content stays collapsed at 0 (Agents with no
132///   session, Working Tree on a clean tree) and never eats a floor.
133///   Recent Commits is never collapsed: an empty history still renders
134///   its bordered panel, floored at the historical `Min(3)`.
135/// - Below the floors' sum (tiny terminal) sections are served in the
136///   order commits → working tree → agents with whatever remains.
137pub fn split_section_heights(available: u16, agents_len: u16, wt_len: u16, commits_len: u16) -> (u16, u16, u16) {
138  let natural = |len: u16| if len == 0 { 0 } else { len.saturating_add(2) };
139  let natural_a = natural(agents_len);
140  let natural_w = natural(wt_len);
141  // Commits is never collapsed and its natural height is floored at 3 —
142  // the old `Min(3)` rendered an empty bordered panel at 3 lines anyway.
143  // Raising the *natural* height (not just the floor) keeps the
144  // `floor <= natural` invariant the sharing math below relies on
145  // (Codex review, PR #454: an empty history with `floor_c = 3` made
146  // `nat - floor` underflow).
147  let natural_c = commits_len.saturating_add(2).max(3);
148
149  if natural_a as u32 + natural_w as u32 + natural_c as u32 <= available as u32 {
150    return (natural_a, natural_w, available - natural_a - natural_w);
151  }
152
153  let floor_a = natural_a;
154  // Working Tree gets a taller floor than Recent Commits (7 = border +
155  // 5 content rows): validation feedback on PR #455 — the shared 5-line
156  // floor read too small for a file tree in the field.
157  let floor_w = natural_w.min(7);
158  let floor_c = natural_c.min(5);
159
160  let base = floor_a + floor_w + floor_c;
161  if base > available {
162    let c = floor_c.min(available);
163    let rest = available - c;
164    let w = floor_w.min(rest);
165    let a = floor_a.min(rest - w);
166    return (a, w, c);
167  }
168
169  let surplus = available - base;
170  let total = agents_len as u32 + wt_len as u32 + commits_len as u32;
171  let give = |len: u16, floor_v: u16, nat: u16| -> u16 {
172    if total == 0 {
173      return 0;
174    }
175    ((surplus as u32 * len as u32 / total) as u16).min(nat - floor_v)
176  };
177  let mut a = floor_a + give(agents_len, floor_a, natural_a);
178  let mut w = floor_w + give(wt_len, floor_w, natural_w);
179  let mut c = floor_c + give(commits_len, floor_c, natural_c);
180  let mut residue = available - a - w - c;
181  let mut top_up = |h: &mut u16, nat: u16| {
182    let room = (nat - *h).min(residue);
183    *h += room;
184    residue -= room;
185  };
186  top_up(&mut c, natural_c);
187  top_up(&mut w, natural_w);
188  top_up(&mut a, natural_a);
189  (a, w, c)
190}
191
192/// Pure sidebar state. Use [`Self::new`] (or the [`Default`] impl below)
193/// to get the initial state that matches the previous `App::new_at`
194/// behaviour (open + unfocused + zero scroll + cold cache) — the
195/// `#[derive(Default)]` Copilot would normally synthesise here would
196/// set `open = false`, which contradicts both the doc above and
197/// `new()`. The hand-written `Default` keeps the contract single-sourced.
198#[derive(Debug)]
199pub struct SidebarState {
200  /// `true` when the sidebar is visible. On a narrow terminal the
201  /// renderer no longer hides it (pre-#188 behaviour) but stacks it
202  /// under the table instead — see [`Self::resolve_layout`]. Closing
203  /// the panel (`open = false`) is the only way to reclaim the full
204  /// width for the table.
205  pub open: bool,
206  /// Which side the sidebar sits on in the side-by-side layout
207  /// (issue #188). Seeded from `[tui] sidebar_position` at `App`
208  /// construction, toggled live by [`Self::toggle_position`] (`H`).
209  /// Ignored by the stacked layout (sidebar always at the bottom).
210  pub position: SidebarPosition,
211  /// How the sidebar is arranged relative to the table (issue #188).
212  /// Defaults to [`SidebarOrientation::Stacked`] since #217 — *not*
213  /// `Auto`, which this comment claimed until #365. Seeded from
214  /// `[tui] sidebar_orientation` at `App` construction and re-seeded on
215  /// config reload, exactly like `position`; cycled live by
216  /// [`Self::cycle_orientation`].
217  pub orientation: SidebarOrientation,
218  /// `true` when keyboard navigation (`j` / `k`) targets the sidebar
219  /// (scrolling Recent Commits) instead of the worktree list.
220  /// Invariant: `focused` is `false` whenever `open` is `false`.
221  pub focused: bool,
222  /// First-visible line index of the Recent Commits section. Bumped
223  /// by [`Self::scroll_down`] / [`Self::scroll_up`]; reset to 0 by
224  /// [`Self::on_navigation`].
225  pub scroll: u16,
226  /// Upper bound for `scroll`, republished by the renderer every
227  /// frame against the actual rendered Recent Commits height. Used
228  /// by [`Self::scroll_down`] to clamp so the panel content can never
229  /// be pushed entirely off-screen.
230  pub max_scroll: u16,
231  /// First-visible line index of the Working Tree section (issue
232  /// #437). Independent from `scroll` — the file tree and the commit
233  /// list overflow at different rates. Bumped by
234  /// [`Self::wt_scroll_down`] / [`Self::wt_scroll_up`]; reset to 0 by
235  /// [`Self::on_navigation`] and [`Self::cycle_mode`].
236  pub wt_scroll: u16,
237  /// Upper bound for `wt_scroll`, republished by the renderer every
238  /// frame against the Working Tree section's clamped viewport (the
239  /// layout solver may hand the section less height than its content
240  /// on a large change set — exactly the case #437 exists for).
241  pub wt_max_scroll: u16,
242  /// Cached pre-rendered sections keyed by the selected worktree's
243  /// path **and** the active mode (issue #34). `None` = cold cache
244  /// (the renderer will rebuild and store). Invalidated on selection
245  /// change ([`Self::on_navigation`]), worktree list mutation
246  /// (`App::refresh` calls [`Self::invalidate`]), filter narrowing
247  /// (`App::filter_push_char` / `filter_pop_char`), and mode toggle
248  /// ([`Self::cycle_mode`]). Two-tuple key so a re-toggle re-shells
249  /// `git stash list` / `git log` rather than serving stale content
250  /// for the other mode.
251  pub cache: Option<((PathBuf, SidebarMode), SidebarSections)>,
252  /// Active preview mode. Defaults to [`SidebarMode::Commits`] so the
253  /// pre-#34 sidebar behaviour is unchanged until the user presses
254  /// `s`. Toggled by [`Self::cycle_mode`].
255  pub mode: SidebarMode,
256}
257
258impl Default for SidebarState {
259  fn default() -> Self {
260    Self::new()
261  }
262}
263
264impl SidebarState {
265  pub fn new() -> Self {
266    Self {
267      open: true,
268      position: SidebarPosition::default(),
269      orientation: SidebarOrientation::default(),
270      focused: false,
271      scroll: 0,
272      max_scroll: 0,
273      wt_scroll: 0,
274      wt_max_scroll: 0,
275      cache: None,
276      mode: SidebarMode::Commits,
277    }
278  }
279
280  /// Resolve the concrete layout for a frame of `width` columns from
281  /// the current visibility, orientation, and position. Pure and
282  /// ratatui-free so the width contract is unit-testable:
283  ///
284  /// - closed → [`ResolvedSidebarLayout::Hidden`];
285  /// - `Auto` → side-by-side at `width >= SIDEBAR_MIN_WIDTH`, else
286  ///   stacked;
287  /// - `SideBySide` / `Stacked` → that layout regardless of width.
288  ///
289  /// In a side-by-side result `sidebar_left` mirrors [`Self::position`].
290  pub fn resolve_layout(&self, width: u16) -> ResolvedSidebarLayout {
291    if !self.open {
292      return ResolvedSidebarLayout::Hidden;
293    }
294    let side_by_side = ResolvedSidebarLayout::SideBySide {
295      sidebar_left: self.position.is_left(),
296    };
297    match self.orientation {
298      SidebarOrientation::SideBySide => side_by_side,
299      SidebarOrientation::Stacked => ResolvedSidebarLayout::Stacked,
300      SidebarOrientation::Auto => {
301        if width >= SIDEBAR_MIN_WIDTH {
302          side_by_side
303        } else {
304          ResolvedSidebarLayout::Stacked
305        }
306      }
307    }
308  }
309
310  /// Cycle the orientation `Auto → SideBySide → Stacked → Auto`
311  /// (issue #188, `V`). The cache survives — orientation changes the
312  /// frame geometry, not the previewed git content.
313  pub fn cycle_orientation(&mut self) {
314    self.orientation = self.orientation.next();
315  }
316
317  /// Flip the side-by-side position left ↔ right (issue #188, `H`).
318  /// The cache survives for the same reason as [`Self::cycle_orientation`].
319  pub fn toggle_position(&mut self) {
320    self.position = match self.position {
321      SidebarPosition::Left => SidebarPosition::Right,
322      SidebarPosition::Right => SidebarPosition::Left,
323    };
324  }
325
326  /// Cycle the preview mode (issue #34). Pre-#34 the sidebar only
327  /// ever showed `git log` + `git status`; now `s` flips between
328  /// `Commits` and `Stashes`. The scroll offset resets to 0 because
329  /// the new content has its own length and the previous offset
330  /// becomes meaningless. The cache is invalidated because the key
331  /// (path + mode) changes — the new mode re-shells the right git
332  /// command on the next frame.
333  pub fn cycle_mode(&mut self) {
334    self.mode = match self.mode {
335      SidebarMode::Commits => SidebarMode::Stashes,
336      SidebarMode::Stashes => SidebarMode::Commits,
337    };
338    self.scroll = 0;
339    self.wt_scroll = 0;
340    self.cache = None;
341  }
342
343  /// Navigation-driven reset: drop the scroll back to the top AND
344  /// invalidate the cache so the new selection's preview renders fresh.
345  /// Paired with `App::refresh_link()` inside `App::on_navigation` to
346  /// collapse the pre-extraction `sidebar_scroll = 0;
347  /// invalidate_sidebar_cache(); refresh_link();` triple that the
348  /// `App` body repeated 4+ times across `next` / `prev` / `first` /
349  /// `last`.
350  ///
351  /// Deliberately does NOT touch `open`, `focused`, or `max_scroll`:
352  /// navigation moves selection within the existing layout; visibility
353  /// is a separate concern owned by the toggle methods, and
354  /// `max_scroll` is owned by the renderer (a stale value resets
355  /// itself on the next frame anyway).
356  pub fn on_navigation(&mut self) {
357    self.scroll = 0;
358    self.wt_scroll = 0;
359    self.cache = None;
360  }
361
362  /// Standalone cache flush. Used outside the navigation path —
363  /// `App::refresh` after the worktrees list mutates, and the filter
364  /// `push_char` / `pop_char` wrappers that re-narrow the visible set
365  /// without moving the cursor. Scroll state survives so a user
366  /// scrolled halfway through the preview keeps their viewport.
367  pub fn invalidate(&mut self) {
368    self.cache = None;
369  }
370
371  /// Scroll the Recent Commits viewport down by one line, clamped at
372  /// `max_scroll`. The clamp is the load-bearing invariant — without
373  /// it, `j` on a focused sidebar would walk the content entirely off
374  /// the bottom of the panel.
375  pub fn scroll_down(&mut self) {
376    self.scroll = self.scroll.saturating_add(1).min(self.max_scroll);
377  }
378
379  /// Scroll the Recent Commits viewport up by one line, saturating at
380  /// 0. Matches `k`-on-sidebar; safe to call from `scroll == 0`.
381  pub fn scroll_up(&mut self) {
382    self.scroll = self.scroll.saturating_sub(1);
383  }
384
385  /// Scroll the Working Tree viewport down by one line, clamped at
386  /// `wt_max_scroll` (issue #437). Same clamp invariant as
387  /// [`Self::scroll_down`], applied to the file-tree section.
388  pub fn wt_scroll_down(&mut self) {
389    self.wt_scroll = self.wt_scroll.saturating_add(1).min(self.wt_max_scroll);
390  }
391
392  /// Scroll the Working Tree viewport up by one line, saturating at 0
393  /// (issue #437).
394  pub fn wt_scroll_up(&mut self) {
395    self.wt_scroll = self.wt_scroll.saturating_sub(1);
396  }
397
398  /// Flip `open`. When closing, also drops `focused` — a hidden
399  /// sidebar can never hold the navigation focus, so the worktree
400  /// list takes back `j` / `k` automatically. Status-bar copy is the
401  /// `App` orchestrator's concern.
402  pub fn toggle_open(&mut self) {
403    self.open = !self.open;
404    if !self.open {
405      self.focused = false;
406    }
407  }
408
409  /// Flip `focused`. No-op when the sidebar is closed — focus cannot
410  /// move to a hidden panel. Matches the `Tab` keybinding semantics.
411  pub fn toggle_focus(&mut self) {
412    if !self.open {
413      return;
414    }
415    self.focused = !self.focused;
416  }
417
418  /// Direct-focus the worktree table (issue #217, `1`). Releases the
419  /// sidebar's navigation focus so `j` / `k` walk the worktree list. The
420  /// sidebar stays open — `1` is about *where the cursor is*, not
421  /// visibility (that's `v` / [`Self::toggle_open`]).
422  pub fn focus_table(&mut self) {
423    self.focused = false;
424  }
425
426  /// Direct-focus the status (sidebar) pane (issue #217, `2`). Opens the
427  /// sidebar if it was closed and moves the navigation focus onto it, so a
428  /// single keystroke both reveals and targets the pane.
429  pub fn focus_panel(&mut self) {
430    self.open = true;
431    self.focused = true;
432  }
433}