1pub mod view;
5
6use gw_parse::{Entry, Status};
7use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8
9use crate::status::{Group, LineStats, groups};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Item {
14 pub group: Group,
15 pub entry: Entry,
16 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
30pub 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 PrepareRevert(Item),
65 Revert(Item),
66 Commit,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum Overlay {
71 None,
72 Confirm { item: Item, files: Vec<String>, total: usize },
74}
75
76#[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 pub scroll: usize,
88 pub focus: Focus,
89 pub diff: Vec<String>,
91 pub diff_for: Option<(Group, String)>,
92 pub diff_scroll: usize,
93 pub overlay: Overlay,
94 pub status: Option<(String, bool)>,
96 pub prefix: String,
98 pub head: String,
100 pub width: u16,
101 pub height: u16,
102 pub color: bool,
104}
105
106pub 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 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 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 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 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 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 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 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}