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}