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}