Skip to main content

gw_render/stage/
mod.rs

1//! `gw status -i`: pure state + key handling for the interactive staging view.
2//! Git work is requested through [`Effect`]s; the binary runs them and feeds results back.
3
4pub mod view;
5
6use gw_parse::{Entry, Status};
7use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8
9use crate::status::{Group, LineStats, groups};
10
11/// One selectable list entry. The same path can appear in two groups (staged and changed again).
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Item {
14    pub group: Group,
15    pub entry: Entry,
16    /// `+N -M` for the diff title; `Some(None)` binary, `None` unknown.
17    pub stat: Option<Option<(u64, u64)>>,
18}
19
20impl Item {
21    pub fn key(&self) -> (Group, &str) {
22        (self.group, &self.entry.path)
23    }
24
25    pub fn code(&self) -> char {
26        self.group.code(&self.entry)
27    }
28}
29
30/// Builds the list in `gw status` order.
31pub fn items(st: &Status, stats: &LineStats) -> Vec<Item> {
32    groups(st)
33        .into_iter()
34        .flat_map(|(group, entries)| {
35            entries.into_iter().map(move |e| {
36                let stat = match group {
37                    Group::Staged => stats.staged.get(&e.path).copied(),
38                    Group::Changed => stats.unstaged.get(&e.path).copied(),
39                    Group::Untracked => stats.untracked.get(&e.path).copied(),
40                    _ => None,
41                };
42                Item { group, entry: e.clone(), stat }
43            })
44        })
45        .collect()
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Focus {
50    List,
51    Diff,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum Effect {
56    None,
57    Quit,
58    Reload,
59    Stage(Item),
60    Unstage(Item),
61    StageAll,
62    UnstageAll,
63    /// Collect what a revert would delete, then call [`App::confirm_revert`].
64    PrepareRevert(Item),
65    Revert(Item),
66    Commit,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum Overlay {
71    None,
72    /// `files`: up to 10 paths that will be deleted, `total` their full count (untracked dirs).
73    Confirm { item: Item, files: Vec<String>, total: usize },
74}
75
76/// A list line: a group header (with its count) or an item index.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Row {
79    Header(Group, usize),
80    Item(usize),
81}
82
83pub struct App {
84    pub items: Vec<Item>,
85    pub sel: usize,
86    /// First visible list row.
87    pub scroll: usize,
88    pub focus: Focus,
89    /// Diff lines for `diff_for`.
90    pub diff: Vec<String>,
91    pub diff_for: Option<(Group, String)>,
92    pub diff_scroll: usize,
93    pub overlay: Overlay,
94    /// (message, is_error)
95    pub status: Option<(String, bool)>,
96    /// `git rev-parse --show-prefix`, for cwd-relative display.
97    pub prefix: String,
98    /// Branch line shown in the list title.
99    pub head: String,
100    pub width: u16,
101    pub height: u16,
102    /// False under `NO_COLOR`.
103    pub color: bool,
104}
105
106/// Width under which only the focused pane is shown.
107pub const NARROW: u16 = 80;
108
109impl App {
110    pub fn new(items: Vec<Item>, prefix: String, head: String) -> Self {
111        App {
112            items,
113            sel: 0,
114            scroll: 0,
115            focus: Focus::List,
116            diff: Vec::new(),
117            diff_for: None,
118            diff_scroll: 0,
119            overlay: Overlay::None,
120            status: None,
121            prefix,
122            head,
123            width: 80,
124            height: 24,
125            color: true,
126        }
127    }
128
129    pub fn resize(&mut self, w: u16, h: u16) {
130        self.width = w;
131        self.height = h;
132        self.keep_visible();
133    }
134
135    /// Lines inside a pane: screen minus status line, hotkey bar and borders.
136    pub fn body_height(&self) -> usize {
137        (self.height as usize).saturating_sub(4).max(1)
138    }
139
140    pub fn selected(&self) -> Option<&Item> {
141        self.items.get(self.sel)
142    }
143
144    pub fn rows(&self) -> Vec<Row> {
145        let mut rows = Vec::new();
146        let mut i = 0;
147        while i < self.items.len() {
148            let g = self.items[i].group;
149            let n = self.items[i..].iter().take_while(|it| it.group == g).count();
150            rows.push(Row::Header(g, n));
151            rows.extend((i..i + n).map(Row::Item));
152            i += n;
153        }
154        rows
155    }
156
157    fn sel_row(&self) -> usize {
158        self.rows().iter().position(|r| *r == Row::Item(self.sel)).unwrap_or(0)
159    }
160
161    fn keep_visible(&mut self) {
162        let h = self.body_height();
163        let row = self.sel_row();
164        // Show the group header above the first entry.
165        let top = if self.sel == 0 { 0 } else { row };
166        if top < self.scroll {
167            self.scroll = top;
168        } else if row >= self.scroll + h {
169            self.scroll = row + 1 - h;
170        }
171    }
172
173    /// The item whose diff should be shown, when it is not loaded yet.
174    pub fn diff_wanted(&self) -> Option<&Item> {
175        let it = self.selected()?;
176        let loaded = self.diff_for.as_ref().is_some_and(|(g, p)| *g == it.group && *p == it.entry.path);
177        (!loaded).then_some(it)
178    }
179
180    pub fn set_diff(&mut self, item: &Item, lines: Vec<String>) {
181        self.diff = lines;
182        self.diff_for = Some((item.group, item.entry.path.clone()));
183        self.diff_scroll = 0;
184    }
185
186    /// New list after a reload: keep the same entry selected (same group and path, else same
187    /// path in another group, else the nearest position).
188    pub fn replace_items(&mut self, items: Vec<Item>) {
189        let old = self.selected().map(|it| (it.group, it.entry.path.clone()));
190        let old_sel = self.sel;
191        self.items = items;
192        self.sel = old
193            .and_then(|(g, p)| {
194                self.items
195                    .iter()
196                    .position(|it| it.group == g && it.entry.path == p)
197                    .or_else(|| self.items.iter().position(|it| it.entry.path == p))
198            })
199            .unwrap_or(old_sel.min(self.items.len().saturating_sub(1)));
200        self.diff_for = None;
201        if self.items.is_empty() {
202            self.diff.clear();
203            self.focus = Focus::List;
204        }
205        self.keep_visible();
206    }
207
208    pub fn confirm_revert(&mut self, item: Item, files: Vec<String>, total: usize) {
209        self.overlay = Overlay::Confirm { item, files, total };
210    }
211
212    pub fn key(&mut self, k: KeyEvent) -> Effect {
213        let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
214        if ctrl && k.code == KeyCode::Char('c') {
215            return Effect::Quit;
216        }
217        if let Overlay::Confirm { item, .. } = &self.overlay {
218            let item = item.clone();
219            self.overlay = Overlay::None;
220            return if k.code == KeyCode::Char('y') { Effect::Revert(item) } else { Effect::None };
221        }
222        self.status = None;
223        if ctrl && k.code == KeyCode::Char('r') {
224            return Effect::Reload;
225        }
226        match k.code {
227            KeyCode::Char('q') => return Effect::Quit,
228            KeyCode::Tab | KeyCode::BackTab => {
229                if !self.items.is_empty() {
230                    self.focus = if self.focus == Focus::List { Focus::Diff } else { Focus::List };
231                }
232                return Effect::None;
233            }
234            KeyCode::Esc if self.focus == Focus::Diff => {
235                self.focus = Focus::List;
236                return Effect::None;
237            }
238            KeyCode::Esc => return Effect::Quit,
239            _ => {}
240        }
241        let page = self.body_height() as isize;
242        let delta = match k.code {
243            KeyCode::Up | KeyCode::Char('k') => Some(-1),
244            KeyCode::Down | KeyCode::Char('j') => Some(1),
245            KeyCode::PageUp => Some(-page),
246            KeyCode::PageDown => Some(page),
247            KeyCode::Home | KeyCode::Char('g') => Some(isize::MIN / 2),
248            KeyCode::End | KeyCode::Char('G') => Some(isize::MAX / 2),
249            _ => None,
250        };
251        if let Some(d) = delta {
252            if self.focus == Focus::Diff {
253                let max = self.diff.len().saturating_sub(self.body_height()) as isize;
254                self.diff_scroll = (self.diff_scroll as isize).saturating_add(d).clamp(0, max.max(0)) as usize;
255            } else if !self.items.is_empty() {
256                self.sel = (self.sel as isize).saturating_add(d).clamp(0, self.items.len() as isize - 1) as usize;
257                self.keep_visible();
258            }
259            return Effect::None;
260        }
261        match k.code {
262            KeyCode::Char('a') => Effect::StageAll,
263            KeyCode::Char('u') => Effect::UnstageAll,
264            KeyCode::Char('c') if !self.items.iter().any(|it| it.group == Group::Staged) => {
265                self.status = Some(("nothing staged to commit".into(), true));
266                Effect::None
267            }
268            KeyCode::Char('c') => Effect::Commit,
269            KeyCode::Char(' ') => match self.selected() {
270                Some(it) if it.group == Group::Staged => Effect::Unstage(it.clone()),
271                Some(it) => Effect::Stage(it.clone()),
272                None => Effect::None,
273            },
274            KeyCode::Char('r') => match self.selected() {
275                Some(it) if it.group == Group::Conflicts => {
276                    self.status = Some(("conflicts can't be reverted here: resolve them, or abort the merge".into(), true));
277                    Effect::None
278                }
279                Some(it) if it.group == Group::Submodules => {
280                    self.status = Some(("submodules can't be reverted here: update or reset inside the submodule".into(), true));
281                    Effect::None
282                }
283                Some(it) => Effect::PrepareRevert(it.clone()),
284                None => Effect::None,
285            },
286            _ => Effect::None,
287        }
288    }
289
290    pub fn hotkeys(&self) -> Vec<(&'static str, &'static str)> {
291        match (&self.overlay, self.focus) {
292            (Overlay::Confirm { .. }, _) => vec![("y", "Revert"), ("any key", "Cancel")],
293            (Overlay::None, Focus::Diff) => vec![("j/k", "Scroll"), ("PgUp/PgDn", "Page"), ("Tab", "Files"), ("Esc", "Back"), ("q", "Quit")],
294            (Overlay::None, Focus::List) => vec![
295                ("Space", "Stage/Unstage"),
296                ("a", "Stage all"),
297                ("u", "Unstage all"),
298                ("r", "Revert"),
299                ("c", "Commit"),
300                ("Tab", "Diff"),
301                ("^R", "Reload"),
302                ("q", "Quit"),
303            ],
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    fn e(x: char, y: char, path: &str) -> Entry {
313        Entry { x, y, unmerged: false, path: path.into(), orig: None, submodule: None }
314    }
315
316    fn st(entries: Vec<Entry>) -> Status {
317        Status { entries, ..Default::default() }
318    }
319
320    fn key(c: KeyCode) -> KeyEvent {
321        KeyEvent::new(c, KeyModifiers::NONE)
322    }
323
324    fn ch(c: char) -> KeyEvent {
325        key(KeyCode::Char(c))
326    }
327
328    /// Staged a.rs (also changed), changed b.rs, untracked n.txt, conflict u.rs.
329    fn app() -> App {
330        let mut conflict = e('U', 'U', "u.rs");
331        conflict.unmerged = true;
332        let s = st(vec![e('M', 'M', "a.rs"), e('.', 'M', "b.rs"), e('?', '?', "n.txt"), conflict]);
333        let mut a = App::new(items(&s, &LineStats::default()), String::new(), "main".into());
334        a.resize(100, 30);
335        a
336    }
337
338    fn sel(a: &App) -> (Group, &str) {
339        a.selected().unwrap().key()
340    }
341
342    #[test]
343    fn list_order_and_headers() {
344        let a = app();
345        let keys: Vec<_> = a.items.iter().map(|i| i.key()).collect();
346        assert_eq!(
347            keys,
348            vec![(Group::Conflicts, "u.rs"), (Group::Staged, "a.rs"), (Group::Changed, "a.rs"), (Group::Changed, "b.rs"), (Group::Untracked, "n.txt")]
349        );
350        let rows = a.rows();
351        assert_eq!(rows[0], Row::Header(Group::Conflicts, 1));
352        assert_eq!(rows[4], Row::Header(Group::Changed, 2));
353        assert_eq!(rows.len(), 9);
354    }
355
356    #[test]
357    fn navigation_skips_headers_and_clamps() {
358        let mut a = app();
359        a.key(ch('j'));
360        assert_eq!(sel(&a), (Group::Staged, "a.rs"));
361        a.key(ch('j'));
362        assert_eq!(sel(&a), (Group::Changed, "a.rs"), "last Staged -> first Changed, header skipped");
363        a.key(ch('G'));
364        assert_eq!(sel(&a), (Group::Untracked, "n.txt"));
365        a.key(ch('j'));
366        assert_eq!(sel(&a), (Group::Untracked, "n.txt"));
367        a.key(key(KeyCode::PageUp));
368        assert_eq!(a.sel, 0);
369    }
370
371    #[test]
372    fn clean_tree_has_no_selection() {
373        let mut a = App::new(vec![], String::new(), "main".into());
374        assert!(a.selected().is_none());
375        assert_eq!(a.key(ch(' ')), Effect::None);
376        assert_eq!(a.key(ch('r')), Effect::None);
377        a.key(key(KeyCode::Tab));
378        assert_eq!(a.focus, Focus::List);
379    }
380
381    #[test]
382    fn space_stages_or_unstages_by_group() {
383        let mut a = app();
384        a.key(ch('j'));
385        assert!(matches!(a.key(ch(' ')), Effect::Unstage(it) if it.key() == (Group::Staged, "a.rs")));
386        a.key(ch('j'));
387        assert!(matches!(a.key(ch(' ')), Effect::Stage(it) if it.key() == (Group::Changed, "a.rs")));
388        a.key(ch('g'));
389        assert!(matches!(a.key(ch(' ')), Effect::Stage(it) if it.group == Group::Conflicts), "staging marks resolved");
390        assert_eq!(a.key(ch('a')), Effect::StageAll);
391        assert_eq!(a.key(ch('u')), Effect::UnstageAll);
392        assert_eq!(a.key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)), Effect::Reload);
393    }
394
395    #[test]
396    fn revert_rules() {
397        let mut a = app();
398        assert_eq!(a.key(ch('r')), Effect::None, "conflict");
399        assert!(a.status.as_ref().unwrap().0.contains("conflicts"));
400        a.key(ch('G'));
401        let Effect::PrepareRevert(it) = a.key(ch('r')) else { panic!() };
402        a.confirm_revert(it, vec!["n.txt".into()], 1);
403        assert_eq!(a.hotkeys()[0], ("y", "Revert"));
404        assert!(matches!(a.key(ch('y')), Effect::Revert(it) if it.key() == (Group::Untracked, "n.txt")));
405        assert_eq!(a.overlay, Overlay::None);
406        for cancel in [ch('n'), key(KeyCode::Esc)] {
407            let Effect::PrepareRevert(it) = a.key(ch('r')) else { panic!() };
408            a.confirm_revert(it, vec![], 0);
409            assert_eq!(a.key(cancel), Effect::None);
410            assert_eq!(a.overlay, Overlay::None);
411        }
412    }
413
414    #[test]
415    fn submodule_not_revertable() {
416        let mut s = e('.', 'M', "lib");
417        s.submodule = Some(gw_parse::SubmoduleState { commit_changed: true, modified: false, untracked: false, head: "a".into(), index: "a".into() });
418        let mut a = App::new(items(&st(vec![s]), &LineStats::default()), String::new(), "main".into());
419        assert_eq!(a.key(ch('r')), Effect::None);
420        assert!(a.status.as_ref().unwrap().0.contains("submodule"));
421    }
422
423    #[test]
424    fn commit_needs_staged() {
425        let mut a = app();
426        assert_eq!(a.key(ch('c')), Effect::Commit);
427        let mut b = App::new(items(&st(vec![e('.', 'M', "b.rs")]), &LineStats::default()), String::new(), "main".into());
428        assert_eq!(b.key(ch('c')), Effect::None);
429        assert_eq!(b.status.as_ref().unwrap().0, "nothing staged to commit");
430    }
431
432    #[test]
433    fn reload_keeps_selection() {
434        let mut a = app();
435        a.key(ch('j'));
436        a.key(ch('j'));
437        a.key(ch('j'));
438        assert_eq!(sel(&a), (Group::Changed, "b.rs"));
439        // b.rs staged: it moves to Staged and stays selected.
440        let mut c = e('U', 'U', "u.rs");
441        c.unmerged = true;
442        a.replace_items(items(&st(vec![e('M', 'M', "a.rs"), e('M', '.', "b.rs"), e('?', '?', "n.txt"), c]), &LineStats::default()));
443        assert_eq!(sel(&a), (Group::Staged, "b.rs"));
444        // Last entry reverted: selection moves to the new last one.
445        a.key(ch('G'));
446        a.replace_items(items(&st(vec![e('M', '.', "a.rs")]), &LineStats::default()));
447        assert_eq!(sel(&a), (Group::Staged, "a.rs"));
448        a.replace_items(vec![]);
449        assert!(a.selected().is_none());
450    }
451
452    #[test]
453    fn diff_focus_scrolls_without_moving_selection() {
454        let mut a = app();
455        a.resize(100, 10);
456        let it = a.selected().unwrap().clone();
457        assert!(a.diff_wanted().is_some());
458        a.set_diff(&it, (0..50).map(|i| format!("+{i}")).collect());
459        assert!(a.diff_wanted().is_none());
460        a.key(key(KeyCode::Tab));
461        a.key(key(KeyCode::PageDown));
462        assert_eq!(a.diff_scroll, 6);
463        assert_eq!(a.sel, 0);
464        a.key(ch('G'));
465        assert_eq!(a.diff_scroll, 44);
466        a.key(key(KeyCode::Esc));
467        assert_eq!(a.focus, Focus::List);
468        assert_eq!(a.key(key(KeyCode::Esc)), Effect::Quit);
469    }
470}